diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_core_behavior.py new file mode 100644 index 000000000..2b1a34192 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_core_behavior.py @@ -0,0 +1,235 @@ +""" +Core behavior tests for $arrayElemAt expression. + +Tests basic positive/negative index access, duplicate values, 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 ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Positive Index]: $arrayElemAt returns the element at the given positive index. +POSITIVE_INDEX_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="first_element", + doc={"arr": [1, 2, 3], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=1, + msg="$arrayElemAt should return first element", + ), + ExpressionTestCase( + id="second_element", + doc={"arr": [1, 2, 3], "idx": 1}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=2, + msg="$arrayElemAt should return second element", + ), + ExpressionTestCase( + id="last_element", + doc={"arr": [1, 2, 3], "idx": 2}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=3, + msg="$arrayElemAt should return last element", + ), + ExpressionTestCase( + id="single_element_array", + doc={"arr": [42], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=42, + msg="$arrayElemAt should return single element", + ), + ExpressionTestCase( + id="string_elements", + doc={"arr": ["a", "b", "c"], "idx": 1}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected="b", + msg="$arrayElemAt should return string element", + ), + ExpressionTestCase( + id="mixed_types", + doc={"arr": [1, "two", 3.0, True], "idx": 2}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=3.0, + msg="$arrayElemAt should return element from mixed-type array", + ), + ExpressionTestCase( + id="nested_array_element", + doc={"arr": [[1, 2], [3, 4]], "idx": 1}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=[3, 4], + msg="$arrayElemAt should return nested array element", + ), + ExpressionTestCase( + id="nested_object_element", + doc={"arr": [{"a": 1}, {"b": 2}], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected={"a": 1}, + msg="$arrayElemAt should return nested object element", + ), + ExpressionTestCase( + id="null_element_in_array", + doc={"arr": [None, 1, 2], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=None, + msg="$arrayElemAt should return null element", + ), + ExpressionTestCase( + id="bool_element", + doc={"arr": [True, False], "idx": 1}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=False, + msg="$arrayElemAt should return bool element", + ), +] + +# Property [Negative Index]: $arrayElemAt counts from the end of the array for a negative index. +NEGATIVE_INDEX_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="last_via_neg1", + doc={"arr": [1, 2, 3], "idx": -1}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=3, + msg="$arrayElemAt should return last element via -1", + ), + ExpressionTestCase( + id="second_to_last", + doc={"arr": [1, 2, 3], "idx": -2}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=2, + msg="$arrayElemAt should return second to last", + ), + ExpressionTestCase( + id="first_via_neg_len", + doc={"arr": [1, 2, 3], "idx": -3}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=1, + msg="$arrayElemAt should return first via negative length", + ), + ExpressionTestCase( + id="single_element_neg1", + doc={"arr": [42], "idx": -1}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=42, + msg="$arrayElemAt should return single element via -1", + ), +] + +# Property [Duplicate Values]: $arrayElemAt selects by position, ignoring duplicate elements. +DUPLICATE_VALUE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="dup_first", + doc={"arr": [1, 1, 1], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=1, + msg="$arrayElemAt is unaffected by duplicate elements at index 0", + ), + ExpressionTestCase( + id="dup_last", + doc={"arr": [1, 1, 1], "idx": 2}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=1, + msg="$arrayElemAt is unaffected by duplicate elements at the last index", + ), + ExpressionTestCase( + id="dup_neg", + doc={"arr": ["a", "a", "b", "a"], "idx": -1}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected="a", + msg="$arrayElemAt is unaffected by duplicate elements at a negative index", + ), +] + +# Property [Large Array]: $arrayElemAt resolves positions within large arrays. +_LARGE_ARRAY_SIZE = 20_000 +_LARGE_ARRAY = list(range(_LARGE_ARRAY_SIZE)) + +# Property [Large Arrays]: $concatArrays concatenates large arrays. +LARGE_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="large_array_first", + doc={"arr": _LARGE_ARRAY, "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=0, + msg="$arrayElemAt should return first in large array", + ), + ExpressionTestCase( + id="large_array_last", + doc={"arr": _LARGE_ARRAY, "idx": _LARGE_ARRAY_SIZE - 1}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=_LARGE_ARRAY_SIZE - 1, + msg="$arrayElemAt should return last in large array", + ), + ExpressionTestCase( + id="large_array_neg1", + doc={"arr": _LARGE_ARRAY, "idx": -1}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=_LARGE_ARRAY_SIZE - 1, + msg="$arrayElemAt should return last via -1 in large array", + ), + ExpressionTestCase( + id="large_array_middle", + doc={"arr": _LARGE_ARRAY, "idx": _LARGE_ARRAY_SIZE // 2}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=_LARGE_ARRAY_SIZE // 2, + msg="$arrayElemAt should return middle in large array", + ), + ExpressionTestCase( + id="large_array_neg_middle", + doc={"arr": _LARGE_ARRAY, "idx": -(_LARGE_ARRAY_SIZE // 4)}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=_LARGE_ARRAY_SIZE - _LARGE_ARRAY_SIZE // 4, + msg="$arrayElemAt should return negative middle in large array", + ), +] + +ALL_TESTS = POSITIVE_INDEX_TESTS + NEGATIVE_INDEX_TESTS + DUPLICATE_VALUE_TESTS + LARGE_ARRAY_TESTS + +TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ + ExpressionTestCase( + "first_element_literal", + doc=None, + expression={"$arrayElemAt": [{"$literal": [1, 2, 3]}, 0]}, + expected=1, + msg="$arrayElemAt should return first element from literal array", + ), + ExpressionTestCase( + "last_via_neg1_literal", + doc=None, + expression={"$arrayElemAt": [{"$literal": [1, 2, 3]}, -1]}, + expected=3, + msg="$arrayElemAt should return last element via -1 from literal array", + ), + ExpressionTestCase( + "large_array_first_literal", + doc=None, + expression={"$arrayElemAt": [{"$literal": list(range(20_000))}, 0]}, + expected=0, + msg="$arrayElemAt should return first element from large literal array", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_arrayElemAt_literal(collection, test): + """Test $arrayElemAt with literal values.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_arrayElemAt_insert(collection, test): + """Test $arrayElemAt with values from inserted documents.""" + 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/arrayElemAt/test_arrayElemAt_element_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_element_types.py new file mode 100644 index 000000000..96083d2be --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_element_types.py @@ -0,0 +1,157 @@ +""" +Element type preservation tests for $arrayElemAt expression. + +Tests that $arrayElemAt correctly returns elements of all BSON types +including special float/Decimal128 values and boundary integers. +""" + +import math +from datetime import datetime, timezone + +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.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_ONE_AND_HALF, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT64_MAX, +) + +# Property [Element Types]: $arrayElemAt returns the element with its original BSON type. +ELEMENT_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="int64_element", + doc={"arr": [Int64(99)], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=Int64(99), + msg="$arrayElemAt should return Int64 element", + ), + ExpressionTestCase( + id="decimal128_element", + doc={"arr": [DECIMAL128_ONE_AND_HALF], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=DECIMAL128_ONE_AND_HALF, + msg="$arrayElemAt should return Decimal128 element", + ), + ExpressionTestCase( + id="datetime_element", + doc={"arr": [datetime(2024, 1, 1, tzinfo=timezone.utc)], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=datetime(2024, 1, 1, tzinfo=timezone.utc), + msg="$arrayElemAt should return datetime element", + ), + ExpressionTestCase( + id="binary_element", + doc={"arr": [Binary(b"\x01\x02", 0)], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=b"\x01\x02", + msg="$arrayElemAt should return binary element", + ), + ExpressionTestCase( + id="regex_element", + doc={"arr": [Regex("^abc", "i")], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=Regex("^abc", "i"), + msg="$arrayElemAt should return regex element", + ), + ExpressionTestCase( + id="objectid_element", + doc={"arr": [ObjectId("000000000000000000000001")], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=ObjectId("000000000000000000000001"), + msg="$arrayElemAt should return ObjectId element", + ), + ExpressionTestCase( + id="minkey_element", + doc={"arr": [MinKey(), 1], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=MinKey(), + msg="$arrayElemAt should return MinKey element", + ), + ExpressionTestCase( + id="maxkey_element", + doc={"arr": [1, MaxKey()], "idx": 1}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=MaxKey(), + msg="$arrayElemAt should return MaxKey element", + ), + ExpressionTestCase( + id="timestamp_element", + doc={"arr": [Timestamp(0, 0)], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=Timestamp(0, 0), + msg="$arrayElemAt should return Timestamp element", + ), + ExpressionTestCase( + id="float_nan_element", + doc={"arr": [FLOAT_NAN, 1], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="$arrayElemAt should return NaN element", + ), + ExpressionTestCase( + id="float_infinity_element", + doc={"arr": [FLOAT_INFINITY, 1], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=FLOAT_INFINITY, + msg="$arrayElemAt should return Infinity element", + ), + ExpressionTestCase( + id="float_neg_infinity_element", + doc={"arr": [FLOAT_NEGATIVE_INFINITY, 1], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=FLOAT_NEGATIVE_INFINITY, + msg="$arrayElemAt should return -Infinity element", + ), + ExpressionTestCase( + id="decimal128_nan_element", + doc={"arr": [DECIMAL128_NAN, 1], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=DECIMAL128_NAN, + msg="$arrayElemAt should return Decimal128 NaN element", + ), + ExpressionTestCase( + id="decimal128_infinity_element", + doc={"arr": [DECIMAL128_INFINITY, 1], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=DECIMAL128_INFINITY, + msg="$arrayElemAt should return Decimal128 Infinity element", + ), + ExpressionTestCase( + id="decimal128_neg_infinity_element", + doc={"arr": [DECIMAL128_NEGATIVE_INFINITY, 1], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=DECIMAL128_NEGATIVE_INFINITY, + msg="$arrayElemAt should return Decimal128 -Infinity element", + ), + ExpressionTestCase( + id="int32_max_element", + doc={"arr": [INT32_MAX, 0], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=INT32_MAX, + msg="$arrayElemAt should return INT32_MAX element", + ), + ExpressionTestCase( + id="int64_max_element", + doc={"arr": [INT64_MAX, 0], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=INT64_MAX, + msg="$arrayElemAt should return INT64_MAX element", + ), + ExpressionTestCase( + id="mixed_special_last", + doc={"arr": [INT32_MAX, FLOAT_INFINITY, DECIMAL128_NAN], "idx": 2}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=DECIMAL128_NAN, + msg="$arrayElemAt should return element from mixed special values array", + ), +] diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_errors.py new file mode 100644 index 000000000..344934c33 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_errors.py @@ -0,0 +1,393 @@ +""" +Error tests for $arrayElemAt expression. + +Tests non-array first argument, non-numeric index, non-integral numeric index, +and wrong arity errors. +""" + +from datetime import datetime, timezone + +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 ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + EXPRESSION_TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_HALF, + DECIMAL128_INFINITY, + DECIMAL128_INT64_OVERFLOW, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_OVERFLOW, + INT64_MAX, + INT64_MIN, +) + +# Property [Array Type Strictness]: $arrayElemAt rejects a non-array first argument. +ARRAY_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="string_as_array", + doc={"arr": "hello", "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject string as array", + ), + ExpressionTestCase( + id="int_as_array", + doc={"arr": 42, "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject int as array", + ), + ExpressionTestCase( + id="bool_true_as_array", + doc={"arr": True, "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject bool true as array", + ), + ExpressionTestCase( + id="bool_false_as_array", + doc={"arr": False, "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject bool false as array", + ), + ExpressionTestCase( + id="object_as_array", + doc={"arr": {"a": 1}, "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject object as array", + ), + ExpressionTestCase( + id="double_as_array", + doc={"arr": 3.14, "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject double as array", + ), + ExpressionTestCase( + id="decimal128_as_array", + doc={"arr": Decimal128("1"), "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject decimal128 as array", + ), + ExpressionTestCase( + id="int64_as_array", + doc={"arr": Int64(1), "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject int64 as array", + ), + ExpressionTestCase( + id="binary_as_array", + doc={"arr": Binary(b"x", 0), "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject binary as array", + ), + ExpressionTestCase( + id="datetime_as_array", + doc={"arr": datetime(2024, 1, 1, tzinfo=timezone.utc), "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject datetime as array", + ), + ExpressionTestCase( + id="objectid_as_array", + doc={"arr": ObjectId(), "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject objectid as array", + ), + ExpressionTestCase( + id="regex_as_array", + doc={"arr": Regex("x"), "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject regex as array", + ), + ExpressionTestCase( + id="maxkey_as_array", + doc={"arr": MaxKey(), "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject maxkey as array", + ), + ExpressionTestCase( + id="minkey_as_array", + doc={"arr": MinKey(), "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject minkey as array", + ), + ExpressionTestCase( + id="timestamp_as_array", + doc={"arr": Timestamp(0, 0), "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject timestamp as array", + ), + ExpressionTestCase( + id="nan_as_array", + doc={"arr": FLOAT_NAN, "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject NaN as array", + ), + ExpressionTestCase( + id="inf_as_array", + doc={"arr": FLOAT_INFINITY, "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject Infinity as array", + ), + ExpressionTestCase( + id="decimal128_nan_as_array", + doc={"arr": DECIMAL128_NAN, "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject Decimal128 NaN as array", + ), + ExpressionTestCase( + id="decimal128_inf_as_array", + doc={"arr": DECIMAL128_INFINITY, "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject Decimal128 Infinity as array", + ), +] + +# Property [Index Type Strictness]: $arrayElemAt rejects a non-numeric index. +INDEX_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="string_index", + doc={"arr": [1, 2], "idx": "0"}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject string index", + ), + ExpressionTestCase( + id="bool_true_index", + doc={"arr": [1, 2], "idx": True}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject bool true index", + ), + ExpressionTestCase( + id="bool_false_index", + doc={"arr": [1, 2], "idx": False}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject bool false index", + ), + ExpressionTestCase( + id="array_index", + doc={"arr": [1, 2], "idx": [0]}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject array index", + ), + ExpressionTestCase( + id="object_index", + doc={"arr": [1, 2], "idx": {"a": 0}}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject object index", + ), + ExpressionTestCase( + id="objectid_index", + doc={"arr": [1, 2], "idx": ObjectId()}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject objectid index", + ), + ExpressionTestCase( + id="binary_index", + doc={"arr": [1, 2], "idx": Binary(b"\x01", 0)}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject binary index", + ), + ExpressionTestCase( + id="timestamp_index", + doc={"arr": [1, 2], "idx": Timestamp(0, 0)}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject timestamp index", + ), + ExpressionTestCase( + id="datetime_index", + doc={"arr": [1, 2], "idx": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject datetime index", + ), + ExpressionTestCase( + id="maxkey_index", + doc={"arr": [1, 2], "idx": MaxKey()}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject maxkey index", + ), + ExpressionTestCase( + id="minkey_index", + doc={"arr": [1, 2], "idx": MinKey()}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject minkey index", + ), + ExpressionTestCase( + id="regex_index", + doc={"arr": [1, 2], "idx": Regex("x")}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject regex index", + ), +] + +# Property [Integral Index]: $arrayElemAt rejects a non-integral or out-of-range numeric index. +NON_INTEGRAL_INDEX_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="double_fractional_index", + doc={"arr": [1, 2, 3], "idx": 1.5}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject fractional double index", + ), + ExpressionTestCase( + id="decimal128_fractional_index", + doc={"arr": [1, 2, 3], "idx": DECIMAL128_HALF}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject fractional decimal128 index", + ), + ExpressionTestCase( + id="double_nan_index", + doc={"arr": [1, 2, 3], "idx": FLOAT_NAN}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject NaN index", + ), + ExpressionTestCase( + id="double_inf_index", + doc={"arr": [1, 2, 3], "idx": FLOAT_INFINITY}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject infinity index", + ), + ExpressionTestCase( + id="double_neg_inf_index", + doc={"arr": [1, 2, 3], "idx": FLOAT_NEGATIVE_INFINITY}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject -infinity index", + ), + ExpressionTestCase( + id="decimal128_nan_index", + doc={"arr": [1, 2, 3], "idx": DECIMAL128_NAN}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject decimal128 NaN index", + ), + ExpressionTestCase( + id="decimal128_inf_index", + doc={"arr": [1, 2, 3], "idx": DECIMAL128_INFINITY}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject decimal128 infinity index", + ), + ExpressionTestCase( + id="decimal128_neg_inf_index", + doc={"arr": [1, 2, 3], "idx": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject decimal128 -infinity index", + ), + ExpressionTestCase( + id="int64_max_index", + doc={"arr": [1, 2, 3], "idx": INT64_MAX}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject INT64_MAX index", + ), + ExpressionTestCase( + id="int64_min_index", + doc={"arr": [1, 2, 3], "idx": INT64_MIN}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject INT64_MIN index", + ), + ExpressionTestCase( + id="large_double_index", + doc={"arr": [1, 2, 3], "idx": 1.0e18}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject large double index", + ), + ExpressionTestCase( + id="large_neg_double_index", + doc={"arr": [1, 2, 3], "idx": -1.0e18}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject large negative double index", + ), + ExpressionTestCase( + id="decimal128_beyond_int32", + doc={"arr": [1, 2, 3], "idx": Decimal128(str(INT32_OVERFLOW))}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject decimal128 beyond int32", + ), + ExpressionTestCase( + id="decimal128_huge", + doc={"arr": [1, 2, 3], "idx": DECIMAL128_INT64_OVERFLOW}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject huge decimal128 index", + ), +] + +ALL_TESTS = ARRAY_TYPE_ERROR_TESTS + INDEX_TYPE_ERROR_TESTS + NON_INTEGRAL_INDEX_TESTS + +# Property [Arity]: $arrayElemAt requires exactly two arguments. +ARITY_ERROR_TESTS = [ + pytest.param({"$arrayElemAt": [[1, 2, 3]]}, id="one_arg"), + pytest.param({"$arrayElemAt": [[1, 2, 3], 0, 1]}, id="three_args"), + pytest.param({"$arrayElemAt": []}, id="zero_args"), + pytest.param({"$arrayElemAt": [[[1, 2, 3], 0]]}, id="nested_pair_not_flattened_to_two_args"), +] + + +@pytest.mark.parametrize("expr", ARITY_ERROR_TESTS) +def test_arrayElemAt_syntax_error(collection, expr): + """Test $arrayElemAt errors with wrong number of arguments.""" + result = execute_expression(collection, expr) + assert_expression_result(result, error_code=EXPRESSION_TYPE_MISMATCH_ERROR) + + +# Property [Index Type Strictness]: $arrayElemAt rejects a field path that resolves to an +# array as the index argument. +def test_arrayElemAt_composite_array_as_index(collection): + """Test $arrayElemAt rejects a composite array from $x.y as the index argument.""" + result = execute_expression_with_insert( + collection, {"$arrayElemAt": [[10, 20, 30], "$x.y"]}, {"x": [{"y": 0}, {"y": 1}]} + ) + assert_expression_result(result, error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_expressions.py new file mode 100644 index 000000000..495abc5bb --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_expressions.py @@ -0,0 +1,146 @@ +""" +Expression and field path tests for $arrayElemAt expression. + +Tests nested expressions, field path lookups, composite paths, +and path through array of objects. +""" + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.test_constants import DECIMAL128_ZERO + + +# Nested expressions +@pytest.mark.parametrize( + "expression,expected", + [ + # 2D array access: arr[1][0] + ({"$arrayElemAt": [{"$arrayElemAt": [[[10, 20], [30, 40]], 1]}, 0]}, 30), + # 3D array access: arr[1][0][1] + ( + { + "$arrayElemAt": [ + { + "$arrayElemAt": [ + {"$arrayElemAt": [[[[1, 2], [3, 4]], [[5, 6], [7, 8]]], 1]}, + 0, + ] + }, + 1, + ] + }, + 6, + ), + # 4D array access: arr[1][1][0][1] + ( + { + "$arrayElemAt": [ + { + "$arrayElemAt": [ + { + "$arrayElemAt": [ + { + "$arrayElemAt": [ + [ + [[[1, 2], [3, 4]], [[5, 6], [7, 8]]], + [[[9, 10], [11, 12]], [[13, 14], [15, 16]]], + ], + 1, + ] + }, + 1, + ] + }, + 0, + ] + }, + 1, + ] + }, + 14, + ), + ], + ids=["nested_2d_access", "nested_3d_access", "nested_4d_access"], +) +def test_arrayElemAt_nested_expression(collection, expression, expected): + """Test $arrayElemAt composed with other expressions.""" + result = execute_expression(collection, expression) + assert_expression_result(result, expected=expected) + + +# Field path lookups: $arrayElemAt with array and/or index arguments resolved from inserted +# document fields. These all share one execution path, so they are parametrized into one test. +@pytest.mark.parametrize( + "document,array_ref,idx,expected", + [ + ({"a": {"b": [10, 20, 30]}}, "$a.b", 1, 20), + ({"a": {"missing": 1}}, "$a.nonexistent", 0, None), + ({"a": {"b": {"c": [5, 6, 7]}}}, "$a.b.c", -1, 7), + # field path traverses an array of objects -> collapses to [10, 20] + ({"a": [{"b": 10}, {"b": 20}]}, "$a.b", 0, 10), + # literal array with a field-path index + ({"a": {"b": 1}}, [10, 20, 30], "$a.b", 20), + # array argument resolved from an array of objects + ({"x": [{"y": 10}, {"y": 20}, {"y": 30}]}, "$x.y", 1, 20), + # numeric path component ".0" addresses an object key "0", not an array position + ({"a": {"0": {"b": [10, 20, 30]}}}, "$a.0.b", 1, 20), + # Decimal128 index resolved alongside a composite array path + ({"a": [{"b": 1}, {"b": 2}, {"b": 3}]}, "$a.b", DECIMAL128_ZERO, 1), + ({"a": [{"b": 1}, {"b": 2}, {"b": 3}]}, "$a.b", Decimal128("-1"), 3), + # first argument is an array expression whose elements are field references + ({"x": 10, "y": 20}, ["$x", "$y"], 0, 10), + ({"x": 10, "y": 20}, ["$x", "$y"], 1, 20), + ({"x": 10, "y": 20}, ["$x", "$y"], -1, 20), + ], + ids=[ + "nested_field_path", + "nonexistent_field_null", + "deeply_nested_field", + "path_through_array_of_objects", + "composite_path_for_index", + "composite_array_as_array", + "object_numeric_key_path", + "composite_decimal128_pos", + "composite_decimal128_neg", + "array_expr_first", + "array_expr_second", + "array_expr_negative", + ], +) +def test_arrayElemAt_field_lookup(collection, document, array_ref, idx, expected): + """Test $arrayElemAt with array/index arguments resolved from inserted document fields.""" + result = execute_expression_with_insert( + collection, {"$arrayElemAt": [array_ref, idx]}, document + ) + assert_expression_result(result, expected=expected) + + +# Field-path lookups that resolve to a missing result: an out-of-bounds Decimal128 index, or a +# numeric path component that does not positionally index an array. Same execution path -> one test. +@pytest.mark.parametrize( + "document,array_ref,idx", + [ + ({"a": [{"b": 1}, {"b": 2}, {"b": 3}]}, "$a.b", Decimal128("4")), + ({"a": [{"b": 1}, {"b": 2}, {"b": 3}]}, "$a.b", Decimal128("-4")), + # "$a.0" does not positionally index an array in expression context -> missing + ({"a": [[1, 2], [3, 4]]}, "$a.0", 0), + ], + ids=[ + "composite_decimal128_oob_pos", + "composite_decimal128_oob_neg", + "numeric_path_component_not_array_index", + ], +) +def test_arrayElemAt_field_lookup_missing(collection, document, array_ref, idx): + """Test $arrayElemAt returns a missing result for out-of-bounds field-path lookups.""" + result = execute_expression_with_insert( + collection, {"$arrayElemAt": [array_ref, idx]}, document + ) + assertSuccess(result, [{}]) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_null_missing.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_null_missing.py new file mode 100644 index 000000000..0d1f154cf --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_null_missing.py @@ -0,0 +1,60 @@ +""" +Null and missing field behavior tests for $arrayElemAt expression. + +Tests null propagation and missing field handling for array and index arguments. +""" + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.framework.test_constants import MISSING + +# Property [Null Propagation]: $arrayElemAt returns null when the array or index argument is null. +NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="null_array", + doc={"arr": None, "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=None, + msg="$arrayElemAt should return null for null array", + ), + ExpressionTestCase( + id="null_array_neg_idx", + doc={"arr": None, "idx": -1}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=None, + msg="$arrayElemAt should return null for null array with negative index", + ), + ExpressionTestCase( + id="null_index", + doc={"arr": [1, 2], "idx": None}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=None, + msg="$arrayElemAt should return null for null index", + ), + ExpressionTestCase( + id="both_null", + doc={"arr": None, "idx": None}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=None, + msg="$arrayElemAt should return null when both null", + ), +] + +# Property [Missing Propagation]: $arrayElemAt returns null when the array or index is missing. +LITERAL_ONLY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="missing_array", + doc={"arr": MISSING, "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=None, + msg="$arrayElemAt should return null for missing array", + ), + ExpressionTestCase( + id="missing_index", + doc={"arr": [1, 2, 3], "idx": MISSING}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=None, + msg="$arrayElemAt should return null for missing index", + ), +] diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_numeric_index.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_numeric_index.py new file mode 100644 index 000000000..7b88f9208 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_numeric_index.py @@ -0,0 +1,156 @@ +""" +Numeric index type tests for $arrayElemAt expression. + +Tests various numeric types (Int64, double, Decimal128) and edge cases +like negative zero and Decimal128 scientific notation as index values. +""" + +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_TRAILING_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + INT64_ZERO, +) + +# Property [Numeric Index Types]: $arrayElemAt accepts int32, int64, and integral double indexes. +NUMERIC_INDEX_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="int64_zero_index", + doc={"arr": [10, 20, 30], "idx": INT64_ZERO}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=10, + msg="$arrayElemAt should accept Int64 zero index", + ), + ExpressionTestCase( + id="int64_index", + doc={"arr": [10, 20, 30], "idx": Int64(1)}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=20, + msg="$arrayElemAt should accept Int64 index", + ), + ExpressionTestCase( + id="double_integral_index", + doc={"arr": [10, 20, 30], "idx": 2.0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=30, + msg="$arrayElemAt should accept integral double index", + ), + ExpressionTestCase( + id="decimal128_integral_index", + doc={"arr": [10, 20, 30], "idx": DECIMAL128_ZERO}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=10, + msg="$arrayElemAt should accept Decimal128 index", + ), + ExpressionTestCase( + id="int64_negative_index", + doc={"arr": [10, 20, 30], "idx": Int64(-1)}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=30, + msg="$arrayElemAt should accept negative Int64 index", + ), + ExpressionTestCase( + id="double_negative_integral", + doc={"arr": [10, 20, 30], "idx": -2.0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=20, + msg="$arrayElemAt should accept negative integral double index", + ), + ExpressionTestCase( + id="negative_zero_index", + doc={"arr": [10, 20, 30], "idx": DOUBLE_NEGATIVE_ZERO}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=10, + msg="$arrayElemAt should treat -0.0 as index 0", + ), + ExpressionTestCase( + id="decimal128_negative_zero_index", + doc={"arr": [10, 20, 30], "idx": DECIMAL128_NEGATIVE_ZERO}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=10, + msg="$arrayElemAt should treat decimal128 -0 as index 0", + ), + ExpressionTestCase( + id="decimal128_trailing_zero", + doc={"arr": [10, 20, 30], "idx": DECIMAL128_TRAILING_ZERO}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=20, + msg="$arrayElemAt should accept decimal128 with trailing zero", + ), + ExpressionTestCase( + id="decimal128_subnormal_zero", + doc={"arr": [10, 20, 30], "idx": Decimal128("0E-6176")}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=10, + msg="$arrayElemAt should accept decimal128 subnormal zero", + ), + ExpressionTestCase( + id="decimal128_20E_neg1", + doc={"arr": [10, 20, 30], "idx": Decimal128("20E-1")}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=30, + msg="$arrayElemAt should accept decimal128 20E-1 as index 2", + ), + ExpressionTestCase( + id="decimal128_0_2E1", + doc={"arr": [10, 20, 30], "idx": Decimal128("0.2E1")}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=30, + msg="$arrayElemAt should accept decimal128 0.2E1 as index 2", + ), + ExpressionTestCase( + id="decimal128_2E0", + doc={"arr": [10, 20, 30], "idx": Decimal128("2E0")}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=30, + msg="$arrayElemAt should accept decimal128 2E0 as index 2", + ), + ExpressionTestCase( + id="decimal128_10E_neg1", + doc={"arr": [10, 20, 30], "idx": Decimal128("10E-1")}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=20, + msg="$arrayElemAt should accept decimal128 10E-1 as index 1", + ), + ExpressionTestCase( + id="decimal128_negative_integral_index", + doc={"arr": [10, 20, 30], "idx": Decimal128("-1")}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=30, + msg="$arrayElemAt should accept negative Decimal128 integral index", + ), + ExpressionTestCase( + id="decimal128_neg_10E_neg1", + doc={"arr": [10, 20, 30], "idx": Decimal128("-10E-1")}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=30, + msg="$arrayElemAt should accept decimal128 -10E-1 as index -1", + ), + ExpressionTestCase( + id="decimal128_0E_pos3", + doc={"arr": [10, 20, 30], "idx": Decimal128("0E+3")}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=10, + msg="$arrayElemAt should accept decimal128 0E+3 as index 0", + ), + ExpressionTestCase( + id="decimal128_0E_neg3", + doc={"arr": [10, 20, 30], "idx": Decimal128("0E-3")}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=10, + msg="$arrayElemAt should accept decimal128 0E-3 as index 0", + ), + ExpressionTestCase( + id="decimal128_1_00000", + doc={"arr": [10, 20, 30], "idx": Decimal128("1.00000")}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=20, + msg="$arrayElemAt should accept decimal128 1.00000 as index 1", + ), +] diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_out_of_bounds.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_out_of_bounds.py new file mode 100644 index 000000000..674973526 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_arrayElemAt_out_of_bounds.py @@ -0,0 +1,101 @@ +""" +Out-of-bounds index tests for $arrayElemAt expression. + +Tests that $arrayElemAt returns no result (missing) when the index +exceeds array bounds in either direction. +""" + +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.framework.test_constants import INT32_MAX, INT32_MIN + +# Property [Out Of Bounds]: $arrayElemAt returns no value when the index is out of bounds. +OUT_OF_BOUNDS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="positive_oob", + doc={"arr": [1, 2, 3], "idx": 15}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=[{}], + msg="$arrayElemAt should return no result for positive OOB", + ), + ExpressionTestCase( + id="positive_oob_by_one", + doc={"arr": [1, 2, 3], "idx": 3}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=[{}], + msg="$arrayElemAt should return no result for OOB by one", + ), + ExpressionTestCase( + id="negative_oob", + doc={"arr": [1, 2, 3], "idx": -4}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=[{}], + msg="$arrayElemAt should return no result for negative OOB", + ), + ExpressionTestCase( + id="negative_oob_large", + doc={"arr": [1, 2, 3], "idx": -100}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=[{}], + msg="$arrayElemAt should return no result for large negative OOB", + ), + ExpressionTestCase( + id="empty_array_idx_0", + doc={"arr": [], "idx": 0}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=[{}], + msg="$arrayElemAt should return no result for empty array idx 0", + ), + ExpressionTestCase( + id="empty_array_neg1", + doc={"arr": [], "idx": -1}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=[{}], + msg="$arrayElemAt should return no result for empty array idx -1", + ), + ExpressionTestCase( + id="int32_max_oob", + doc={"arr": [1, 2, 3], "idx": INT32_MAX}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=[{}], + msg="$arrayElemAt should return no result for INT32_MAX index", + ), + ExpressionTestCase( + id="int32_min_oob", + doc={"arr": [1, 2, 3], "idx": INT32_MIN}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=[{}], + msg="$arrayElemAt should return no result for INT32_MIN index", + ), + ExpressionTestCase( + id="single_element_oob_pos", + doc={"arr": [42], "idx": 1}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=[{}], + msg="$arrayElemAt should return no result for single element OOB positive", + ), + ExpressionTestCase( + id="single_element_oob_neg", + doc={"arr": [42], "idx": -2}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=[{}], + msg="$arrayElemAt should return no result for single element OOB negative", + ), + ExpressionTestCase( + id="decimal128_oob_pos", + doc={"arr": [1, 2, 3], "idx": Decimal128("15")}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=[{}], + msg="$arrayElemAt should return no result for Decimal128 positive OOB", + ), + ExpressionTestCase( + id="decimal128_oob_neg", + doc={"arr": [1, 2, 3], "idx": Decimal128("-100")}, + expression={"$arrayElemAt": ["$arr", "$idx"]}, + expected=[{}], + msg="$arrayElemAt should return no result for Decimal128 negative OOB", + ), +] diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_smoke_expression_arrayElemAt.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_smoke_arrayElemAt.py similarity index 88% rename from documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_smoke_expression_arrayElemAt.py rename to documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_smoke_arrayElemAt.py index 1f287e081..0699dd680 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_smoke_expression_arrayElemAt.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_smoke_arrayElemAt.py @@ -26,4 +26,4 @@ def test_smoke_expression_arrayElemAt(collection): ) expected = [{"_id": 1, "element": 20}, {"_id": 2, "element": 15}] - assertSuccess(result, expected, msg="Should support $arrayElemAt expression") + assertSuccess(result, expected, "Should support $arrayElemAt expression", ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_arrayToObject_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_arrayToObject_bson_types.py new file mode 100644 index 000000000..4d2095d87 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_arrayToObject_bson_types.py @@ -0,0 +1,463 @@ +""" +BSON type tests for $arrayToObject expression. + +Tests that various BSON value types are preserved when converting +arrays to objects, including special numeric values, boundary values, +UUID binary, nested BSON values, and numeric type equivalence, +across both k/v and pair input forms. +""" + +import math +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 ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_MAX, + DECIMAL128_MIN, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_TWO_AND_HALF, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT64_MAX, + INT64_MIN, +) + +# Property [Value Types K/V]: $arrayToObject preserves each value's BSON type in k/v form. +BSON_KV_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="kv_int64", + doc={"arr": [{"k": "a", "v": Int64(99)}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Int64(99)}, + msg="$arrayToObject should preserve Int64 value", + ), + ExpressionTestCase( + id="kv_decimal128", + doc={"arr": [{"k": "a", "v": Decimal128("3.14")}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Decimal128("3.14")}, + msg="$arrayToObject should preserve Decimal128 value", + ), + ExpressionTestCase( + id="kv_datetime", + doc={"arr": [{"k": "a", "v": datetime(2024, 1, 1, tzinfo=timezone.utc)}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + msg="$arrayToObject should preserve datetime value", + ), + ExpressionTestCase( + id="kv_objectid", + doc={"arr": [{"k": "a", "v": ObjectId("000000000000000000000001")}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": ObjectId("000000000000000000000001")}, + msg="$arrayToObject should preserve ObjectId value", + ), + ExpressionTestCase( + id="kv_bool_false", + doc={"arr": [{"k": "a", "v": False}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": False}, + msg="$arrayToObject should preserve false value", + ), + ExpressionTestCase( + id="kv_bool_true", + doc={"arr": [{"k": "a", "v": True}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": True}, + msg="$arrayToObject should preserve true value", + ), + ExpressionTestCase( + id="kv_null", + doc={"arr": [{"k": "a", "v": None}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": None}, + msg="$arrayToObject should preserve null value", + ), + ExpressionTestCase( + id="kv_regex", + doc={"arr": [{"k": "a", "v": Regex("^abc", "i")}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Regex("^abc", "i")}, + msg="$arrayToObject should preserve regex value", + ), + ExpressionTestCase( + id="kv_minkey", + doc={"arr": [{"k": "a", "v": MinKey()}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": MinKey()}, + msg="$arrayToObject should preserve MinKey value", + ), + ExpressionTestCase( + id="kv_maxkey", + doc={"arr": [{"k": "a", "v": MaxKey()}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": MaxKey()}, + msg="$arrayToObject should preserve MaxKey value", + ), + ExpressionTestCase( + id="kv_binary", + doc={"arr": [{"k": "a", "v": Binary(b"\x01\x02\x03", 0)}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": b"\x01\x02\x03"}, + msg="$arrayToObject should preserve Binary value", + ), + ExpressionTestCase( + id="kv_timestamp", + doc={"arr": [{"k": "a", "v": Timestamp(1234567890, 1)}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Timestamp(1234567890, 1)}, + msg="$arrayToObject should preserve Timestamp value", + ), + ExpressionTestCase( + id="kv_uuid", + doc={ + "arr": [{"k": "a", "v": Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210"))}] + }, + expression={"$arrayToObject": "$arr"}, + expected={"a": Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210"))}, + msg="$arrayToObject should preserve UUID binary value", + ), +] + +# Property [Value Types Pair]: $arrayToObject preserves each value's BSON type in pair form. +BSON_PAIR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="pair_int64", + doc={"arr": [["a", Int64(99)]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Int64(99)}, + msg="$arrayToObject should preserve Int64 value (pair form)", + ), + ExpressionTestCase( + id="pair_decimal128", + doc={"arr": [["a", Decimal128("3.14")]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Decimal128("3.14")}, + msg="$arrayToObject should preserve Decimal128 value (pair form)", + ), + ExpressionTestCase( + id="pair_datetime", + doc={"arr": [["a", datetime(2024, 1, 1, tzinfo=timezone.utc)]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + msg="$arrayToObject should preserve datetime value (pair form)", + ), + ExpressionTestCase( + id="pair_objectid", + doc={"arr": [["a", ObjectId("000000000000000000000001")]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": ObjectId("000000000000000000000001")}, + msg="$arrayToObject should preserve ObjectId value (pair form)", + ), + ExpressionTestCase( + id="pair_binary", + doc={"arr": [["a", Binary(b"\x01\x02\x03", 0)]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": b"\x01\x02\x03"}, + msg="$arrayToObject should preserve Binary value (pair form)", + ), + ExpressionTestCase( + id="pair_timestamp", + doc={"arr": [["a", Timestamp(1234567890, 1)]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Timestamp(1234567890, 1)}, + msg="$arrayToObject should preserve Timestamp value (pair form)", + ), + ExpressionTestCase( + id="pair_regex", + doc={"arr": [["a", Regex("^abc", "i")]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Regex("^abc", "i")}, + msg="$arrayToObject should preserve regex value (pair form)", + ), + ExpressionTestCase( + id="pair_minkey", + doc={"arr": [["a", MinKey()]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": MinKey()}, + msg="$arrayToObject should preserve MinKey value (pair form)", + ), + ExpressionTestCase( + id="pair_maxkey", + doc={"arr": [["a", MaxKey()]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": MaxKey()}, + msg="$arrayToObject should preserve MaxKey value (pair form)", + ), + ExpressionTestCase( + id="pair_uuid", + doc={"arr": [["a", Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210"))]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210"))}, + msg="$arrayToObject should preserve UUID binary value (pair form)", + ), +] + +# Property [Special Numerics]: $arrayToObject preserves NaN, Infinity, and negative zero. +SPECIAL_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="value_infinity", + doc={"arr": [{"k": "a", "v": FLOAT_INFINITY}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": FLOAT_INFINITY}, + msg="$arrayToObject should preserve Infinity value", + ), + ExpressionTestCase( + id="value_neg_infinity", + doc={"arr": [{"k": "a", "v": FLOAT_NEGATIVE_INFINITY}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": FLOAT_NEGATIVE_INFINITY}, + msg="$arrayToObject should preserve -Infinity value", + ), + ExpressionTestCase( + id="value_neg_zero", + doc={"arr": [{"k": "a", "v": DOUBLE_NEGATIVE_ZERO}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": DOUBLE_NEGATIVE_ZERO}, + msg="$arrayToObject should preserve negative zero value", + ), + ExpressionTestCase( + id="value_decimal128_nan", + doc={"arr": [{"k": "a", "v": DECIMAL128_NAN}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": DECIMAL128_NAN}, + msg="$arrayToObject should preserve Decimal128 NaN value", + ), + ExpressionTestCase( + id="value_decimal128_infinity", + doc={"arr": [{"k": "a", "v": DECIMAL128_INFINITY}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": DECIMAL128_INFINITY}, + msg="$arrayToObject should preserve Decimal128 Infinity value", + ), + ExpressionTestCase( + id="value_decimal128_neg_infinity", + doc={"arr": [{"k": "a", "v": DECIMAL128_NEGATIVE_INFINITY}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": DECIMAL128_NEGATIVE_INFINITY}, + msg="$arrayToObject should preserve Decimal128 -Infinity value", + ), + ExpressionTestCase( + id="value_decimal128_neg_zero", + doc={"arr": [{"k": "a", "v": DECIMAL128_NEGATIVE_ZERO}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": DECIMAL128_NEGATIVE_ZERO}, + msg="$arrayToObject should preserve Decimal128 -0 value", + ), + ExpressionTestCase( + id="value_decimal128_high_precision", + doc={"arr": [{"k": "a", "v": Decimal128("1.234567890123456789012345678901234")}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Decimal128("1.234567890123456789012345678901234")}, + msg="$arrayToObject should preserve full Decimal128 precision", + ), + ExpressionTestCase( + id="value_decimal128_zero_exponent", + doc={"arr": [{"k": "a", "v": Decimal128("0E+10")}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Decimal128("0E+10")}, + msg="$arrayToObject should preserve Decimal128 exponent notation", + ), + ExpressionTestCase( + id="value_decimal128_trailing_zeros", + doc={"arr": [{"k": "a", "v": Decimal128("1.00000")}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Decimal128("1.00000")}, + msg="$arrayToObject should preserve Decimal128 trailing zeros", + ), + ExpressionTestCase( + id="value_decimal128_subnormal_zero", + doc={"arr": [{"k": "a", "v": Decimal128("0E-6176")}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Decimal128("0E-6176")}, + msg="$arrayToObject should preserve Decimal128 subnormal zero", + ), +] + +# Property [Numeric Boundaries]: $arrayToObject preserves numeric boundary values. +BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="value_int32_max", + doc={"arr": [{"k": "a", "v": INT32_MAX}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": INT32_MAX}, + msg="$arrayToObject should preserve INT32_MAX value", + ), + ExpressionTestCase( + id="value_int32_min", + doc={"arr": [{"k": "a", "v": INT32_MIN}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": INT32_MIN}, + msg="$arrayToObject should preserve INT32_MIN value", + ), + ExpressionTestCase( + id="value_int64_max", + doc={"arr": [{"k": "a", "v": INT64_MAX}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": INT64_MAX}, + msg="$arrayToObject should preserve INT64_MAX value", + ), + ExpressionTestCase( + id="value_int64_min", + doc={"arr": [{"k": "a", "v": INT64_MIN}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": INT64_MIN}, + msg="$arrayToObject should preserve INT64_MIN value", + ), + ExpressionTestCase( + id="value_decimal128_max", + doc={"arr": [{"k": "a", "v": DECIMAL128_MAX}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": DECIMAL128_MAX}, + msg="$arrayToObject should preserve DECIMAL128_MAX value", + ), + ExpressionTestCase( + id="value_decimal128_min", + doc={"arr": [{"k": "a", "v": DECIMAL128_MIN}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": DECIMAL128_MIN}, + msg="$arrayToObject should preserve DECIMAL128_MIN value", + ), +] + +# Property [Nested Values]: $arrayToObject preserves nested arrays and documents as values. +NESTED_BSON_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="nested_bson_in_object_value", + doc={"arr": [{"k": "a", "v": {"x": Int64(1), "y": DECIMAL128_TWO_AND_HALF}}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": {"x": Int64(1), "y": DECIMAL128_TWO_AND_HALF}}, + msg="$arrayToObject should preserve nested BSON types in object value", + ), + ExpressionTestCase( + id="nested_bson_in_array_value", + doc={ + "arr": [ + { + "k": "a", + "v": [ + MinKey(), + datetime(2024, 1, 1, tzinfo=timezone.utc), + ObjectId("000000000000000000000001"), + ], + } + ] + }, + expression={"$arrayToObject": "$arr"}, + expected={ + "a": [ + MinKey(), + datetime(2024, 1, 1, tzinfo=timezone.utc), + ObjectId("000000000000000000000001"), + ] + }, + msg="$arrayToObject should preserve nested BSON types in array value", + ), + ExpressionTestCase( + id="deeply_nested_bson", + doc={"arr": [{"k": "a", "v": {"x": [{"y": DECIMAL128_ONE_AND_HALF}, Timestamp(0, 0)]}}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": {"x": [{"y": DECIMAL128_ONE_AND_HALF}, Timestamp(0, 0)]}}, + msg="$arrayToObject should preserve deeply nested BSON types", + ), + ExpressionTestCase( + id="nested_array_not_interpreted_as_kv", + doc={"arr": [{"k": "a", "v": [["level2", {"x": 1}]]}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": [["level2", {"x": 1}]]}, + msg="$arrayToObject should preserve nested array as value without interpreting as k/v", + ), +] + +# Property [Duplicate Numeric Keys]: last value wins for duplicate keys of differing numeric types. +NUMERIC_EQUIVALENCE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="duplicate_key_int_then_int64", + doc={"arr": [{"k": "a", "v": 1}, {"k": "a", "v": Int64(2)}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Int64(2)}, + msg="$arrayToObject should keep the last Int64 value for a duplicate key", + ), + ExpressionTestCase( + id="duplicate_key_int_then_decimal128", + doc={"arr": [{"k": "a", "v": 1}, {"k": "a", "v": Decimal128("2")}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": Decimal128("2")}, + msg="$arrayToObject should keep the last Decimal128 value for a duplicate key", + ), + ExpressionTestCase( + id="duplicate_key_decimal128_then_double", + doc={"arr": [{"k": "a", "v": Decimal128("1")}, {"k": "a", "v": 2.0}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": 2.0}, + msg="$arrayToObject should keep the last double value for a duplicate key", + ), +] + +# Property [Mixed Types]: $arrayToObject preserves multiple mixed BSON value types in one array. +MIXED_BSON_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="kv_mixed_bson_types", + doc={ + "arr": [ + {"k": "int64", "v": Int64(1)}, + {"k": "dec", "v": DECIMAL128_ONE_AND_HALF}, + {"k": "dt", "v": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + {"k": "oid", "v": ObjectId("000000000000000000000001")}, + {"k": "bin", "v": Binary(b"\x01", 0)}, + {"k": "ts", "v": Timestamp(0, 0)}, + {"k": "min", "v": MinKey()}, + ] + }, + expression={"$arrayToObject": "$arr"}, + expected={ + "int64": Int64(1), + "dec": DECIMAL128_ONE_AND_HALF, + "dt": datetime(2024, 1, 1, tzinfo=timezone.utc), + "oid": ObjectId("000000000000000000000001"), + "bin": b"\x01", + "ts": Timestamp(0, 0), + "min": MinKey(), + }, + msg="$arrayToObject should preserve multiple mixed BSON types in one conversion", + ), +] + +ALL_BSON_TESTS = ( + BSON_KV_TESTS + + BSON_PAIR_TESTS + + SPECIAL_NUMERIC_TESTS + + BOUNDARY_TESTS + + NESTED_BSON_TESTS + + NUMERIC_EQUIVALENCE_TESTS + + MIXED_BSON_TESTS +) + + +# Float NaN needs a dedicated test because NaN does not compare equal to itself. +def test_arrayToObject_float_nan_value(collection): + """Test $arrayToObject preserves float NaN value.""" + result = execute_expression(collection, {"$arrayToObject": {"$literal": [["a", FLOAT_NAN]]}}) + assert_expression_result( + result, + expected={"a": pytest.approx(math.nan, nan_ok=True)}, + msg="$arrayToObject should preserve a NaN value", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_arrayToObject_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_arrayToObject_core_behavior.py new file mode 100644 index 000000000..1bcd93032 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_arrayToObject_core_behavior.py @@ -0,0 +1,410 @@ +""" +Core behavior tests for $arrayToObject expression. + +Tests both input forms (k/v documents and two-element arrays), empty arrays, +duplicate keys, format equivalence, field ordering, case sensitivity, +value edge cases, key edge cases, and large inputs. +""" + +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 ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.lazy_payload import lazy +from documentdb_tests.framework.parametrize import pytest_params + +# Property [K/V Form]: $arrayToObject builds an object from {k, v} document entries. +KV_FORM_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="kv_single_pair", + doc={"arr": [{"k": "a", "v": 1}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": 1}, + msg="$arrayToObject should convert single k/v pair", + ), + ExpressionTestCase( + id="kv_multiple_pairs", + doc={"arr": [{"k": "a", "v": 1}, {"k": "b", "v": 2}, {"k": "c", "v": 3}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": 1, "b": 2, "c": 3}, + msg="$arrayToObject should convert multiple k/v pairs", + ), + ExpressionTestCase( + id="kv_string_values", + doc={"arr": [{"k": "name", "v": "Alice"}, {"k": "city", "v": "Mycity"}]}, + expression={"$arrayToObject": "$arr"}, + expected={"name": "Alice", "city": "Mycity"}, + msg="$arrayToObject should convert k/v pairs with string values", + ), + ExpressionTestCase( + id="kv_mixed_value_types", + doc={ + "arr": [ + {"k": "int", "v": 1}, + {"k": "str", "v": "hello"}, + {"k": "bool", "v": True}, + {"k": "null", "v": None}, + ] + }, + expression={"$arrayToObject": "$arr"}, + expected={"int": 1, "str": "hello", "bool": True, "null": None}, + msg="$arrayToObject should convert k/v pairs with mixed value types", + ), + ExpressionTestCase( + id="kv_nested_object_value", + doc={"arr": [{"k": "obj", "v": {"x": 1, "y": 2}}]}, + expression={"$arrayToObject": "$arr"}, + expected={"obj": {"x": 1, "y": 2}}, + msg="$arrayToObject should convert k/v pair with nested object value", + ), + ExpressionTestCase( + id="kv_array_value", + doc={"arr": [{"k": "arr", "v": [1, 2, 3]}]}, + expression={"$arrayToObject": "$arr"}, + expected={"arr": [1, 2, 3]}, + msg="$arrayToObject should convert k/v pair with array value", + ), +] + +# Property [Pair Form]: $arrayToObject builds an object from two-element [key, value] arrays. +TWO_ELEM_FORM_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="pair_single", + doc={"arr": [["a", 1]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": 1}, + msg="$arrayToObject should convert single two-element pair", + ), + ExpressionTestCase( + id="pair_multiple", + doc={"arr": [["a", 1], ["b", 2], ["c", 3]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": 1, "b": 2, "c": 3}, + msg="$arrayToObject should convert multiple two-element pairs", + ), + ExpressionTestCase( + id="pair_string_values", + doc={"arr": [["name", "Alice"], ["city", "Mycity"]]}, + expression={"$arrayToObject": "$arr"}, + expected={"name": "Alice", "city": "Mycity"}, + msg="$arrayToObject should convert pairs with string values", + ), + ExpressionTestCase( + id="pair_mixed_value_types", + doc={"arr": [["int", 1], ["str", "hello"], ["bool", True], ["null", None]]}, + expression={"$arrayToObject": "$arr"}, + expected={"int": 1, "str": "hello", "bool": True, "null": None}, + msg="$arrayToObject should convert pairs with mixed value types", + ), + ExpressionTestCase( + id="pair_nested_object_value", + doc={"arr": [["obj", {"x": 1, "y": 2}]]}, + expression={"$arrayToObject": "$arr"}, + expected={"obj": {"x": 1, "y": 2}}, + msg="$arrayToObject should convert pair with nested object value", + ), + ExpressionTestCase( + id="pair_array_value", + doc={"arr": [["arr", [1, 2, 3]]]}, + expression={"$arrayToObject": "$arr"}, + expected={"arr": [1, 2, 3]}, + msg="$arrayToObject should convert pair with array value", + ), +] + +# Property [Empty And Null]: $arrayToObject returns {} for an empty array and null for null input. +EMPTY_AND_NULL_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="empty_array", + doc={"arr": []}, + expression={"$arrayToObject": "$arr"}, + expected={}, + msg="$arrayToObject should return empty object for empty array", + ), + ExpressionTestCase( + id="null_array", + doc={"arr": None}, + expression={"$arrayToObject": "$arr"}, + expected=None, + msg="$arrayToObject should return null for null array", + ), +] + +# Property [Duplicate Keys]: when keys repeat, $arrayToObject keeps the last value. +DUPLICATE_KEY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="kv_duplicate_keys", + doc={"arr": [{"k": "a", "v": 1}, {"k": "a", "v": 2}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": 2}, + msg="$arrayToObject should keep the last value for duplicate keys (k/v form)", + ), + ExpressionTestCase( + id="pair_duplicate_keys", + doc={"arr": [["a", 1], ["a", 2]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": 2}, + msg="$arrayToObject should keep the last value for duplicate keys (pair form)", + ), + ExpressionTestCase( + id="kv_triple_duplicate", + doc={"arr": [{"k": "x", "v": 1}, {"k": "x", "v": 2}, {"k": "x", "v": 3}]}, + expression={"$arrayToObject": "$arr"}, + expected={"x": 3}, + msg="$arrayToObject should keep the last of three duplicate keys", + ), + ExpressionTestCase( + id="pair_dup_different_types", + doc={"arr": [["a", 1], ["a", "hello"]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": "hello"}, + msg="$arrayToObject should keep the last value even with different value types", + ), + ExpressionTestCase( + id="pair_dup_interspersed", + doc={"arr": [["a", 1], ["b", 2], ["a", 3]]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": 3, "b": 2}, + msg="$arrayToObject should keep the last value with interspersed duplicate keys", + ), + ExpressionTestCase( + id="kv_dup_interspersed", + doc={"arr": [{"k": "a", "v": 1}, {"k": "b", "v": 2}, {"k": "a", "v": 3}]}, + expression={"$arrayToObject": "$arr"}, + expected={"a": 3, "b": 2}, + msg="$arrayToObject should keep the last value with interspersed duplicates (k/v form)", + ), + ExpressionTestCase( + id="kv_reversed_field_order", + doc={"arr": [{"v": "val", "k": "key"}]}, + expression={"$arrayToObject": "$arr"}, + expected={"key": "val"}, + msg="$arrayToObject should work regardless of k/v field order in document", + ), +] + +# Property [Key Characters]: $arrayToObject accepts unicode, emoji, and spaced keys. +KEY_EDGE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="unicode_key", + doc={"arr": [{"k": "日本語", "v": 1}]}, + expression={"$arrayToObject": "$arr"}, + expected={"日本語": 1}, + msg="$arrayToObject should accept a unicode key", + ), + ExpressionTestCase( + id="emoji_key", + doc={"arr": [{"k": "🔑", "v": "value"}]}, + expression={"$arrayToObject": "$arr"}, + expected={"🔑": "value"}, + msg="$arrayToObject should accept an emoji key", + ), + ExpressionTestCase( + id="key_with_spaces", + doc={"arr": [["key with spaces", 1]]}, + expression={"$arrayToObject": "$arr"}, + expected={"key with spaces": 1}, + msg="$arrayToObject should accept a key with spaces", + ), + ExpressionTestCase( + id="numeric_string_keys", + doc={"arr": [["0", "a"], ["1", "b"]]}, + expression={"$arrayToObject": "$arr"}, + expected={"0": "a", "1": "b"}, + msg="$arrayToObject should treat numeric string keys as strings", + ), + ExpressionTestCase( + id="underscore_id_key", + doc={"arr": [["_id", 1]]}, + expression={"$arrayToObject": "$arr"}, + expected={"_id": 1}, + msg="$arrayToObject should accept _id as a key", + ), + ExpressionTestCase( + id="operator_like_key", + doc={"arr": [["$set", 1]]}, + expression={"$arrayToObject": "$arr"}, + expected={"$set": 1}, + msg="$arrayToObject should accept an operator-like key", + ), + ExpressionTestCase( + id="very_long_key", + doc={"arr": [["k" * 1_024, 1]]}, + expression={"$arrayToObject": "$arr"}, + expected={"k" * 1_024: 1}, + msg="$arrayToObject should not truncate a very long key", + ), +] + +# Property [Key Handling]: $arrayToObject treats keys case-sensitively and preserves arbitrary +# nested/empty values. Output field-order preservation is verified separately in +# test_arrayToObject_preserves_field_order (a plain object comparison is order-insensitive). +# Property [Value Types]: $arrayToObject preserves complex value types. +EDGE_CASE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="case_sensitive_keys_kv", + doc={"arr": [{"k": "price", "v": 24}, {"k": "PRICE", "v": 100}]}, + expression={"$arrayToObject": "$arr"}, + expected={"price": 24, "PRICE": 100}, + msg="$arrayToObject should treat case-differing keys as distinct", + ), + ExpressionTestCase( + id="case_sensitive_keys_pair", + doc={"arr": [["price", 24], ["PRICE", 100]]}, + expression={"$arrayToObject": "$arr"}, + expected={"price": 24, "PRICE": 100}, + msg="$arrayToObject should treat case-differing keys as distinct (pair form)", + ), + ExpressionTestCase( + id="deeply_nested_object_value", + doc={"arr": [["key", {"a": {"b": {"c": {"d": 1}}}}]]}, + expression={"$arrayToObject": "$arr"}, + expected={"key": {"a": {"b": {"c": {"d": 1}}}}}, + msg="$arrayToObject should handle deeply nested object", + ), + ExpressionTestCase( + id="deeply_nested_array_value", + doc={"arr": [["key", [[[[1]]]]]]}, + expression={"$arrayToObject": "$arr"}, + expected={"key": [[[[1]]]]}, + msg="$arrayToObject should handle deeply nested array", + ), + ExpressionTestCase( + id="empty_object_value", + doc={"arr": [["key", {}]]}, + expression={"$arrayToObject": "$arr"}, + expected={"key": {}}, + msg="$arrayToObject should handle empty object value", + ), + ExpressionTestCase( + id="empty_array_value", + doc={"arr": [["key", []]]}, + expression={"$arrayToObject": "$arr"}, + expected={"key": []}, + msg="$arrayToObject should handle empty array value", + ), + ExpressionTestCase( + id="empty_string_value", + doc={"arr": [["key", ""]]}, + expression={"$arrayToObject": "$arr"}, + expected={"key": ""}, + msg="$arrayToObject should handle empty string value", + ), + ExpressionTestCase( + id="large_string_value", + doc={"arr": [["key", "x" * 10_240]]}, + expression={"$arrayToObject": "$arr"}, + expected={"key": "x" * 10_240}, + msg="$arrayToObject should handle large string value", + ), +] + +ALL_TESTS = ( + KV_FORM_TESTS + + TWO_ELEM_FORM_TESTS + + EMPTY_AND_NULL_ARRAY_TESTS + + DUPLICATE_KEY_TESTS + + KEY_EDGE_TESTS + + EDGE_CASE_TESTS +) + +TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ + ExpressionTestCase( + "kv_single_pair_literal", + doc=None, + expression={"$arrayToObject": {"$literal": [{"k": "a", "v": 1}]}}, + expected={"a": 1}, + msg="$arrayToObject should build object from literal kv pair", + ), + ExpressionTestCase( + "pair_single_literal", + doc=None, + expression={"$arrayToObject": {"$literal": [["a", 1]]}}, + expected={"a": 1}, + msg="$arrayToObject should build object from literal two-element pair", + ), + ExpressionTestCase( + "empty_array_literal", + doc=None, + expression={"$arrayToObject": {"$literal": []}}, + expected={}, + msg="$arrayToObject should return empty object for literal empty array", + ), + ExpressionTestCase( + "kv_duplicate_keys_literal", + doc=None, + expression={"$arrayToObject": {"$literal": [{"k": "a", "v": 1}, {"k": "a", "v": 2}]}}, + expected={"a": 2}, + msg="$arrayToObject should use last value for literal duplicate keys", + ), + ExpressionTestCase( + "case_sensitive_keys_kv_literal", + doc=None, + expression={"$arrayToObject": {"$literal": [{"k": "Name", "v": 1}, {"k": "name", "v": 2}]}}, + expected={"Name": 1, "name": 2}, + msg="$arrayToObject should treat literal keys as case-sensitive", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_arrayToObject_literal(collection, test): + """Test $arrayToObject with literal values.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_arrayToObject_insert(collection, test): + """Test $arrayToObject with values from inserted documents.""" + 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 + ) + + +@pytest.mark.parametrize( + "large_arr", + [ + pytest.param( + lazy(lambda: [[f"key_{i}", i] for i in range(10_000)]), id="two_element_pairs" + ), + pytest.param( + lazy(lambda: [{"k": f"key_{i}", "v": i} for i in range(10_000)]), id="kv_documents" + ), + ], +) +def test_arrayToObject_large_array(collection, large_arr): + """Test $arrayToObject builds a 10,000-field object from pair and k/v forms.""" + expected = {f"key_{i}": i for i in range(10_000)} + result = execute_expression(collection, {"$arrayToObject": {"$literal": large_arr}}) + assert_expression_result( + result, + expected=expected, + msg="$arrayToObject should build a 10,000-field object", + ) + + +def test_arrayToObject_preserves_field_order(collection): + """Test $arrayToObject preserves input pair order in the output object. + + Wrapped in $objectToArray so the assertion observes key order; a plain object + comparison is order-insensitive and would not detect a reordering. + """ + result = execute_expression( + collection, + {"$objectToArray": {"$arrayToObject": {"$literal": [["z", 1], ["a", 2], ["m", 3]]}}}, + ) + assert_expression_result( + result, + expected=[{"k": "z", "v": 1}, {"k": "a", "v": 2}, {"k": "m", "v": 3}], + msg="$arrayToObject should preserve input field order in the output", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_arrayToObject_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_arrayToObject_errors.py new file mode 100644 index 000000000..a45e10e21 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_arrayToObject_errors.py @@ -0,0 +1,449 @@ +""" +Error tests for $arrayToObject expression. + +Tests non-array input, invalid element format, non-string keys, +and wrong arity errors. +""" + +from datetime import datetime + +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 ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + ARRAY_TO_OBJECT_INVALID_ELEMENT_ERROR, + ARRAY_TO_OBJECT_INVALID_KV_DOC_ERROR, + ARRAY_TO_OBJECT_INVALID_PAIR_ERROR, + ARRAY_TO_OBJECT_KV_KEY_NOT_STRING_ERROR, + ARRAY_TO_OBJECT_MIXED_KV_THEN_PAIR_ERROR, + ARRAY_TO_OBJECT_MIXED_PAIR_THEN_KV_ERROR, + ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + ARRAY_TO_OBJECT_NULL_BYTE_KV_KEY_ERROR, + ARRAY_TO_OBJECT_NULL_BYTE_PAIR_KEY_ERROR, + ARRAY_TO_OBJECT_PAIR_KEY_NOT_STRING_ERROR, + ARRAY_TO_OBJECT_WRONG_FIELD_NAMES_ERROR, + EXPRESSION_TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Array Type Strictness]: $arrayToObject rejects a non-array input. +NOT_ARRAY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="string_input", + doc={"arr": "hello"}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject string input", + ), + ExpressionTestCase( + id="int_input", + doc={"arr": 42}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject int input", + ), + ExpressionTestCase( + id="bool_input", + doc={"arr": True}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject bool input", + ), + ExpressionTestCase( + id="object_input", + doc={"arr": {"a": 1}}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject object input", + ), + ExpressionTestCase( + id="double_input", + doc={"arr": 3.14}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject double input", + ), + ExpressionTestCase( + id="decimal128_input", + doc={"arr": Decimal128("1")}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject decimal128 input", + ), + ExpressionTestCase( + id="int64_input", + doc={"arr": Int64(1)}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject int64 input", + ), + ExpressionTestCase( + id="objectid_input", + doc={"arr": ObjectId()}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject objectid input", + ), + ExpressionTestCase( + id="datetime_input", + doc={"arr": datetime(2024, 1, 1)}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject datetime input", + ), + ExpressionTestCase( + id="binary_input", + doc={"arr": Binary(b"x", 0)}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject binary input", + ), + ExpressionTestCase( + id="regex_input", + doc={"arr": Regex("x")}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject regex input", + ), + ExpressionTestCase( + id="maxkey_input", + doc={"arr": MaxKey()}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject maxkey input", + ), + ExpressionTestCase( + id="minkey_input", + doc={"arr": MinKey()}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject minkey input", + ), + ExpressionTestCase( + id="timestamp_input", + doc={"arr": Timestamp(0, 0)}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject timestamp input", + ), +] + +# Property [Element Format]: $arrayToObject rejects an element that is not a k/v doc or pair. +INVALID_ELEMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="element_is_string", + doc={"arr": ["not_a_pair"]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_INVALID_ELEMENT_ERROR, + msg="$arrayToObject should reject string element", + ), + ExpressionTestCase( + id="element_is_int", + doc={"arr": [42]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_INVALID_ELEMENT_ERROR, + msg="$arrayToObject should reject int element", + ), + ExpressionTestCase( + id="element_is_null", + doc={"arr": [None]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_INVALID_ELEMENT_ERROR, + msg="$arrayToObject should reject null element", + ), + ExpressionTestCase( + id="element_is_bool", + doc={"arr": [True]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_INVALID_ELEMENT_ERROR, + msg="$arrayToObject should reject bool element", + ), + ExpressionTestCase( + id="element_is_double", + doc={"arr": [3.14]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_INVALID_ELEMENT_ERROR, + msg="$arrayToObject should reject double element", + ), + ExpressionTestCase( + id="element_is_objectid", + doc={"arr": [ObjectId()]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_INVALID_ELEMENT_ERROR, + msg="$arrayToObject should reject ObjectId element", + ), + ExpressionTestCase( + id="kv_missing_v", + doc={"arr": [{"k": "a"}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_INVALID_KV_DOC_ERROR, + msg="$arrayToObject should reject k/v doc missing v field", + ), + ExpressionTestCase( + id="kv_missing_k", + doc={"arr": [{"v": 1}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_INVALID_KV_DOC_ERROR, + msg="$arrayToObject should reject k/v doc missing k field", + ), + ExpressionTestCase( + id="kv_extra_field", + doc={"arr": [{"k": "a", "v": 1, "extra": 2}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_INVALID_KV_DOC_ERROR, + msg="$arrayToObject should reject k/v doc with extra field", + ), + ExpressionTestCase( + id="kv_empty_doc", + doc={"arr": [{}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_INVALID_KV_DOC_ERROR, + msg="$arrayToObject should reject empty document", + ), + ExpressionTestCase( + id="kv_wrong_field_names", + doc={"arr": [{"y": "x", "x": "y"}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_WRONG_FIELD_NAMES_ERROR, + msg="$arrayToObject should reject wrong field names", + ), + ExpressionTestCase( + id="kv_uppercase_K", + doc={"arr": [{"K": "k1", "v": 2}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_WRONG_FIELD_NAMES_ERROR, + msg="$arrayToObject should reject uppercase K (case-sensitive)", + ), + ExpressionTestCase( + id="kv_uppercase_V", + doc={"arr": [{"k": "k1", "V": 2}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_WRONG_FIELD_NAMES_ERROR, + msg="$arrayToObject should reject uppercase V (case-sensitive)", + ), + ExpressionTestCase( + id="kv_key_value_names", + doc={"arr": [{"key": "k1", "value": "v1"}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_WRONG_FIELD_NAMES_ERROR, + msg="$arrayToObject should reject 'key'/'value' instead of 'k'/'v'", + ), + ExpressionTestCase( + id="pair_one_element", + doc={"arr": [["a"]]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_INVALID_PAIR_ERROR, + msg="$arrayToObject should reject one-element array pair", + ), + ExpressionTestCase( + id="pair_three_elements", + doc={"arr": [["a", 1, 2]]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_INVALID_PAIR_ERROR, + msg="$arrayToObject should reject three-element array pair", + ), + ExpressionTestCase( + id="pair_empty_array", + doc={"arr": [[]]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_INVALID_PAIR_ERROR, + msg="$arrayToObject should reject empty array pair", + ), +] + +# Property [Key Type Strictness]: $arrayToObject rejects a non-string key. +KEY_NOT_STRING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="kv_int_key", + doc={"arr": [{"k": 1, "v": "val"}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_KV_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject int key in k/v form", + ), + ExpressionTestCase( + id="kv_bool_key", + doc={"arr": [{"k": True, "v": "val"}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_KV_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject bool key in k/v form", + ), + ExpressionTestCase( + id="kv_null_key", + doc={"arr": [{"k": None, "v": "val"}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_KV_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject null key in k/v form", + ), + ExpressionTestCase( + id="kv_array_key", + doc={"arr": [{"k": [1], "v": "val"}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_KV_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject array key in k/v form", + ), + ExpressionTestCase( + id="kv_object_key", + doc={"arr": [{"k": {"x": 1}, "v": "val"}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_KV_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject object key in k/v form", + ), + ExpressionTestCase( + id="kv_double_key", + doc={"arr": [{"k": 1.5, "v": "val"}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_KV_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject double key in k/v form", + ), + ExpressionTestCase( + id="kv_int64_key", + doc={"arr": [{"k": Int64(1), "v": "val"}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_KV_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject Int64 key in k/v form", + ), + ExpressionTestCase( + id="kv_decimal128_key", + doc={"arr": [{"k": Decimal128("1"), "v": "val"}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_KV_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject Decimal128 key in k/v form", + ), + ExpressionTestCase( + id="pair_int_key", + doc={"arr": [[1, "val"]]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_PAIR_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject int key in pair form", + ), + ExpressionTestCase( + id="pair_bool_key", + doc={"arr": [[True, "val"]]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_PAIR_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject bool key in pair form", + ), + ExpressionTestCase( + id="pair_null_key", + doc={"arr": [[None, "val"]]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_PAIR_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject null key in pair form", + ), + ExpressionTestCase( + id="pair_array_key", + doc={"arr": [[[1], "val"]]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_PAIR_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject array key in pair form", + ), + ExpressionTestCase( + id="pair_object_key", + doc={"arr": [[{"x": 1}, "val"]]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_PAIR_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject object key in pair form", + ), + ExpressionTestCase( + id="pair_double_key", + doc={"arr": [[1.5, "val"]]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_PAIR_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject double key in pair form", + ), + ExpressionTestCase( + id="pair_int64_key", + doc={"arr": [[Int64(1), "val"]]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_PAIR_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject Int64 key in pair form", + ), + ExpressionTestCase( + id="pair_decimal128_key", + doc={"arr": [[Decimal128("1"), "val"]]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_PAIR_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject Decimal128 key in pair form", + ), +] + +# Property [Mixed Formats]: $arrayToObject rejects arrays mixing k/v doc and pair forms. +MIXED_FORMAT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="mixed_kv_then_pair", + doc={"arr": [{"k": "price", "v": 24}, ["item", "apple"]]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_MIXED_KV_THEN_PAIR_ERROR, + msg="$arrayToObject should reject a k/v doc followed by a pair", + ), + ExpressionTestCase( + id="mixed_pair_then_kv", + doc={"arr": [["item", "apple"], {"k": "price", "v": 24}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_MIXED_PAIR_THEN_KV_ERROR, + msg="$arrayToObject should reject a pair followed by a k/v doc", + ), + ExpressionTestCase( + id="mixed_pair_then_non_array", + doc={"arr": [["a", 1], 123]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_MIXED_PAIR_THEN_KV_ERROR, + msg="$arrayToObject should reject a pair followed by a non-array element", + ), +] + +# Property [Null Byte Key]: $arrayToObject rejects a key containing a null byte. +NULL_BYTE_KEY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="null_byte_in_key_pair", + doc={"arr": [["a\x00b", "value"]]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NULL_BYTE_PAIR_KEY_ERROR, + msg="$arrayToObject should reject a null byte in a key (pair form)", + ), + ExpressionTestCase( + id="null_byte_in_key_kv", + doc={"arr": [{"k": "a\x00b", "v": "value"}]}, + expression={"$arrayToObject": "$arr"}, + error_code=ARRAY_TO_OBJECT_NULL_BYTE_KV_KEY_ERROR, + msg="$arrayToObject should reject a null byte in a key (k/v form)", + ), +] + +ALL_TESTS = ( + NOT_ARRAY_ERROR_TESTS + + INVALID_ELEMENT_TESTS + + KEY_NOT_STRING_TESTS + + MIXED_FORMAT_TESTS + + NULL_BYTE_KEY_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_arrayToObject_insert(collection, test): + """Test $arrayToObject error cases with values from inserted documents.""" + 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 + ) + + +# Property [Arity]: $arrayToObject requires exactly one argument. +ARITY_ERROR_TESTS = [ + pytest.param({"$arrayToObject": [[], []]}, id="two_args"), +] + + +@pytest.mark.parametrize("expr", ARITY_ERROR_TESTS) +def test_arrayToObject_arity_error(collection, expr): + """Test $arrayToObject errors with wrong number of arguments.""" + result = execute_expression(collection, expr) + assert_expression_result(result, error_code=EXPRESSION_TYPE_MISMATCH_ERROR) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_arrayToObject_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_arrayToObject_expressions.py new file mode 100644 index 000000000..e5bd305e9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_arrayToObject_expressions.py @@ -0,0 +1,182 @@ +""" +Expression and field path tests for $arrayToObject expression. + +Tests field path lookups, composite paths, key edge cases, +system variables, and null/missing handling. +""" + +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 ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Field Path]: $arrayToObject resolves a field-path array argument. +FIELD_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="nested_field_path", + expression={"$arrayToObject": "$a.b"}, + doc={"a": {"b": [{"k": "x", "v": 1}]}}, + expected={"x": 1}, + msg="$arrayToObject should resolve nested field path", + ), + ExpressionTestCase( + id="nonexistent_field_null", + expression={"$arrayToObject": "$a.nonexistent"}, + doc={"a": {"missing": 1}}, + expected=None, + msg="$arrayToObject should return null for a non-existent field", + ), + ExpressionTestCase( + id="deeply_nested_field", + expression={"$arrayToObject": "$a.b.c"}, + doc={"a": {"b": {"c": [{"k": "x", "v": 1}]}}}, + expected={"x": 1}, + msg="$arrayToObject should resolve deeply nested field path", + ), +] + +# Property [Composite Path]: $arrayToObject resolves a composite array built from a dotted path. +COMPOSITE_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="composite_array_path", + expression={"$arrayToObject": "$a.b"}, + doc={"a": [{"b": {"k": "x", "v": 1}}, {"b": {"k": "y", "v": 2}}]}, + expected={"x": 1, "y": 2}, + msg="$arrayToObject should resolve a composite array path to a valid k/v array", + ), +] + +# Property [Key Characters]: $arrayToObject preserves special key characters from expression input. +KEY_EDGE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="empty_string_key", + expression={"$arrayToObject": "$arr"}, + doc={"arr": [{"k": "", "v": 1}]}, + expected={"": 1}, + msg="$arrayToObject should handle empty string key", + ), + ExpressionTestCase( + id="key_with_dots", + expression={"$arrayToObject": "$arr"}, + doc={"arr": [{"k": "a.b.c", "v": 1}]}, + expected={"a.b.c": 1}, + msg="$arrayToObject should handle key with dots", + ), + ExpressionTestCase( + id="key_with_dollar", + expression={"$arrayToObject": "$arr"}, + doc={"arr": [{"k": "$field", "v": 1}]}, + expected={"$field": 1}, + msg="$arrayToObject should handle key with dollar sign", + ), +] + +# Property [Variables]: $arrayToObject works with $let and system variables like $$ROOT. +SYSTEM_VAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="let_variable", + expression={"$let": {"vars": {"arr": "$arr"}, "in": {"$arrayToObject": "$$arr"}}}, + doc={"arr": [["a", 1]]}, + expected={"a": 1}, + msg="$arrayToObject should work with $let variable", + ), + ExpressionTestCase( + id="root_variable", + expression={"$arrayToObject": "$$ROOT.pairs"}, + doc={"_id": 1, "pairs": [["a", 1]]}, + expected={"a": 1}, + msg="$arrayToObject should work with $$ROOT", + ), + ExpressionTestCase( + id="current_variable", + expression={"$arrayToObject": "$$CURRENT.pairs"}, + doc={"_id": 2, "pairs": [["a", 1]]}, + expected={"a": 1}, + msg="$arrayToObject should treat $$CURRENT like the field path", + ), +] + +# Property [Null Propagation]: $arrayToObject returns null when the field path is null or missing. +NULL_MISSING_EXPR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="missing_field", + expression={"$arrayToObject": "$nonexistent"}, + doc={"other": 1}, + expected=None, + msg="$arrayToObject should return null for a missing field", + ), + ExpressionTestCase( + id="missing_input_type_is_null", + expression={"$type": {"$arrayToObject": "$nonexistent"}}, + doc={"x": 1}, + expected="null", + msg="$arrayToObject should produce null type for a missing field", + ), +] + +# Property [Expression Inputs]: $arrayToObject evaluates expressions and array-expression +# literals that produce the input array, not just field paths and $literal arrays. +# Property [Expression Input]: $arrayToObject accepts expression operators as input. +EXPRESSION_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="expression_operator_input", + expression={"$arrayToObject": {"$concatArrays": ["$a", "$b"]}}, + doc={"a": [["k1", 1]], "b": [["k2", 2]]}, + expected={"k1": 1, "k2": 2}, + msg="$arrayToObject should evaluate a $concatArrays expression that builds the input array", + ), + ExpressionTestCase( + id="array_expression_input", + expression={"$arrayToObject": [[["$k", "$v"]]]}, + doc={"k": "x", "v": 5}, + expected={"x": 5}, + msg="$arrayToObject should evaluate an array expression containing field references", + ), + ExpressionTestCase( + id="map_expression_input", + expression={ + "$arrayToObject": {"$map": {"input": "$pairs", "as": "p", "in": ["$$p.k", "$$p.v"]}} + }, + doc={"pairs": [{"k": "a", "v": 1}, {"k": "b", "v": 2}]}, + expected={"a": 1, "b": 2}, + msg="$arrayToObject should evaluate a $map expression that builds the input array", + ), +] + +# Property [Array Index Path]: numeric path components like ".0" address object keys, not +# array positions, in aggregation expression context. +# Property [Numeric Path]: $arrayToObject resolves numeric path components on objects. +ARRAY_INDEX_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="object_numeric_key_path", + expression={"$arrayToObject": "$a.0"}, + doc={"a": {"0": [["k", 1]]}}, + expected={"k": 1}, + msg="$arrayToObject should resolve a numeric key path through an object, not an index", + ), +] + +ALL_EXPR_TESTS = ( + FIELD_LOOKUP_TESTS + + COMPOSITE_PATH_TESTS + + KEY_EDGE_TESTS + + SYSTEM_VAR_TESTS + + NULL_MISSING_EXPR_TESTS + + EXPRESSION_INPUT_TESTS + + ARRAY_INDEX_PATH_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_EXPR_TESTS)) +def test_arrayToObject_expression(collection, test): + """Test $arrayToObject with field paths and expressions.""" + 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/arrayToObject/test_smoke_expression_arrayToObject.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_smoke_arrayToObject.py similarity index 88% rename from documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_smoke_expression_arrayToObject.py rename to documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_smoke_arrayToObject.py index d589cb42b..8f8dff973 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_smoke_expression_arrayToObject.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_smoke_arrayToObject.py @@ -31,4 +31,6 @@ def test_smoke_expression_arrayToObject(collection): ) expected = [{"_id": 1, "obj": {"a": 1, "b": 2}}, {"_id": 2, "obj": {"x": 10, "y": 20}}] - assertSuccess(result, expected, msg="Should support $arrayToObject expression") + assertSuccess( + result, expected, "Should support $arrayToObject expression", ignore_doc_order=True + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_concatArrays_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_concatArrays_bson_types.py new file mode 100644 index 000000000..0a21fbf5a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_concatArrays_bson_types.py @@ -0,0 +1,224 @@ +""" +BSON type element preservation tests for $concatArrays expression. + +Tests that various BSON types are preserved when concatenating 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 ( + 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]: $concatArrays preserves each element's BSON type. +BSON_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="int64_values", + doc={"arr0": [Int64(1), Int64(2)], "arr1": [Int64(3)]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[Int64(1), Int64(2), Int64(3)], + msg="$concatArrays should preserve Int64 values", + ), + ExpressionTestCase( + id="decimal128_values", + doc={ + "arr0": [DECIMAL128_ONE_AND_HALF], + "arr1": [DECIMAL128_TWO_AND_HALF, Decimal128("3.5")], + }, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[DECIMAL128_ONE_AND_HALF, DECIMAL128_TWO_AND_HALF, Decimal128("3.5")], + msg="$concatArrays should preserve Decimal128 values", + ), + ExpressionTestCase( + id="datetime_values", + doc={ + "arr0": [datetime(2024, 1, 1, tzinfo=timezone.utc)], + "arr1": [datetime(2024, 6, 1, tzinfo=timezone.utc)], + }, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 6, 1, tzinfo=timezone.utc), + ], + msg="$concatArrays should preserve datetime values", + ), + ExpressionTestCase( + id="objectid_values", + doc={ + "arr0": [ObjectId("000000000000000000000001")], + "arr1": [ObjectId("000000000000000000000002")], + }, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[ + ObjectId("000000000000000000000001"), + ObjectId("000000000000000000000002"), + ], + msg="$concatArrays should preserve ObjectId values", + ), + ExpressionTestCase( + id="binary_values", + doc={"arr0": [Binary(b"\x01", 0)], "arr1": [Binary(b"\x02", 0)]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[b"\x01", b"\x02"], + msg="$concatArrays should preserve Binary values", + ), + ExpressionTestCase( + id="regex_values", + doc={"arr0": [Regex("^a", "i")], "arr1": [Regex("^b", "i")]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[Regex("^a", "i"), Regex("^b", "i")], + msg="$concatArrays should preserve Regex values", + ), + ExpressionTestCase( + id="timestamp_values", + doc={"arr0": [Timestamp(1, 0)], "arr1": [Timestamp(2, 0)]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[Timestamp(1, 0), Timestamp(2, 0)], + msg="$concatArrays should preserve Timestamp values", + ), + ExpressionTestCase( + id="minkey_maxkey", + doc={"arr0": [MinKey()], "arr1": [MaxKey()]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[MinKey(), MaxKey()], + msg="$concatArrays should preserve MinKey/MaxKey values", + ), + ExpressionTestCase( + id="uuid_values", + doc={ + "arr0": [Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210"))], + "arr1": [Binary.from_uuid(UUID("fedcba98-7654-3210-0123-456789abcdef"))], + }, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[ + Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), + Binary.from_uuid(UUID("fedcba98-7654-3210-0123-456789abcdef")), + ], + msg="$concatArrays should preserve UUID binary values", + ), +] + +# Property [Mixed Types]: $concatArrays concatenates arrays holding mixed BSON element types. +MIXED_BSON_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="mixed_bson_types", + doc={"arr0": [1, "two", Int64(3)], "arr1": [Decimal128("4"), True, None, MinKey()]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[1, "two", Int64(3), Decimal128("4"), True, None, MinKey()], + msg="$concatArrays should concatenate mixed BSON types preserving each", + ), + ExpressionTestCase( + id="mixed_dates_and_ids", + doc={ + "arr0": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + ObjectId("000000000000000000000001"), + ], + "arr1": [Timestamp(1, 0), Binary(b"\x01", 0)], + }, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[ + datetime(2024, 1, 1, tzinfo=timezone.utc), + ObjectId("000000000000000000000001"), + Timestamp(1, 0), + b"\x01", + ], + msg="$concatArrays should concatenate dates, ObjectIds, timestamps, and binary", + ), + ExpressionTestCase( + id="mixed_extremes", + doc={"arr0": [MinKey(), FLOAT_NEGATIVE_INFINITY, None], "arr1": [FLOAT_INFINITY, MaxKey()]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[MinKey(), FLOAT_NEGATIVE_INFINITY, None, FLOAT_INFINITY, MaxKey()], + msg="$concatArrays should concatenate MinKey, MaxKey, infinities, and null", + ), +] + +# Property [Special Numerics]: $concatArrays preserves NaN, Infinity, and negative zero elements. +SPECIAL_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="infinity_values", + doc={"arr0": [FLOAT_INFINITY], "arr1": [FLOAT_NEGATIVE_INFINITY]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[FLOAT_INFINITY, FLOAT_NEGATIVE_INFINITY], + msg="$concatArrays should preserve infinity values", + ), + ExpressionTestCase( + id="decimal128_infinity", + doc={"arr0": [DECIMAL128_INFINITY], "arr1": [DECIMAL128_NEGATIVE_INFINITY]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[DECIMAL128_INFINITY, DECIMAL128_NEGATIVE_INFINITY], + msg="$concatArrays should preserve Decimal128 infinity values", + ), + ExpressionTestCase( + id="boundary_values", + doc={"arr0": [INT32_MIN, INT32_MAX], "arr1": [INT64_MIN, INT64_MAX]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[INT32_MIN, INT32_MAX, INT64_MIN, INT64_MAX], + msg="$concatArrays should preserve numeric boundary values", + ), + ExpressionTestCase( + id="negative_zero", + doc={"arr0": [DOUBLE_NEGATIVE_ZERO], "arr1": [DECIMAL128_NEGATIVE_ZERO]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[DOUBLE_NEGATIVE_ZERO, DECIMAL128_NEGATIVE_ZERO], + msg="$concatArrays should preserve negative zero values", + ), +] + +# Property [Element Identity]: $concatArrays preserves element values and order. +ELEMENT_PRESERVATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="decimal128_trailing_zeros", + doc={"arr0": [DECIMAL128_TRAILING_ZERO], "arr1": [Decimal128("1.00"), Decimal128("1.000")]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[DECIMAL128_TRAILING_ZERO, Decimal128("1.00"), Decimal128("1.000")], + msg="$concatArrays should preserve Decimal128 trailing zeros", + ), + ExpressionTestCase( + id="decimal128_nan", + doc={"arr0": [DECIMAL128_NAN], "arr1": [Decimal128("1")]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[DECIMAL128_NAN, Decimal128("1")], + msg="$concatArrays should preserve a Decimal128 NaN element", + ), +] + +ALL_BSON_TESTS = ( + BSON_TYPE_TESTS + MIXED_BSON_TESTS + SPECIAL_NUMERIC_TESTS + ELEMENT_PRESERVATION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_BSON_TESTS)) +def test_concatArrays_bson_insert(collection, test): + """Test $concatArrays BSON types with values from inserted documents.""" + 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/concatArrays/test_concatArrays_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_concatArrays_core_behavior.py new file mode 100644 index 000000000..87adebc2c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_concatArrays_core_behavior.py @@ -0,0 +1,361 @@ +""" +Core behavior tests for $concatArrays expression. + +Tests concatenation of arrays with various element types, empty arrays, +single arrays, multiple arrays, nested arrays, duplicates, null +propagation, 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 ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Concatenation]: $concatArrays joins multiple arrays into one in argument order. +BASIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "two_int_arrays", + doc={"arr0": [1, 2], "arr1": [3, 4]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[1, 2, 3, 4], + msg="$concatArrays should concatenate two int arrays", + ), + ExpressionTestCase( + "two_string_arrays", + doc={"arr0": ["a", "b"], "arr1": ["c", "d"]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=["a", "b", "c", "d"], + msg="$concatArrays should concatenate two string arrays", + ), + ExpressionTestCase( + "three_arrays", + doc={"arr0": [1, 2], "arr1": [3, 4], "arr2": [5, 6]}, + expression={"$concatArrays": ["$arr0", "$arr1", "$arr2"]}, + expected=[1, 2, 3, 4, 5, 6], + msg="$concatArrays should concatenate three arrays", + ), + ExpressionTestCase( + "mixed_type_elements", + doc={"arr0": [1, "two"], "arr1": [True, None, {"a": 1}]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[1, "two", True, None, {"a": 1}], + msg="$concatArrays should concatenate arrays with mixed types", + ), +] + +# Property [Empty Arrays]: $concatArrays treats empty arrays as contributing no elements. +EMPTY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "both_empty", + doc={"arr0": [], "arr1": []}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[], + msg="$concatArrays should return empty array for two empty arrays", + ), + ExpressionTestCase( + "first_empty", + doc={"arr0": [], "arr1": [1, 2]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[1, 2], + msg="$concatArrays should return second array when first is empty", + ), + ExpressionTestCase( + "second_empty", + doc={"arr0": [1, 2], "arr1": []}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[1, 2], + msg="$concatArrays should return first array when second is empty", + ), + ExpressionTestCase( + "all_empty", + doc={"arr0": [], "arr1": [], "arr2": []}, + expression={"$concatArrays": ["$arr0", "$arr1", "$arr2"]}, + expected=[], + msg="$concatArrays should return empty array for all empty inputs", + ), + ExpressionTestCase( + "no_arguments", + doc={"x": 1}, + expression={"$concatArrays": []}, + expected=[], + msg="$concatArrays should return an empty array for no arguments", + ), + ExpressionTestCase( + "empty_between_nonempty", + doc={"arr0": [1], "arr1": [], "arr2": [2]}, + expression={"$concatArrays": ["$arr0", "$arr1", "$arr2"]}, + expected=[1, 2], + msg="$concatArrays should skip an empty array between non-empty arrays", + ), + ExpressionTestCase( + "multiple_empty", + doc={"arr0": [], "arr1": [], "arr2": [], "arr3": []}, + expression={"$concatArrays": ["$arr0", "$arr1", "$arr2", "$arr3"]}, + expected=[], + msg="$concatArrays should return an empty array for multiple empty arrays", + ), +] + +# Property [Single Array]: $concatArrays returns a single array argument unchanged. +SINGLE_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "single_array", + doc={"arr0": [1, 2, 3]}, + expression={"$concatArrays": ["$arr0"]}, + expected=[1, 2, 3], + msg="$concatArrays should return the single array unchanged", + ), + ExpressionTestCase( + "single_empty_array", + doc={"arr0": []}, + expression={"$concatArrays": ["$arr0"]}, + expected=[], + msg="$concatArrays should return empty array for single empty input", + ), +] + +# Property [Top Level Only]: $concatArrays joins at the top level without flattening. +NESTED_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_subarrays", + doc={"arr0": [[1, 2]], "arr1": [[3, 4]]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[[1, 2], [3, 4]], + msg="$concatArrays should concatenate top-level, not flatten subarrays", + ), + ExpressionTestCase( + "mixed_nested", + doc={"arr0": [[1], "two"], "arr1": [[3, 4]]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[[1], "two", [3, 4]], + msg="$concatArrays should concatenate mixed nested elements", + ), + ExpressionTestCase( + "deeply_nested", + doc={"arr0": [[[1]]], "arr1": [[[2]]]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[[[1]], [[2]]], + msg="$concatArrays should preserve deeply nested array elements", + ), + ExpressionTestCase( + "empty_nested", + doc={"arr0": [[]], "arr1": [[]]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[[], []], + msg="$concatArrays should preserve empty nested arrays as elements", + ), +] + +# Property [Duplicates]: $concatArrays keeps duplicate elements from the inputs. +DUPLICATE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "duplicate_elements", + doc={"arr0": [1, 2, 3], "arr1": [2, 3, 4]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[1, 2, 3, 2, 3, 4], + msg="$concatArrays should preserve duplicate elements across arrays", + ), + ExpressionTestCase( + "identical_arrays", + doc={"arr0": [1, 2], "arr1": [1, 2]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[1, 2, 1, 2], + msg="$concatArrays should concatenate identical arrays", + ), +] + +# Property [Null Propagation]: $concatArrays returns null when any argument is null or missing. +NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_first_arg", + doc={"arr0": None, "arr1": [1, 2]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=None, + msg="$concatArrays should return null when first argument is null", + ), + ExpressionTestCase( + "null_second_arg", + doc={"arr0": [1, 2], "arr1": None}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=None, + msg="$concatArrays should return null when second argument is null", + ), + ExpressionTestCase( + "all_null", + doc={"arr0": None, "arr1": None}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=None, + msg="$concatArrays should return null when all arguments are null", + ), + ExpressionTestCase( + "null_among_three", + doc={"arr0": [1], "arr1": None, "arr2": [2]}, + expression={"$concatArrays": ["$arr0", "$arr1", "$arr2"]}, + expected=None, + msg="$concatArrays should return null when any argument is null", + ), + ExpressionTestCase( + "null_elements_in_arrays", + doc={"arr0": [1, None], "arr1": [None, 2]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[1, None, None, 2], + msg="$concatArrays should preserve null elements within arrays", + ), +] + +# Property [Object Elements]: $concatArrays concatenates arrays of documents intact. +OBJECT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arrays_of_objects", + doc={"arr0": [{"a": 1}], "arr1": [{"b": 2}]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[{"a": 1}, {"b": 2}], + msg="$concatArrays should concatenate arrays of objects", + ), + ExpressionTestCase( + "objects_with_arrays", + doc={"arr0": [{"items": [1, 2]}], "arr1": [{"items": [3, 4]}]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=[{"items": [1, 2]}, {"items": [3, 4]}], + msg="$concatArrays should preserve inner arrays in objects", + ), +] + +# Property [Large Arrays]: $concatArrays concatenates large arrays. +_LARGE_A = list(range(500)) +_LARGE_B = list(range(500, 1000)) + +# Property [Large Arrays]: $concatArrays concatenates large arrays. +LARGE_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "large_arrays", + doc={"arr0": _LARGE_A, "arr1": _LARGE_B}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=list(range(1000)), + msg="$concatArrays should concatenate large arrays", + ), + ExpressionTestCase( + "two_5000_arrays", + doc={"arr0": list(range(5_000)), "arr1": list(range(5_000, 10000))}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=list(range(10_000)), + msg="$concatArrays should concatenate two large arrays into 10,000 elements", + ), + ExpressionTestCase( + "one_large_one_small", + doc={"arr0": list(range(10_000)), "arr1": [10_000]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + expected=list(range(10_001)), + msg="$concatArrays should concatenate a large array and a small array", + ), + ExpressionTestCase( + "100_single_element_arrays", + doc={f"arr{i}": [i] for i in range(100)}, + expression={"$concatArrays": [f"$arr{i}" for i in range(100)]}, + expected=list(range(100)), + msg="$concatArrays should concatenate 100 single-element arrays", + ), +] + +# Property [Many Arrays]: $concatArrays concatenates many array arguments. +MANY_ARRAYS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "five_arrays", + doc={"arr0": [1], "arr1": [2], "arr2": [3], "arr3": [4], "arr4": [5]}, + expression={"$concatArrays": ["$arr0", "$arr1", "$arr2", "$arr3", "$arr4"]}, + expected=[1, 2, 3, 4, 5], + msg="$concatArrays should concatenate five arrays", + ), + ExpressionTestCase( + "ten_empty_arrays", + doc={f"arr{i}": [] for i in range(10)}, + expression={"$concatArrays": [f"$arr{i}" for i in range(10)]}, + expected=[], + msg="$concatArrays should concatenate ten empty arrays", + ), + ExpressionTestCase( + "fifty_arrays", + doc={f"arr{i}": [i] for i in range(50)}, + expression={"$concatArrays": [f"$arr{i}" for i in range(50)]}, + expected=list(range(50)), + msg="$concatArrays should concatenate 50 arrays", + ), +] + +ALL_TESTS = ( + BASIC_TESTS + + EMPTY_TESTS + + SINGLE_ARRAY_TESTS + + NESTED_ARRAY_TESTS + + DUPLICATE_TESTS + + NULL_TESTS + + OBJECT_TESTS + + LARGE_ARRAY_TESTS + + MANY_ARRAYS_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_concatArrays_insert(collection, test): + """Test $concatArrays with values from inserted documents.""" + 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 + ) + + +TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ + ExpressionTestCase( + "two_int_arrays", + doc=None, + expression={"$concatArrays": [{"$literal": [1, 2]}, {"$literal": [3, 4]}]}, + expected=[1, 2, 3, 4], + msg="$concatArrays should concatenate two literal int arrays", + ), + ExpressionTestCase( + "three_arrays", + doc=None, + expression={ + "$concatArrays": [{"$literal": [1, 2]}, {"$literal": [3, 4]}, {"$literal": [5, 6]}] + }, + expected=[1, 2, 3, 4, 5, 6], + msg="$concatArrays should concatenate three literal arrays", + ), + ExpressionTestCase( + "both_empty", + doc=None, + expression={"$concatArrays": [{"$literal": []}, {"$literal": []}]}, + expected=[], + msg="$concatArrays should return empty for two literal empty arrays", + ), + ExpressionTestCase( + "single_array", + doc=None, + expression={"$concatArrays": [{"$literal": [1, 2, 3]}]}, + expected=[1, 2, 3], + msg="$concatArrays should return single literal array unchanged", + ), + ExpressionTestCase( + "nested_subarrays", + doc=None, + expression={"$concatArrays": [{"$literal": [[1, 2]]}, {"$literal": [[3, 4]]}]}, + expected=[[1, 2], [3, 4]], + msg="$concatArrays should concatenate literal nested subarrays", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_concatArrays_literal(collection, test): + """Test $concatArrays with literal values.""" + 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/concatArrays/test_concatArrays_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_concatArrays_errors.py new file mode 100644 index 000000000..c3198576c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_concatArrays_errors.py @@ -0,0 +1,322 @@ +""" +Error tests for $concatArrays expression. + +Tests non-array input (all BSON types, special numeric values, boundary values, +string edge cases). $concatArrays propagates null but errors on non-array, +non-null input. +""" + +from datetime import datetime, timezone + +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 ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import CONCAT_ARRAYS_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_ZERO, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT64_MAX, + INT64_MIN, +) + +# Property [Array Type Strictness]: $concatArrays rejects a non-array argument. +NOT_ARRAY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="string_input", + doc={"arr0": "hello", "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject string input", + ), + ExpressionTestCase( + id="int_input", + doc={"arr0": 42, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject int input", + ), + ExpressionTestCase( + id="negative_int_input", + doc={"arr0": -42, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject negative int input", + ), + ExpressionTestCase( + id="bool_input", + doc={"arr0": True, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject bool input", + ), + ExpressionTestCase( + id="object_input", + doc={"arr0": {"a": 1}, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject object input", + ), + ExpressionTestCase( + id="double_input", + doc={"arr0": 3.14, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject double input", + ), + ExpressionTestCase( + id="negative_double_input", + doc={"arr0": -3.14, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject negative double input", + ), + ExpressionTestCase( + id="decimal128_input", + doc={"arr0": Decimal128("1"), "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject decimal128 input", + ), + ExpressionTestCase( + id="int64_input", + doc={"arr0": Int64(1), "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject int64 input", + ), + ExpressionTestCase( + id="objectid_input", + doc={"arr0": ObjectId(), "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject objectid input", + ), + ExpressionTestCase( + id="datetime_input", + doc={"arr0": datetime(2024, 1, 1, tzinfo=timezone.utc), "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject datetime input", + ), + ExpressionTestCase( + id="binary_input", + doc={"arr0": Binary(b"x", 0), "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject binary input", + ), + ExpressionTestCase( + id="regex_input", + doc={"arr0": Regex("x"), "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject regex input", + ), + ExpressionTestCase( + id="maxkey_input", + doc={"arr0": MaxKey(), "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject maxkey input", + ), + ExpressionTestCase( + id="minkey_input", + doc={"arr0": MinKey(), "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject minkey input", + ), + ExpressionTestCase( + id="timestamp_input", + doc={"arr0": Timestamp(0, 0), "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject timestamp input", + ), + ExpressionTestCase( + id="non_array_second_arg", + doc={"arr0": [1], "arr1": 42}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject non-array in second position", + ), + ExpressionTestCase( + id="non_array_middle_arg", + doc={"arr0": [1], "arr1": "bad", "arr2": [2]}, + expression={"$concatArrays": ["$arr0", "$arr1", "$arr2"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject non-array in middle position", + ), +] + +# Property [Non-Array Numerics]: $concatArrays rejects special float/Decimal128 arguments. +SPECIAL_NUMERIC_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="nan_input", + doc={"arr0": FLOAT_NAN, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject NaN input", + ), + ExpressionTestCase( + id="inf_input", + doc={"arr0": FLOAT_INFINITY, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject Infinity input", + ), + ExpressionTestCase( + id="neg_inf_input", + doc={"arr0": FLOAT_NEGATIVE_INFINITY, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject -Infinity input", + ), + ExpressionTestCase( + id="neg_zero_input", + doc={"arr0": DOUBLE_NEGATIVE_ZERO, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject negative zero input", + ), + ExpressionTestCase( + id="decimal128_nan_input", + doc={"arr0": DECIMAL128_NAN, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject Decimal128 NaN input", + ), + ExpressionTestCase( + id="decimal128_inf_input", + doc={"arr0": DECIMAL128_INFINITY, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject Decimal128 Infinity input", + ), + ExpressionTestCase( + id="decimal128_neg_inf_input", + doc={"arr0": DECIMAL128_NEGATIVE_INFINITY, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject Decimal128 -Infinity input", + ), + ExpressionTestCase( + id="decimal128_neg_zero_input", + doc={"arr0": DECIMAL128_NEGATIVE_ZERO, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject Decimal128 -0 input", + ), +] + +# Property [Non-Array Boundaries]: $concatArrays rejects numeric boundary values as arguments. +BOUNDARY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="int32_max_input", + doc={"arr0": INT32_MAX, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject INT32_MAX input", + ), + ExpressionTestCase( + id="int32_min_input", + doc={"arr0": INT32_MIN, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject INT32_MIN input", + ), + ExpressionTestCase( + id="int64_max_input", + doc={"arr0": INT64_MAX, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject INT64_MAX input", + ), + ExpressionTestCase( + id="int64_min_input", + doc={"arr0": INT64_MIN, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject INT64_MIN input", + ), + ExpressionTestCase( + id="decimal128_max_input", + doc={"arr0": DECIMAL128_MAX, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject DECIMAL128_MAX input", + ), + ExpressionTestCase( + id="decimal128_min_input", + doc={"arr0": DECIMAL128_MIN, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject DECIMAL128_MIN input", + ), +] + +# Property [Non-Array Strings]: $concatArrays rejects string arguments regardless of content. +STRING_EDGE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="comma_separated_string_input", + doc={"arr0": "1, 2, 3", "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject comma-separated string", + ), + ExpressionTestCase( + id="json_like_string_input", + doc={"arr0": "[1, 2, 3]", "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject JSON-like string", + ), + ExpressionTestCase( + id="empty_object_input", + doc={"arr0": {}, "arr1": [1]}, + expression={"$concatArrays": ["$arr0", "$arr1"]}, + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject empty object as arg", + ), +] + +ALL_TESTS = ( + NOT_ARRAY_ERROR_TESTS + + SPECIAL_NUMERIC_ERROR_TESTS + + BOUNDARY_ERROR_TESTS + + STRING_EDGE_ERROR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_concatArrays_not_array_insert(collection, test): + """Test $concatArrays error with non-array input from inserted documents.""" + 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 + ) + + +# Property [Array Type Strictness]: $concatArrays rejects an object expression argument. +def test_concatArrays_object_expression_input(collection): + """Test $concatArrays rejects an object expression that is not an array.""" + result = execute_expression_with_insert(collection, {"$concatArrays": [{"a": "$x"}]}, {"x": 1}) + assert_expression_result(result, error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_concatArrays_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_concatArrays_expressions.py new file mode 100644 index 000000000..98e6dd28a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_concatArrays_expressions.py @@ -0,0 +1,328 @@ +""" +Expression and field path tests for $concatArrays 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 ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Field Path]: $concatArrays resolves field-path array arguments. +FIELD_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="nested_field_path", + expression={"$concatArrays": ["$a.b", "$a.c"]}, + doc={"a": {"b": [1, 2], "c": [3, 4]}}, + expected=[1, 2, 3, 4], + msg="$concatArrays should resolve nested field paths", + ), + ExpressionTestCase( + id="deeply_nested_field", + expression={"$concatArrays": ["$a.b.c", "$a.b.d"]}, + doc={"a": {"b": {"c": [10], "d": [20]}}}, + expected=[10, 20], + msg="$concatArrays should resolve deeply nested field paths", + ), + ExpressionTestCase( + id="nonexistent_field_null", + expression={"$concatArrays": ["$a.nonexistent", "$b"]}, + doc={"a": {"missing": 1}, "b": [1]}, + expected=None, + msg="$concatArrays should propagate null for a non-existent field", + ), + ExpressionTestCase( + id="numeric_path_component_not_array_index", + expression={"$concatArrays": ["$a.0", [5]]}, + doc={"a": [[1, 2], [3, 4]]}, + expected=[5], + msg="$concatArrays should resolve $a.0 to an empty array in expression context", + ), + ExpressionTestCase( + id="nonexistent_nested_path_empty", + expression={"$concatArrays": ["$f.x", [3]]}, + doc={"f": [{"g": 1}, {"g": 2}]}, + expected=[3], + msg="$concatArrays should resolve a non-existent nested path to an empty array", + ), + ExpressionTestCase( + id="nested_array_of_object_path", + expression={"$concatArrays": ["$a.b.c", [3]]}, + doc={"a": {"b": [{"c": [1]}, {"c": [2]}]}}, + expected=[[1], [2], 3], + msg="$concatArrays should concatenate an array-of-arrays produced by mapping a field " + "over an array of objects", + ), +] + +# Property [Composite Path]: $concatArrays resolves composite arrays from dotted paths. +COMPOSITE_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="composite_array", + expression={"$concatArrays": ["$x.y", [100]]}, + doc={"x": [{"y": 10}, {"y": 20}]}, + expected=[10, 20, 100], + msg="$concatArrays should resolve a composite array path from an array of objects", + ), + ExpressionTestCase( + id="composite_path_tags", + expression={"$concatArrays": ["$items.tags", ["d"]]}, + doc={"items": [{"tags": ["a", "b"]}, {"tags": ["c"]}]}, + expected=[["a", "b"], ["c"], "d"], + msg="$concatArrays should resolve $items.tags to an array of arrays", + ), +] + +# Property [Variables]: $concatArrays works with $let and system variables like $$ROOT. +LET_AND_VARIABLE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="let_variable", + expression={ + "$let": { + "vars": {"a": "$arr1", "b": "$arr2"}, + "in": {"$concatArrays": ["$$a", "$$b"]}, + } + }, + doc={"arr1": [1, 2], "arr2": [3, 4]}, + expected=[1, 2, 3, 4], + msg="$concatArrays should work with $let variables", + ), + ExpressionTestCase( + id="root_variable", + expression={"$concatArrays": ["$$ROOT.a", "$$ROOT.b"]}, + doc={"_id": 1, "a": [1], "b": [2]}, + expected=[1, 2], + msg="$concatArrays should work with $$ROOT", + ), + ExpressionTestCase( + id="current_variable", + expression={"$concatArrays": ["$$CURRENT.a", "$$CURRENT.b"]}, + doc={"_id": 2, "a": [1], "b": [2]}, + expected=[1, 2], + msg="$concatArrays should treat $$CURRENT like the field path", + ), + ExpressionTestCase( + id="let_null_variable", + expression={ + "$let": { + "vars": {"x": None}, + "in": {"$concatArrays": ["$$x", [1]]}, + } + }, + doc={"_placeholder": 1}, + expected=None, + msg="$concatArrays should return null for a null $let variable", + ), +] + +# Property [Null Propagation]: $concatArrays returns null when a field path is null or missing. +NULL_MISSING_EXPR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="missing_field", + expression={"$concatArrays": ["$nonexistent", [1]]}, + doc={"other": 1}, + expected=None, + msg="$concatArrays should propagate null for a missing field", + ), + ExpressionTestCase( + id="missing_input_type_is_null", + expression={"$type": {"$concatArrays": ["$nonexistent", [1]]}}, + doc={"x": 1}, + expected="null", + msg="$concatArrays should produce null type for a missing field", + ), + ExpressionTestCase( + id="remove_variable", + expression={"$concatArrays": ["$$REMOVE", [1]]}, + doc={"x": 1}, + expected=None, + msg="$concatArrays should return null when an argument is $$REMOVE", + ), + ExpressionTestCase( + id="missing_first_field", + expression={"$concatArrays": ["$a", "$b"]}, + doc={"b": [1]}, + expected=None, + msg="$concatArrays should return null when the first field is missing", + ), + ExpressionTestCase( + id="missing_last_field", + expression={"$concatArrays": ["$a", "$b"]}, + doc={"a": [1]}, + expected=None, + msg="$concatArrays should return null when the last field is missing", + ), + ExpressionTestCase( + id="missing_middle_field", + expression={"$concatArrays": ["$a", "$b", "$c"]}, + doc={"a": [1], "c": [3]}, + expected=None, + msg="$concatArrays should return null when a middle field is missing", + ), + ExpressionTestCase( + id="all_missing_fields", + expression={"$concatArrays": ["$a", "$b"]}, + doc={"_placeholder": 1}, + expected=None, + msg="$concatArrays should return null when all fields are missing", + ), + ExpressionTestCase( + id="missing_plus_null", + expression={"$concatArrays": ["$not_a_field", "$null_val"]}, + doc={"null_val": None}, + expected=None, + msg="$concatArrays should return null for a missing field plus null", + ), + ExpressionTestCase( + id="null_precedes_non_array", + expression={"$concatArrays": ["$arr", "$null_val", "$int_val"]}, + doc={"arr": [1, 2], "null_val": None, "int_val": 42}, + expected=None, + msg="$concatArrays should return null when a null precedes a non-array argument", + ), + ExpressionTestCase( + id="null_result_type_is_null", + expression={"$type": {"$concatArrays": ["$a", "$nonexistent"]}}, + doc={"a": [1]}, + expected="null", + msg="$concatArrays should produce null type, not missing, for a null result", + ), +] + +# Property [Nesting]: $concatArrays can be nested as an argument to itself. +SELF_COMPOSITION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="nested_concatArrays", + expression={"$concatArrays": [{"$concatArrays": ["$a", "$b"]}, "$c"]}, + doc={"a": [1], "b": [2], "c": [3]}, + expected=[1, 2, 3], + msg="$concatArrays should evaluate a nested $concatArrays argument", + ), + ExpressionTestCase( + id="double_nested_concatArrays", + expression={ + "$concatArrays": [{"$concatArrays": ["$a", "$b"]}, {"$concatArrays": ["$c", "$d"]}] + }, + doc={"a": [1], "b": [2], "c": [3], "d": [4]}, + expected=[1, 2, 3, 4], + msg="$concatArrays should evaluate nested $concatArrays in both arguments", + ), + ExpressionTestCase( + id="triple_depth_concatArrays", + expression={ + "$concatArrays": [{"$concatArrays": [{"$concatArrays": ["$a", "$b"]}, "$c"]}, "$d"] + }, + doc={"a": [1], "b": [2], "c": [3], "d": [4]}, + expected=[1, 2, 3, 4], + msg="$concatArrays should evaluate triple-nested $concatArrays", + ), +] + +# Property [Repeated Field]: $concatArrays repeats elements when the same field is referenced again. +SAME_FIELD_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="same_field_twice", + expression={"$concatArrays": ["$a", "$a"]}, + doc={"a": [1, 2, 3]}, + expected=[1, 2, 3, 1, 2, 3], + msg="$concatArrays should double elements when a field is referenced twice", + ), + ExpressionTestCase( + id="same_field_three_times", + expression={"$concatArrays": ["$a", "$a", "$a"]}, + doc={"a": [1]}, + expected=[1, 1, 1], + msg="$concatArrays should triple elements when a field is referenced three times", + ), + ExpressionTestCase( + id="self_concat_mixed_types", + expression={"$concatArrays": ["$a", "$a"]}, + doc={"a": [42, "string", {"key": "value"}, [1, 2], True]}, + expected=[ + 42, + "string", + {"key": "value"}, + [1, 2], + True, + 42, + "string", + {"key": "value"}, + [1, 2], + True, + ], + msg="$concatArrays should preserve all element types when self-concatenating", + ), +] + +# Property [Expression Inputs]: $concatArrays evaluates array expressions that produce +# array arguments. +# Property [Expression Input]: $concatArrays accepts expression operators as input. +EXPRESSION_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="array_expression_input", + expression={"$concatArrays": [["$x", "$y"], [3]]}, + doc={"x": 1, "y": 2}, + expected=[1, 2, 3], + msg="$concatArrays should resolve an array expression containing field references", + ), + ExpressionTestCase( + id="literal_then_field", + expression={"$concatArrays": [[1, 2, 3], "$a"]}, + doc={"a": [1, 2]}, + expected=[1, 2, 3, 1, 2], + msg="$concatArrays should preserve order for a literal followed by a field", + ), + ExpressionTestCase( + id="field_then_literal", + expression={"$concatArrays": ["$a", [1, 2, 3]]}, + doc={"a": [1, 2]}, + expected=[1, 2, 1, 2, 3], + msg="$concatArrays should preserve order for a field followed by a literal", + ), + ExpressionTestCase( + id="four_fields_with_empty_and_literal", + expression={"$concatArrays": ["$a", "$b", "$c", "$d", [], ["array"]]}, + doc={"a": [1, 2], "b": [3, 4], "c": [5, 6], "d": []}, + expected=[1, 2, 3, 4, 5, 6, "array"], + msg="$concatArrays should concatenate multiple fields and literals", + ), +] + +# Property [Object Elements]: $concatArrays preserves documents with special keys as elements. +SPECIAL_KEY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="special_object_keys", + expression={"$concatArrays": ["$a", "$b"]}, + doc={"a": [{"a.b": 1}], "b": [{"$x": 2}]}, + expected=[{"a.b": 1}, {"$x": 2}], + msg="$concatArrays should preserve objects with special keys", + ), +] + +ALL_EXPR_TESTS = ( + FIELD_LOOKUP_TESTS + + COMPOSITE_PATH_TESTS + + LET_AND_VARIABLE_TESTS + + NULL_MISSING_EXPR_TESTS + + SELF_COMPOSITION_TESTS + + SAME_FIELD_TESTS + + EXPRESSION_INPUT_TESTS + + SPECIAL_KEY_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_EXPR_TESTS)) +def test_concatArrays_expression(collection, test): + """Test $concatArrays with field paths and expressions.""" + 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/concatArrays/test_smoke_expression_concatArrays.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_smoke_concatArrays.py similarity index 87% rename from documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_smoke_expression_concatArrays.py rename to documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_smoke_concatArrays.py index 62ffe6471..9af309d97 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_smoke_expression_concatArrays.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_smoke_concatArrays.py @@ -28,4 +28,6 @@ def test_smoke_expression_concatArrays(collection): ) expected = [{"_id": 1, "combined": [1, 2, 3, 4]}, {"_id": 2, "combined": [5, 6, 7, 8]}] - assertSuccess(result, expected, msg="Should support $concatArrays expression") + assertSuccess( + result, expected, "Should support $concatArrays expression", ignore_doc_order=True + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_arrayToObject.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_arrayToObject.py new file mode 100644 index 000000000..78e4b046f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_arrayToObject.py @@ -0,0 +1,73 @@ +""" +Combination tests for $arrayToObject 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 ( + execute_expression_with_insert, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ARRAY_TO_OBJECT_INVALID_ELEMENT_ERROR +from documentdb_tests.framework.parametrize import pytest_params + +ARRAY_TO_OBJECT_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="arrayToObject_roundtrip_with_objectToArray", + expression={"$arrayToObject": {"$objectToArray": "$obj"}}, + doc={"obj": {"a": 1, "b": 2}}, + expected={"a": 1, "b": 2}, + msg="Roundtrip should restore original object", + ), + ExpressionTestCase( + id="arrayToObject_double_roundtrip", + expression={"$arrayToObject": {"$objectToArray": {"$arrayToObject": "$arr"}}}, + doc={"arr": [["a", 1], ["b", 2]]}, + expected={"a": 1, "b": 2}, + msg="Double roundtrip: array → object → array → object should restore", + ), + ExpressionTestCase( + id="arrayToObject_on_concatArrays", + expression={"$arrayToObject": {"$concatArrays": ["$a", "$b"]}}, + doc={"a": [["a", 1]], "b": [["b", 2]]}, + expected={"a": 1, "b": 2}, + msg="Should work on $concatArrays result", + ), + ExpressionTestCase( + id="arrayToObject_formats_produce_same_result", + expression={ + "$eq": [ + {"$arrayToObject": "$pairs"}, + {"$arrayToObject": "$kv"}, + ] + }, + doc={"pairs": [["a", 1], ["b", 2]], "kv": [{"k": "a", "v": 1}, {"k": "b", "v": 2}]}, + expected=True, + msg="Both formats should produce identical output", + ), + ExpressionTestCase( + id="arrayToObject_on_slice", + expression={"$arrayToObject": {"$slice": ["$arr", 2]}}, + doc={"arr": [["a", 1], ["b", 2], ["c", 3]]}, + expected={"a": 1, "b": 2}, + msg="Should work on $slice result", + ), + ExpressionTestCase( + id="arrayToObject_on_range_fails", + expression={"$arrayToObject": {"$range": ["$start", "$end"]}}, + doc={"start": 0, "end": 3}, + error_code=ARRAY_TO_OBJECT_INVALID_ELEMENT_ERROR, + msg="$range produces numbers, should fail", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ARRAY_TO_OBJECT_COMBINATION_TESTS)) +def test_arrayToObject_combination(collection, test): + """Test $arrayToObject composed with other operators.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + expected = [{"result": test.expected}] if test.error_code is None else None + assertResult(result, expected=expected, error_code=test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_array_arrayElemAt_indexOfArray_in_slice.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_array_arrayElemAt_indexOfArray_in_slice.py new file mode 100644 index 000000000..44551a87f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_array_arrayElemAt_indexOfArray_in_slice.py @@ -0,0 +1,363 @@ +""" +Combination tests for array expression operators: $arrayElemAt, $indexOfArray, $in, $slice. + +Tests that verify these operators work correctly when composed with each other +and with other operators like $concatArrays, $reverseArray, $filter, $map, $size, etc. +""" + +import pytest +from bson import Decimal128, MaxKey, MinKey, Regex + +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 ( + execute_expression, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Composition]: $arrayElemAt resolves indexes and arrays from other operators. +ARRAY_ELEM_AT_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="arrayElemAt_index_from_indexOfArray", + expression={"$arrayElemAt": [[10, 20, 30], {"$indexOfArray": [[10, 20, 30], 30]}]}, + expected=30, + msg="Should use $indexOfArray result as index", + ), + ExpressionTestCase( + id="arrayElemAt_last_element_via_size", + expression={"$arrayElemAt": [[10, 20, 30], {"$subtract": [{"$size": [[10, 20, 30]]}, 1]}]}, + expected=30, + msg="Should access last element via $size - 1", + ), + ExpressionTestCase( + id="arrayElemAt_elem_from_slice", + expression={"$arrayElemAt": [{"$slice": [[10, 20, 30, 40], -2]}, 0]}, + expected=30, + msg="Should access element from $slice result", + ), + ExpressionTestCase( + id="arrayElemAt_elem_from_slice_3arg", + expression={"$arrayElemAt": [{"$slice": [[10, 20, 30, 40], 1, 2]}, 1]}, + expected=30, + msg="Should access element from $slice 3-arg result", + ), + ExpressionTestCase( + id="arrayElemAt_elem_from_reverseArray", + expression={"$arrayElemAt": [{"$reverseArray": [[10, 20, 30]]}, 0]}, + expected=30, + msg="Should access element from $reverseArray result", + ), + ExpressionTestCase( + id="arrayElemAt_elem_from_concatArrays", + expression={"$arrayElemAt": [{"$concatArrays": [[10, 20], [30, 40]]}, 2]}, + expected=30, + msg="Should access element from $concatArrays result", + ), + ExpressionTestCase( + id="arrayElemAt_computed_index", + expression={"$arrayElemAt": [[10, 20, 30], {"$subtract": [3, 1]}]}, + expected=30, + msg="Should use computed index from $subtract", + ), +] + +# Property [Composition]: $in searches arrays produced by other operators. +IN_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="in_value_from_add", + expression={"$in": [{"$add": [1, 1]}, [1, 2, 3]]}, + expected=True, + msg="Should find value computed by $add", + ), + ExpressionTestCase( + id="in_array_from_concatArrays", + expression={"$in": [3, {"$concatArrays": [[1, 2], [3, 4]]}]}, + expected=True, + msg="Should search in $concatArrays result", + ), + ExpressionTestCase( + id="in_value_from_arrayElemAt", + expression={"$in": [{"$arrayElemAt": [[10, 20, 30], 1]}, [5, 20, 35]]}, + expected=True, + msg="Should find value from $arrayElemAt", + ), + ExpressionTestCase( + id="in_array_from_filter", + expression={ + "$in": [ + 4, + { + "$filter": { + "input": [1, 2, 3, 4, 5], + "as": "n", + "cond": {"$gte": ["$$n", 3]}, + } + }, + ] + }, + expected=True, + msg="Should search in $filter result", + ), + ExpressionTestCase( + id="in_array_from_map", + expression={ + "$in": [ + 20, + { + "$map": { + "input": [1, 2, 3], + "as": "n", + "in": {"$multiply": ["$$n", 10]}, + } + }, + ] + }, + expected=True, + msg="Should search in $map result", + ), + ExpressionTestCase( + id="in_array_from_reverseArray", + expression={"$in": [1, {"$reverseArray": [[1, 2, 3]]}]}, + expected=True, + msg="Should search in $reverseArray result", + ), + ExpressionTestCase( + id="in_cond_with_inner_in", + expression={"$in": [5, {"$cond": [{"$in": ["a", ["a", "b"]]}, [5, 6], [7, 8]]}]}, + expected=True, + msg="Should search in $cond-selected array", + ), + ExpressionTestCase( + id="in_inside_cond", + expression={"$cond": [{"$in": [2, [1, 2, 3]]}, "found", "not_found"]}, + expected="found", + msg="Should use $in result in $cond", + ), + ExpressionTestCase( + id="in_value_from_indexOfArray", + expression={"$in": [{"$indexOfArray": [[10, 20, 30], 20]}, [0, 1, 2]]}, + expected=True, + msg="Should find $indexOfArray result in array", + ), + ExpressionTestCase( + id="in_nested_decimal128", + expression={ + "$in": [ + {"$arrayElemAt": [[Decimal128("1.1"), Decimal128("2.2")], 1]}, + [Decimal128("2.2"), Decimal128("3.3")], + ] + }, + expected=True, + msg="Should find Decimal128 from $arrayElemAt in array", + ), +] + +# Property [Composition]: $indexOfArray searches arrays and uses indexes from other operators. +INDEX_OF_ARRAY_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="indexOfArray_result_as_arrayElemAt_index", + expression={"$arrayElemAt": [[10, 20, 30], {"$indexOfArray": [[10, 20, 30], 20]}]}, + expected=20, + msg="Should use $indexOfArray result as $arrayElemAt index", + ), + ExpressionTestCase( + id="indexOfArray_search_from_add", + expression={"$indexOfArray": [[1, 2, 3], {"$add": [1, 1]}]}, + expected=1, + msg="Should search for value computed by $add", + ), + ExpressionTestCase( + id="indexOfArray_array_from_concatArrays", + expression={"$indexOfArray": [{"$concatArrays": [[1, 2], [3, 4]]}, 3]}, + expected=2, + msg="Should search in $concatArrays result", + ), + ExpressionTestCase( + id="indexOfArray_array_from_filter", + expression={ + "$indexOfArray": [ + {"$filter": {"input": [1, 2, 3, 4, 5], "cond": {"$gt": ["$$this", 2]}}}, + 4, + ] + }, + expected=1, + msg="Should search in $filter result", + ), + ExpressionTestCase( + id="indexOfArray_result_in_cond", + expression={ + "$cond": [{"$gte": [{"$indexOfArray": [[1, 2, 3], 2]}, 0]}, "found", "not_found"] + }, + expected="found", + msg="Should use $indexOfArray result in $cond", + ), + ExpressionTestCase( + id="indexOfArray_start_from_subtract", + expression={"$indexOfArray": [[1, 2, 1, 2], 1, {"$subtract": [3, 1]}]}, + expected=2, + msg="Should use $subtract result as start index", + ), + ExpressionTestCase( + id="indexOfArray_via_arrayElemAt", + expression={ + "$indexOfArray": [ + ["a", "b", "c", "d"], + { + "$arrayElemAt": [ + ["a", "b", "c", "d"], + {"$indexOfArray": [[10, 20, 30], 20]}, + ] + }, + ] + }, + expected=1, + msg="Should search for value from nested $arrayElemAt/$indexOfArray", + ), + ExpressionTestCase( + id="indexOfArray_subarray_mixed_bson", + expression={ + "$indexOfArray": [ + [[MinKey(), MaxKey()], [1, 2], "x"], + { + "$arrayElemAt": [ + [[MinKey(), MaxKey()], [1, 2], "x"], + {"$indexOfArray": [[[MinKey(), MaxKey()], [1, 2], "x"], [1, 2]]}, + ] + }, + ] + }, + expected=1, + msg="Should find mixed BSON subarray via nested operators", + ), + ExpressionTestCase( + id="indexOfArray_triple_nested_decimal128", + expression={ + "$indexOfArray": [ + [Decimal128("1.1"), Decimal128("2.2"), Decimal128("3.3")], + { + "$arrayElemAt": [ + [Decimal128("1.1"), Decimal128("2.2"), Decimal128("3.3")], + { + "$indexOfArray": [ + [Decimal128("1.1"), Decimal128("2.2"), Decimal128("3.3")], + Decimal128("3.3"), + ] + }, + ] + }, + ] + }, + expected=2, + msg="Should resolve triple-nested Decimal128 operators", + ), +] + +# Property [Composition]: $slice operates on arrays produced by other operators. +SLICE_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="slice_array_from_concatArrays", + expression={"$slice": [{"$concatArrays": [[1, 2], [3, 4, 5]]}, 3]}, + expected=[1, 2, 3], + msg="Should slice $concatArrays result", + ), + ExpressionTestCase( + id="slice_n_from_subtract", + expression={"$slice": [[1, 2, 3, 4, 5], {"$subtract": [5, 2]}]}, + expected=[1, 2, 3], + msg="Should use $subtract result as n", + ), + ExpressionTestCase( + id="slice_array_from_filter", + expression={ + "$slice": [ + { + "$filter": { + "input": [1, 2, 3, 4, 5], + "as": "n", + "cond": {"$gte": ["$$n", 3]}, + } + }, + 2, + ] + }, + expected=[3, 4], + msg="Should slice $filter result", + ), + ExpressionTestCase( + id="slice_position_from_indexOfArray", + expression={ + "$slice": [ + [10, 20, 30, 40, 50], + {"$indexOfArray": [[10, 20, 30, 40, 50], 30]}, + 2, + ] + }, + expected=[30, 40], + msg="Should use $indexOfArray result as position", + ), + ExpressionTestCase( + id="slice_array_from_map", + expression={ + "$slice": [ + { + "$map": { + "input": [1, 2, 3], + "as": "n", + "in": {"$multiply": ["$$n", 10]}, + } + }, + 2, + ] + }, + expected=[10, 20], + msg="Should slice $map result", + ), + ExpressionTestCase( + id="slice_array_from_reverseArray", + expression={"$slice": [{"$reverseArray": [[1, 2, 3, 4, 5]]}, 3]}, + expected=[5, 4, 3], + msg="Should slice $reverseArray result", + ), + ExpressionTestCase( + id="slice_n_from_size", + expression={ + "$slice": [[10, 20, 30, 40], {"$subtract": [{"$size": [[10, 20, 30, 40]]}, 1]}] + }, + expected=[10, 20, 30], + msg="Should use $size-based computation as n", + ), +] + +# Property [Type Reporting]: $type over $arrayElemAt reports the resolved element type. +ARRAY_ELEM_AT_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arrayElemAt_oob_is_missing", + expression={"$type": {"$arrayElemAt": [[1, 2, 3], 10]}}, + expected="missing", + msg="$arrayElemAt out-of-bounds should produce missing, not null", + ), + ExpressionTestCase( + "arrayElemAt_regex_type_preserved", + expression={"$type": {"$arrayElemAt": [[Regex("abc")], 0]}}, + expected="regex", + msg="$arrayElemAt should preserve the regex element type", + ), +] + + +ALL_COMBINATION_TESTS = ( + ARRAY_ELEM_AT_COMBINATION_TESTS + + IN_COMBINATION_TESTS + + INDEX_OF_ARRAY_COMBINATION_TESTS + + SLICE_COMBINATION_TESTS + + ARRAY_ELEM_AT_TYPE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_COMBINATION_TESTS)) +def test_combination_expression(collection, test): + """Test array operators composed with other operators.""" + result = execute_expression(collection, test.expression) + assertResult(result, expected=[{"result": test.expected}], msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_concatArrays.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_concatArrays.py new file mode 100644 index 000000000..6bad4c246 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_concatArrays.py @@ -0,0 +1,89 @@ +""" +Combination tests for $concatArrays composed with other operators. +""" + +import math + +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 ( + execute_expression_with_insert, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import FLOAT_NAN + +CONCAT_ARRAYS_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="concatArrays_on_range", + expression={"$concatArrays": [{"$range": [0, 3]}, {"$range": [3, 6]}]}, + doc={"x": 1}, + expected=[0, 1, 2, 3, 4, 5], + msg="Should concatenate two $range results", + ), + ExpressionTestCase( + id="size_of_concatArrays", + expression={"$size": {"$concatArrays": ["$a", "$b"]}}, + doc={"a": [1, 2], "b": [3, 4, 5]}, + expected=5, + msg="$size on $concatArrays result", + ), + ExpressionTestCase( + id="size_of_empty_concatArrays", + expression={"$size": {"$concatArrays": ["$a", "$b"]}}, + doc={"a": [], "b": []}, + expected=0, + msg="Size of empty concatenation", + ), + ExpressionTestCase( + id="concatArrays_reverseArray_concatArrays", + expression={ + "$concatArrays": [ + {"$reverseArray": {"$concatArrays": ["$a", "$b"]}}, + "$c", + ] + }, + doc={"a": [1, 2], "b": [3], "c": [4]}, + expected=[3, 2, 1, 4], + msg="Should concatenate reversed concat result with another array", + ), + ExpressionTestCase( + id="isArray_on_concatArrays", + expression={"$isArray": {"$concatArrays": ["$a", "$b"]}}, + doc={"a": [1], "b": [2]}, + expected=True, + msg="$isArray on $concatArrays result should return true", + ), + ExpressionTestCase( + id="in_found_in_concatArrays", + expression={"$in": [3, {"$concatArrays": ["$a", "$b"]}]}, + doc={"a": [1, 2], "b": [3, 4]}, + expected=True, + msg="Element found in concatenated array", + ), + ExpressionTestCase( + id="isArray_null_concatArrays", + expression={"$isArray": {"$concatArrays": ["$a", "$b"]}}, + doc={"a": None, "b": [1]}, + expected=False, + msg="Null result is not array", + ), + ExpressionTestCase( + id="float_nan_preserved", + expression={"$arrayElemAt": [{"$concatArrays": ["$a", "$b"]}, 0]}, + doc={"a": [FLOAT_NAN], "b": [1]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="Float NaN element preserved after concatenation", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(CONCAT_ARRAYS_COMBINATION_TESTS)) +def test_concatArrays_combination(collection, test): + """Test $concatArrays composed with other operators.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + expected = [{"result": test.expected}] if test.error_code is None else None + assertResult(result, expected=expected, error_code=test.error_code, msg=test.msg)