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_expression_arrayElemAt_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_expression_arrayElemAt_core_behavior.py new file mode 100644 index 000000000..a05941365 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_expression_arrayElemAt_core_behavior.py @@ -0,0 +1,218 @@ +""" +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.array.utils.array_test_case import ( # noqa: E501 + ArrayTestClass, +) +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[ArrayTestClass] = [ + ArrayTestClass( + id="first_element", + arrays=[1, 2, 3], + idx=0, + expected=1, + msg="$arrayElemAt should return first element", + ), + ArrayTestClass( + id="second_element", + arrays=[1, 2, 3], + idx=1, + expected=2, + msg="$arrayElemAt should return second element", + ), + ArrayTestClass( + id="last_element", + arrays=[1, 2, 3], + idx=2, + expected=3, + msg="$arrayElemAt should return last element", + ), + ArrayTestClass( + id="single_element_array", + arrays=[42], + idx=0, + expected=42, + msg="$arrayElemAt should return single element", + ), + ArrayTestClass( + id="string_elements", + arrays=["a", "b", "c"], + idx=1, + expected="b", + msg="$arrayElemAt should return string element", + ), + ArrayTestClass( + id="mixed_types", + arrays=[1, "two", 3.0, True], + idx=2, + expected=3.0, + msg="$arrayElemAt should return element from mixed-type array", + ), + ArrayTestClass( + id="nested_array_element", + arrays=[[1, 2], [3, 4]], + idx=1, + expected=[3, 4], + msg="$arrayElemAt should return nested array element", + ), + ArrayTestClass( + id="nested_object_element", + arrays=[{"a": 1}, {"b": 2}], + idx=0, + expected={"a": 1}, + msg="$arrayElemAt should return nested object element", + ), + ArrayTestClass( + id="null_element_in_array", + arrays=[None, 1, 2], + idx=0, + expected=None, + msg="$arrayElemAt should return null element", + ), + ArrayTestClass( + id="bool_element", + arrays=[True, False], + idx=1, + 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[ArrayTestClass] = [ + ArrayTestClass( + id="last_via_neg1", + arrays=[1, 2, 3], + idx=-1, + expected=3, + msg="$arrayElemAt should return last element via -1", + ), + ArrayTestClass( + id="second_to_last", + arrays=[1, 2, 3], + idx=-2, + expected=2, + msg="$arrayElemAt should return second to last", + ), + ArrayTestClass( + id="first_via_neg_len", + arrays=[1, 2, 3], + idx=-3, + expected=1, + msg="$arrayElemAt should return first via negative length", + ), + ArrayTestClass( + id="single_element_neg1", + arrays=[42], + idx=-1, + expected=42, + msg="$arrayElemAt should return single element via -1", + ), +] + +# Property [Duplicate Values]: $arrayElemAt selects by position, ignoring duplicate elements. +DUPLICATE_VALUE_TESTS: list[ArrayTestClass] = [ + ArrayTestClass( + id="dup_first", + arrays=[1, 1, 1], + idx=0, + expected=1, + msg="$arrayElemAt is unaffected by duplicate elements at index 0", + ), + ArrayTestClass( + id="dup_last", + arrays=[1, 1, 1], + idx=2, + expected=1, + msg="$arrayElemAt is unaffected by duplicate elements at the last index", + ), + ArrayTestClass( + id="dup_neg", + arrays=["a", "a", "b", "a"], + idx=-1, + 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)) + +LARGE_ARRAY_TESTS: list[ArrayTestClass] = [ + ArrayTestClass( + id="large_array_first", + arrays=_LARGE_ARRAY, + idx=0, + expected=0, + msg="$arrayElemAt should return first in large array", + ), + ArrayTestClass( + id="large_array_last", + arrays=_LARGE_ARRAY, + idx=_LARGE_ARRAY_SIZE - 1, + expected=_LARGE_ARRAY_SIZE - 1, + msg="$arrayElemAt should return last in large array", + ), + ArrayTestClass( + id="large_array_neg1", + arrays=_LARGE_ARRAY, + idx=-1, + expected=_LARGE_ARRAY_SIZE - 1, + msg="$arrayElemAt should return last via -1 in large array", + ), + ArrayTestClass( + id="large_array_middle", + arrays=_LARGE_ARRAY, + idx=_LARGE_ARRAY_SIZE // 2, + expected=_LARGE_ARRAY_SIZE // 2, + msg="$arrayElemAt should return middle in large array", + ), + ArrayTestClass( + id="large_array_neg_middle", + arrays=_LARGE_ARRAY, + idx=-(_LARGE_ARRAY_SIZE // 4), + 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 = [ + POSITIVE_INDEX_TESTS[0], + NEGATIVE_INDEX_TESTS[0], + LARGE_ARRAY_TESTS[0], +] + + +@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, {"$arrayElemAt": [test.arrays, test.idx]}) + 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, {"$arrayElemAt": ["$arr", "$idx"]}, {"arr": test.arrays, "idx": test.idx} + ) + 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_expression_arrayElemAt_element_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_expression_arrayElemAt_element_types.py new file mode 100644 index 000000000..63585578b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_expression_arrayElemAt_element_types.py @@ -0,0 +1,188 @@ +""" +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, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.array.utils.array_test_case import ( # noqa: E501 + ArrayTestClass, +) +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 +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + 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[ArrayTestClass] = [ + ArrayTestClass( + id="int64_element", + arrays=[Int64(99)], + idx=0, + expected=Int64(99), + msg="$arrayElemAt should return Int64 element", + ), + ArrayTestClass( + id="decimal128_element", + arrays=[Decimal128("1.5")], + idx=0, + expected=Decimal128("1.5"), + msg="$arrayElemAt should return Decimal128 element", + ), + ArrayTestClass( + id="datetime_element", + arrays=[datetime(2024, 1, 1, tzinfo=timezone.utc)], + idx=0, + expected=datetime(2024, 1, 1, tzinfo=timezone.utc), + msg="$arrayElemAt should return datetime element", + ), + ArrayTestClass( + id="binary_element", + arrays=[Binary(b"\x01\x02", 0)], + idx=0, + expected=b"\x01\x02", + msg="$arrayElemAt should return binary element", + ), + ArrayTestClass( + id="regex_element", + arrays=[Regex("^abc", "i")], + idx=0, + expected=Regex("^abc", "i"), + msg="$arrayElemAt should return regex element", + ), + ArrayTestClass( + id="objectid_element", + arrays=[ObjectId("000000000000000000000001")], + idx=0, + expected=ObjectId("000000000000000000000001"), + msg="$arrayElemAt should return ObjectId element", + ), + ArrayTestClass( + id="minkey_element", + arrays=[MinKey(), 1], + idx=0, + expected=MinKey(), + msg="$arrayElemAt should return MinKey element", + ), + ArrayTestClass( + id="maxkey_element", + arrays=[1, MaxKey()], + idx=1, + expected=MaxKey(), + msg="$arrayElemAt should return MaxKey element", + ), + ArrayTestClass( + id="timestamp_element", + arrays=[Timestamp(0, 0)], + idx=0, + expected=Timestamp(0, 0), + msg="$arrayElemAt should return Timestamp element", + ), + ArrayTestClass( + id="float_nan_element", + arrays=[FLOAT_NAN, 1], + idx=0, + expected=pytest.approx(math.nan, nan_ok=True), + msg="$arrayElemAt should return NaN element", + ), + ArrayTestClass( + id="float_infinity_element", + arrays=[FLOAT_INFINITY, 1], + idx=0, + expected=FLOAT_INFINITY, + msg="$arrayElemAt should return Infinity element", + ), + ArrayTestClass( + id="float_neg_infinity_element", + arrays=[FLOAT_NEGATIVE_INFINITY, 1], + idx=0, + expected=FLOAT_NEGATIVE_INFINITY, + msg="$arrayElemAt should return -Infinity element", + ), + ArrayTestClass( + id="decimal128_nan_element", + arrays=[DECIMAL128_NAN, 1], + idx=0, + expected=DECIMAL128_NAN, + msg="$arrayElemAt should return Decimal128 NaN element", + ), + ArrayTestClass( + id="decimal128_infinity_element", + arrays=[DECIMAL128_INFINITY, 1], + idx=0, + expected=DECIMAL128_INFINITY, + msg="$arrayElemAt should return Decimal128 Infinity element", + ), + ArrayTestClass( + id="decimal128_neg_infinity_element", + arrays=[DECIMAL128_NEGATIVE_INFINITY, 1], + idx=0, + expected=DECIMAL128_NEGATIVE_INFINITY, + msg="$arrayElemAt should return Decimal128 -Infinity element", + ), + ArrayTestClass( + id="int32_max_element", + arrays=[INT32_MAX, 0], + idx=0, + expected=INT32_MAX, + msg="$arrayElemAt should return INT32_MAX element", + ), + ArrayTestClass( + id="int64_max_element", + arrays=[INT64_MAX, 0], + idx=0, + expected=INT64_MAX, + msg="$arrayElemAt should return INT64_MAX element", + ), + ArrayTestClass( + id="mixed_special_last", + arrays=[INT32_MAX, FLOAT_INFINITY, DECIMAL128_NAN], + idx=2, + expected=DECIMAL128_NAN, + msg="$arrayElemAt should return element from mixed special values array", + ), +] + +TEST_SUBSET_FOR_LITERAL = [ + ELEMENT_TYPE_TESTS[0], + ELEMENT_TYPE_TESTS[9], + ELEMENT_TYPE_TESTS[-1], +] + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_arrayElemAt_literal(collection, test): + """Test $arrayElemAt element type preservation with literal values.""" + result = execute_expression(collection, {"$arrayElemAt": [test.arrays, test.idx]}) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(ELEMENT_TYPE_TESTS)) +def test_arrayElemAt_insert(collection, test): + """Test $arrayElemAt element type preservation with values from inserted documents.""" + result = execute_expression_with_insert( + collection, {"$arrayElemAt": ["$arr", "$idx"]}, {"arr": test.arrays, "idx": test.idx} + ) + 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_expression_arrayElemAt_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_expression_arrayElemAt_errors.py new file mode 100644 index 000000000..6c7618a38 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_expression_arrayElemAt_errors.py @@ -0,0 +1,409 @@ +""" +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.array.utils.array_test_case import ( # noqa: E501 + ArrayTestClass, +) +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.parametrize import pytest_params +from documentdb_tests.framework.test_constants import INT64_MAX, INT64_MIN + +# Property [Array Type Strictness]: $arrayElemAt rejects a non-array first argument. +ARRAY_TYPE_ERROR_TESTS: list[ArrayTestClass] = [ + ArrayTestClass( + id="string_as_array", + arrays="hello", + idx=0, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject string as array", + ), + ArrayTestClass( + id="int_as_array", + arrays=42, + idx=0, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject int as array", + ), + ArrayTestClass( + id="bool_true_as_array", + arrays=True, + idx=0, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject bool true as array", + ), + ArrayTestClass( + id="bool_false_as_array", + arrays=False, + idx=0, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject bool false as array", + ), + ArrayTestClass( + id="object_as_array", + arrays={"a": 1}, + idx=0, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject object as array", + ), + ArrayTestClass( + id="double_as_array", + arrays=3.14, + idx=0, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject double as array", + ), + ArrayTestClass( + id="decimal128_as_array", + arrays=Decimal128("1"), + idx=0, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject decimal128 as array", + ), + ArrayTestClass( + id="int64_as_array", + arrays=Int64(1), + idx=0, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject int64 as array", + ), + ArrayTestClass( + id="binary_as_array", + arrays=Binary(b"x", 0), + idx=0, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject binary as array", + ), + ArrayTestClass( + id="datetime_as_array", + arrays=datetime(2024, 1, 1, tzinfo=timezone.utc), + idx=0, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject datetime as array", + ), + ArrayTestClass( + id="objectid_as_array", + arrays=ObjectId(), + idx=0, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject objectid as array", + ), + ArrayTestClass( + id="regex_as_array", + arrays=Regex("x"), + idx=0, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject regex as array", + ), + ArrayTestClass( + id="maxkey_as_array", + arrays=MaxKey(), + idx=0, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject maxkey as array", + ), + ArrayTestClass( + id="minkey_as_array", + arrays=MinKey(), + idx=0, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject minkey as array", + ), + ArrayTestClass( + id="timestamp_as_array", + arrays=Timestamp(0, 0), + idx=0, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject timestamp as array", + ), + ArrayTestClass( + id="nan_as_array", + arrays=float("nan"), + idx=0, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject NaN as array", + ), + ArrayTestClass( + id="inf_as_array", + arrays=float("inf"), + idx=0, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject Infinity as array", + ), + ArrayTestClass( + id="decimal128_nan_as_array", + arrays=Decimal128("NaN"), + idx=0, + error_code=ARRAY_ELEM_AT_ARRAY_TYPE_ERROR, + msg="$arrayElemAt should reject Decimal128 NaN as array", + ), + ArrayTestClass( + id="decimal128_inf_as_array", + arrays=Decimal128("Infinity"), + idx=0, + 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[ArrayTestClass] = [ + ArrayTestClass( + id="string_index", + arrays=[1, 2], + idx="0", + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject string index", + ), + ArrayTestClass( + id="bool_true_index", + arrays=[1, 2], + idx=True, + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject bool true index", + ), + ArrayTestClass( + id="bool_false_index", + arrays=[1, 2], + idx=False, + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject bool false index", + ), + ArrayTestClass( + id="array_index", + arrays=[1, 2], + idx=[0], + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject array index", + ), + ArrayTestClass( + id="object_index", + arrays=[1, 2], + idx={"a": 0}, + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject object index", + ), + ArrayTestClass( + id="objectid_index", + arrays=[1, 2], + idx=ObjectId(), + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject objectid index", + ), + ArrayTestClass( + id="binary_index", + arrays=[1, 2], + idx=Binary(b"\x01", 0), + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject binary index", + ), + ArrayTestClass( + id="timestamp_index", + arrays=[1, 2], + idx=Timestamp(0, 0), + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject timestamp index", + ), + ArrayTestClass( + id="datetime_index", + arrays=[1, 2], + idx=datetime(2024, 1, 1, tzinfo=timezone.utc), + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject datetime index", + ), + ArrayTestClass( + id="maxkey_index", + arrays=[1, 2], + idx=MaxKey(), + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject maxkey index", + ), + ArrayTestClass( + id="minkey_index", + arrays=[1, 2], + idx=MinKey(), + error_code=ARRAY_ELEM_AT_INDEX_TYPE_ERROR, + msg="$arrayElemAt should reject minkey index", + ), + ArrayTestClass( + id="regex_index", + arrays=[1, 2], + idx=Regex("x"), + 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[ArrayTestClass] = [ + ArrayTestClass( + id="double_fractional_index", + arrays=[1, 2, 3], + idx=1.5, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject fractional double index", + ), + ArrayTestClass( + id="decimal128_fractional_index", + arrays=[1, 2, 3], + idx=Decimal128("0.5"), + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject fractional decimal128 index", + ), + ArrayTestClass( + id="double_nan_index", + arrays=[1, 2, 3], + idx=float("nan"), + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject NaN index", + ), + ArrayTestClass( + id="double_inf_index", + arrays=[1, 2, 3], + idx=float("inf"), + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject infinity index", + ), + ArrayTestClass( + id="double_neg_inf_index", + arrays=[1, 2, 3], + idx=float("-inf"), + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject -infinity index", + ), + ArrayTestClass( + id="decimal128_nan_index", + arrays=[1, 2, 3], + idx=Decimal128("NaN"), + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject decimal128 NaN index", + ), + ArrayTestClass( + id="decimal128_inf_index", + arrays=[1, 2, 3], + idx=Decimal128("Infinity"), + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject decimal128 infinity index", + ), + ArrayTestClass( + id="decimal128_neg_inf_index", + arrays=[1, 2, 3], + idx=Decimal128("-Infinity"), + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject decimal128 -infinity index", + ), + ArrayTestClass( + id="int64_max_index", + arrays=[1, 2, 3], + idx=INT64_MAX, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject INT64_MAX index", + ), + ArrayTestClass( + id="int64_min_index", + arrays=[1, 2, 3], + idx=INT64_MIN, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject INT64_MIN index", + ), + ArrayTestClass( + id="large_double_index", + arrays=[1, 2, 3], + idx=1.0e18, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject large double index", + ), + ArrayTestClass( + id="large_neg_double_index", + arrays=[1, 2, 3], + idx=-1.0e18, + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject large negative double index", + ), + ArrayTestClass( + id="decimal128_beyond_int32", + arrays=[1, 2, 3], + idx=Decimal128("2147483648"), + error_code=ARRAY_ELEM_AT_INDEX_NOT_INTEGRAL_ERROR, + msg="$arrayElemAt should reject decimal128 beyond int32", + ), + ArrayTestClass( + id="decimal128_huge", + arrays=[1, 2, 3], + idx=Decimal128("9223372036854775808"), + 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 + +TEST_SUBSET_FOR_LITERAL = [ + ARRAY_TYPE_ERROR_TESTS[0], + INDEX_TYPE_ERROR_TESTS[0], + NON_INTEGRAL_INDEX_TESTS[0], +] + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_arrayElemAt_literal(collection, test): + """Test $arrayElemAt error cases with literal values.""" + result = execute_expression(collection, {"$arrayElemAt": [test.arrays, test.idx]}) + 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 error cases with values from inserted documents.""" + result = execute_expression_with_insert( + collection, {"$arrayElemAt": ["$arr", "$idx"]}, {"arr": test.arrays, "idx": test.idx} + ) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +# 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_expression_arrayElemAt_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_expression_arrayElemAt_expressions.py new file mode 100644 index 000000000..a71d8e788 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_expression_arrayElemAt_expressions.py @@ -0,0 +1,145 @@ +""" +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 + + +# 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("0"), 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_expression_arrayElemAt_null_missing.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_expression_arrayElemAt_null_missing.py new file mode 100644 index 000000000..324e5dda5 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_expression_arrayElemAt_null_missing.py @@ -0,0 +1,94 @@ +""" +Null and missing field behavior tests for $arrayElemAt expression. + +Tests null propagation and missing field handling for array and index arguments. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.array.utils.array_test_case import ( # noqa: E501 + ArrayTestClass, +) +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 +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[ArrayTestClass] = [ + ArrayTestClass( + id="null_array", + arrays=None, + idx=0, + expected=None, + msg="$arrayElemAt should return null for null array", + ), + ArrayTestClass( + id="null_array_neg_idx", + arrays=None, + idx=-1, + expected=None, + msg="$arrayElemAt should return null for null array with negative index", + ), + ArrayTestClass( + id="null_index", + arrays=[1, 2], + idx=None, + expected=None, + msg="$arrayElemAt should return null for null index", + ), + ArrayTestClass( + id="both_null", + arrays=None, + idx=None, + 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[ArrayTestClass] = [ + ArrayTestClass( + id="missing_array", + arrays=MISSING, + idx=0, + expected=None, + msg="$arrayElemAt should return null for missing array", + ), + ArrayTestClass( + id="missing_index", + arrays=[1, 2, 3], + idx=MISSING, + expected=None, + msg="$arrayElemAt should return null for missing index", + ), +] + +TEST_SUBSET_FOR_LITERAL = [ + NULL_TESTS[0], + NULL_TESTS[2], + NULL_TESTS[3], +] + LITERAL_ONLY_TESTS + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_arrayElemAt_literal(collection, test): + """Test $arrayElemAt null/missing with literal values.""" + result = execute_expression(collection, {"$arrayElemAt": [test.arrays, test.idx]}) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(NULL_TESTS)) +def test_arrayElemAt_insert(collection, test): + """Test $arrayElemAt null with values from inserted documents.""" + result = execute_expression_with_insert( + collection, {"$arrayElemAt": ["$arr", "$idx"]}, {"arr": test.arrays, "idx": test.idx} + ) + 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_expression_arrayElemAt_numeric_index.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_expression_arrayElemAt_numeric_index.py new file mode 100644 index 000000000..aa0b427c2 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_expression_arrayElemAt_numeric_index.py @@ -0,0 +1,183 @@ +""" +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. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.array.utils.array_test_case import ( # noqa: E501 + ArrayTestClass, +) +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 +from documentdb_tests.framework.test_constants import DECIMAL128_NEGATIVE_ZERO + +# Property [Numeric Index Types]: $arrayElemAt accepts int32, int64, and integral double indexes. +NUMERIC_INDEX_TESTS: list[ArrayTestClass] = [ + ArrayTestClass( + id="int64_zero_index", + arrays=[10, 20, 30], + idx=Int64(0), + expected=10, + msg="$arrayElemAt should accept Int64 zero index", + ), + ArrayTestClass( + id="int64_index", + arrays=[10, 20, 30], + idx=Int64(1), + expected=20, + msg="$arrayElemAt should accept Int64 index", + ), + ArrayTestClass( + id="double_integral_index", + arrays=[10, 20, 30], + idx=2.0, + expected=30, + msg="$arrayElemAt should accept integral double index", + ), + ArrayTestClass( + id="decimal128_integral_index", + arrays=[10, 20, 30], + idx=Decimal128("0"), + expected=10, + msg="$arrayElemAt should accept Decimal128 index", + ), + ArrayTestClass( + id="int64_negative_index", + arrays=[10, 20, 30], + idx=Int64(-1), + expected=30, + msg="$arrayElemAt should accept negative Int64 index", + ), + ArrayTestClass( + id="double_negative_integral", + arrays=[10, 20, 30], + idx=-2.0, + expected=20, + msg="$arrayElemAt should accept negative integral double index", + ), + ArrayTestClass( + id="negative_zero_index", + arrays=[10, 20, 30], + idx=-0.0, + expected=10, + msg="$arrayElemAt should treat -0.0 as index 0", + ), + ArrayTestClass( + id="decimal128_negative_zero_index", + arrays=[10, 20, 30], + idx=DECIMAL128_NEGATIVE_ZERO, + expected=10, + msg="$arrayElemAt should treat decimal128 -0 as index 0", + ), + ArrayTestClass( + id="decimal128_trailing_zero", + arrays=[10, 20, 30], + idx=Decimal128("1.0"), + expected=20, + msg="$arrayElemAt should accept decimal128 with trailing zero", + ), + ArrayTestClass( + id="decimal128_subnormal_zero", + arrays=[10, 20, 30], + idx=Decimal128("0E-6176"), + expected=10, + msg="$arrayElemAt should accept decimal128 subnormal zero", + ), + ArrayTestClass( + id="decimal128_20E_neg1", + arrays=[10, 20, 30], + idx=Decimal128("20E-1"), + expected=30, + msg="$arrayElemAt should accept decimal128 20E-1 as index 2", + ), + ArrayTestClass( + id="decimal128_0_2E1", + arrays=[10, 20, 30], + idx=Decimal128("0.2E1"), + expected=30, + msg="$arrayElemAt should accept decimal128 0.2E1 as index 2", + ), + ArrayTestClass( + id="decimal128_2E0", + arrays=[10, 20, 30], + idx=Decimal128("2E0"), + expected=30, + msg="$arrayElemAt should accept decimal128 2E0 as index 2", + ), + ArrayTestClass( + id="decimal128_10E_neg1", + arrays=[10, 20, 30], + idx=Decimal128("10E-1"), + expected=20, + msg="$arrayElemAt should accept decimal128 10E-1 as index 1", + ), + ArrayTestClass( + id="decimal128_negative_integral_index", + arrays=[10, 20, 30], + idx=Decimal128("-1"), + expected=30, + msg="$arrayElemAt should accept negative Decimal128 integral index", + ), + ArrayTestClass( + id="decimal128_neg_10E_neg1", + arrays=[10, 20, 30], + idx=Decimal128("-10E-1"), + expected=30, + msg="$arrayElemAt should accept decimal128 -10E-1 as index -1", + ), + ArrayTestClass( + id="decimal128_0E_pos3", + arrays=[10, 20, 30], + idx=Decimal128("0E+3"), + expected=10, + msg="$arrayElemAt should accept decimal128 0E+3 as index 0", + ), + ArrayTestClass( + id="decimal128_0E_neg3", + arrays=[10, 20, 30], + idx=Decimal128("0E-3"), + expected=10, + msg="$arrayElemAt should accept decimal128 0E-3 as index 0", + ), + ArrayTestClass( + id="decimal128_1_00000", + arrays=[10, 20, 30], + idx=Decimal128("1.00000"), + expected=20, + msg="$arrayElemAt should accept decimal128 1.00000 as index 1", + ), +] + +TEST_SUBSET_FOR_LITERAL = [ + NUMERIC_INDEX_TESTS[0], + NUMERIC_INDEX_TESTS[5], + NUMERIC_INDEX_TESTS[-1], +] + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_arrayElemAt_literal(collection, test): + """Test $arrayElemAt numeric index types with literal values.""" + result = execute_expression(collection, {"$arrayElemAt": [test.arrays, test.idx]}) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(NUMERIC_INDEX_TESTS)) +def test_arrayElemAt_insert(collection, test): + """Test $arrayElemAt numeric index types with values from inserted documents.""" + result = execute_expression_with_insert( + collection, {"$arrayElemAt": ["$arr", "$idx"]}, {"arr": test.arrays, "idx": test.idx} + ) + 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_expression_arrayElemAt_out_of_bounds.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_expression_arrayElemAt_out_of_bounds.py new file mode 100644 index 000000000..7d3022ae1 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayElemAt/test_expression_arrayElemAt_out_of_bounds.py @@ -0,0 +1,130 @@ +""" +Out-of-bounds index tests for $arrayElemAt expression. + +Tests that $arrayElemAt returns no result (missing) when the index +exceeds array bounds in either direction. +""" + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.expressions.array.utils.array_test_case import ( # noqa: E501 + ArrayTestClass, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.parametrize import pytest_params +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[ArrayTestClass] = [ + ArrayTestClass( + id="positive_oob", + arrays=[1, 2, 3], + idx=15, + expected=[{}], + msg="$arrayElemAt should return no result for positive OOB", + ), + ArrayTestClass( + id="positive_oob_by_one", + arrays=[1, 2, 3], + idx=3, + expected=[{}], + msg="$arrayElemAt should return no result for OOB by one", + ), + ArrayTestClass( + id="negative_oob", + arrays=[1, 2, 3], + idx=-4, + expected=[{}], + msg="$arrayElemAt should return no result for negative OOB", + ), + ArrayTestClass( + id="negative_oob_large", + arrays=[1, 2, 3], + idx=-100, + expected=[{}], + msg="$arrayElemAt should return no result for large negative OOB", + ), + ArrayTestClass( + id="empty_array_idx_0", + arrays=[], + idx=0, + expected=[{}], + msg="$arrayElemAt should return no result for empty array idx 0", + ), + ArrayTestClass( + id="empty_array_neg1", + arrays=[], + idx=-1, + expected=[{}], + msg="$arrayElemAt should return no result for empty array idx -1", + ), + ArrayTestClass( + id="int32_max_oob", + arrays=[1, 2, 3], + idx=INT32_MAX, + expected=[{}], + msg="$arrayElemAt should return no result for INT32_MAX index", + ), + ArrayTestClass( + id="int32_min_oob", + arrays=[1, 2, 3], + idx=INT32_MIN, + expected=[{}], + msg="$arrayElemAt should return no result for INT32_MIN index", + ), + ArrayTestClass( + id="single_element_oob_pos", + arrays=[42], + idx=1, + expected=[{}], + msg="$arrayElemAt should return no result for single element OOB positive", + ), + ArrayTestClass( + id="single_element_oob_neg", + arrays=[42], + idx=-2, + expected=[{}], + msg="$arrayElemAt should return no result for single element OOB negative", + ), + ArrayTestClass( + id="decimal128_oob_pos", + arrays=[1, 2, 3], + idx=Decimal128("15"), + expected=[{}], + msg="$arrayElemAt should return no result for Decimal128 positive OOB", + ), + ArrayTestClass( + id="decimal128_oob_neg", + arrays=[1, 2, 3], + idx=Decimal128("-100"), + expected=[{}], + msg="$arrayElemAt should return no result for Decimal128 negative OOB", + ), +] + +TEST_SUBSET_FOR_LITERAL = [ + OUT_OF_BOUNDS_TESTS[0], + OUT_OF_BOUNDS_TESTS[4], + OUT_OF_BOUNDS_TESTS[-1], +] + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_arrayElemAt_out_of_bounds_literal(collection, test): + """Test $arrayElemAt returns no result when index exceeds array bounds.""" + result = execute_expression(collection, {"$arrayElemAt": [test.arrays, test.idx]}) + assertSuccess(result, test.expected) + + +@pytest.mark.parametrize("test", pytest_params(OUT_OF_BOUNDS_TESTS)) +def test_arrayElemAt_out_of_bounds_insert(collection, test): + """Test $arrayElemAt out-of-bounds with inserted documents.""" + result = execute_expression_with_insert( + collection, {"$arrayElemAt": ["$arr", "$idx"]}, {"arr": test.arrays, "idx": test.idx} + ) + assertSuccess(result, test.expected) 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_expression_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_expression_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_expression_arrayToObject_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_expression_arrayToObject_bson_types.py new file mode 100644 index 000000000..c7071ea93 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_expression_arrayToObject_bson_types.py @@ -0,0 +1,439 @@ +""" +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.array.utils.array_test_case import ( # noqa: E501 + ArrayTestClass, +) +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 +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_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[ArrayTestClass] = [ + ArrayTestClass( + id="kv_int64", + arrays=[{"k": "a", "v": Int64(99)}], + expected={"a": Int64(99)}, + msg="$arrayToObject should preserve Int64 value", + ), + ArrayTestClass( + id="kv_decimal128", + arrays=[{"k": "a", "v": Decimal128("3.14")}], + expected={"a": Decimal128("3.14")}, + msg="$arrayToObject should preserve Decimal128 value", + ), + ArrayTestClass( + id="kv_datetime", + arrays=[{"k": "a", "v": datetime(2024, 1, 1, tzinfo=timezone.utc)}], + expected={"a": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + msg="$arrayToObject should preserve datetime value", + ), + ArrayTestClass( + id="kv_objectid", + arrays=[{"k": "a", "v": ObjectId("000000000000000000000001")}], + expected={"a": ObjectId("000000000000000000000001")}, + msg="$arrayToObject should preserve ObjectId value", + ), + ArrayTestClass( + id="kv_bool_false", + arrays=[{"k": "a", "v": False}], + expected={"a": False}, + msg="$arrayToObject should preserve false value", + ), + ArrayTestClass( + id="kv_bool_true", + arrays=[{"k": "a", "v": True}], + expected={"a": True}, + msg="$arrayToObject should preserve true value", + ), + ArrayTestClass( + id="kv_null", + arrays=[{"k": "a", "v": None}], + expected={"a": None}, + msg="$arrayToObject should preserve null value", + ), + ArrayTestClass( + id="kv_regex", + arrays=[{"k": "a", "v": Regex("^abc", "i")}], + expected={"a": Regex("^abc", "i")}, + msg="$arrayToObject should preserve regex value", + ), + ArrayTestClass( + id="kv_minkey", + arrays=[{"k": "a", "v": MinKey()}], + expected={"a": MinKey()}, + msg="$arrayToObject should preserve MinKey value", + ), + ArrayTestClass( + id="kv_maxkey", + arrays=[{"k": "a", "v": MaxKey()}], + expected={"a": MaxKey()}, + msg="$arrayToObject should preserve MaxKey value", + ), + ArrayTestClass( + id="kv_binary", + arrays=[{"k": "a", "v": Binary(b"\x01\x02\x03", 0)}], + expected={"a": b"\x01\x02\x03"}, + msg="$arrayToObject should preserve Binary value", + ), + ArrayTestClass( + id="kv_timestamp", + arrays=[{"k": "a", "v": Timestamp(1234567890, 1)}], + expected={"a": Timestamp(1234567890, 1)}, + msg="$arrayToObject should preserve Timestamp value", + ), + ArrayTestClass( + id="kv_uuid", + arrays=[{"k": "a", "v": Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210"))}], + 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[ArrayTestClass] = [ + ArrayTestClass( + id="pair_int64", + arrays=[["a", Int64(99)]], + expected={"a": Int64(99)}, + msg="$arrayToObject should preserve Int64 value (pair form)", + ), + ArrayTestClass( + id="pair_decimal128", + arrays=[["a", Decimal128("3.14")]], + expected={"a": Decimal128("3.14")}, + msg="$arrayToObject should preserve Decimal128 value (pair form)", + ), + ArrayTestClass( + id="pair_datetime", + arrays=[["a", datetime(2024, 1, 1, tzinfo=timezone.utc)]], + expected={"a": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + msg="$arrayToObject should preserve datetime value (pair form)", + ), + ArrayTestClass( + id="pair_objectid", + arrays=[["a", ObjectId("000000000000000000000001")]], + expected={"a": ObjectId("000000000000000000000001")}, + msg="$arrayToObject should preserve ObjectId value (pair form)", + ), + ArrayTestClass( + id="pair_binary", + arrays=[["a", Binary(b"\x01\x02\x03", 0)]], + expected={"a": b"\x01\x02\x03"}, + msg="$arrayToObject should preserve Binary value (pair form)", + ), + ArrayTestClass( + id="pair_timestamp", + arrays=[["a", Timestamp(1234567890, 1)]], + expected={"a": Timestamp(1234567890, 1)}, + msg="$arrayToObject should preserve Timestamp value (pair form)", + ), + ArrayTestClass( + id="pair_regex", + arrays=[["a", Regex("^abc", "i")]], + expected={"a": Regex("^abc", "i")}, + msg="$arrayToObject should preserve regex value (pair form)", + ), + ArrayTestClass( + id="pair_minkey", + arrays=[["a", MinKey()]], + expected={"a": MinKey()}, + msg="$arrayToObject should preserve MinKey value (pair form)", + ), + ArrayTestClass( + id="pair_maxkey", + arrays=[["a", MaxKey()]], + expected={"a": MaxKey()}, + msg="$arrayToObject should preserve MaxKey value (pair form)", + ), + ArrayTestClass( + id="pair_uuid", + arrays=[["a", Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210"))]], + 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[ArrayTestClass] = [ + ArrayTestClass( + id="value_infinity", + arrays=[{"k": "a", "v": FLOAT_INFINITY}], + expected={"a": FLOAT_INFINITY}, + msg="$arrayToObject should preserve Infinity value", + ), + ArrayTestClass( + id="value_neg_infinity", + arrays=[{"k": "a", "v": FLOAT_NEGATIVE_INFINITY}], + expected={"a": FLOAT_NEGATIVE_INFINITY}, + msg="$arrayToObject should preserve -Infinity value", + ), + ArrayTestClass( + id="value_neg_zero", + arrays=[{"k": "a", "v": DOUBLE_NEGATIVE_ZERO}], + expected={"a": DOUBLE_NEGATIVE_ZERO}, + msg="$arrayToObject should preserve negative zero value", + ), + ArrayTestClass( + id="value_decimal128_nan", + arrays=[{"k": "a", "v": DECIMAL128_NAN}], + expected={"a": DECIMAL128_NAN}, + msg="$arrayToObject should preserve Decimal128 NaN value", + ), + ArrayTestClass( + id="value_decimal128_infinity", + arrays=[{"k": "a", "v": DECIMAL128_INFINITY}], + expected={"a": DECIMAL128_INFINITY}, + msg="$arrayToObject should preserve Decimal128 Infinity value", + ), + ArrayTestClass( + id="value_decimal128_neg_infinity", + arrays=[{"k": "a", "v": DECIMAL128_NEGATIVE_INFINITY}], + expected={"a": DECIMAL128_NEGATIVE_INFINITY}, + msg="$arrayToObject should preserve Decimal128 -Infinity value", + ), + ArrayTestClass( + id="value_decimal128_neg_zero", + arrays=[{"k": "a", "v": DECIMAL128_NEGATIVE_ZERO}], + expected={"a": DECIMAL128_NEGATIVE_ZERO}, + msg="$arrayToObject should preserve Decimal128 -0 value", + ), + ArrayTestClass( + id="value_decimal128_high_precision", + arrays=[{"k": "a", "v": Decimal128("1.234567890123456789012345678901234")}], + expected={"a": Decimal128("1.234567890123456789012345678901234")}, + msg="$arrayToObject should preserve full Decimal128 precision", + ), + ArrayTestClass( + id="value_decimal128_zero_exponent", + arrays=[{"k": "a", "v": Decimal128("0E+10")}], + expected={"a": Decimal128("0E+10")}, + msg="$arrayToObject should preserve Decimal128 exponent notation", + ), + ArrayTestClass( + id="value_decimal128_trailing_zeros", + arrays=[{"k": "a", "v": Decimal128("1.00000")}], + expected={"a": Decimal128("1.00000")}, + msg="$arrayToObject should preserve Decimal128 trailing zeros", + ), + ArrayTestClass( + id="value_decimal128_subnormal_zero", + arrays=[{"k": "a", "v": Decimal128("0E-6176")}], + expected={"a": Decimal128("0E-6176")}, + msg="$arrayToObject should preserve Decimal128 subnormal zero", + ), +] + +# Property [Numeric Boundaries]: $arrayToObject preserves numeric boundary values. +BOUNDARY_TESTS: list[ArrayTestClass] = [ + ArrayTestClass( + id="value_int32_max", + arrays=[{"k": "a", "v": INT32_MAX}], + expected={"a": INT32_MAX}, + msg="$arrayToObject should preserve INT32_MAX value", + ), + ArrayTestClass( + id="value_int32_min", + arrays=[{"k": "a", "v": INT32_MIN}], + expected={"a": INT32_MIN}, + msg="$arrayToObject should preserve INT32_MIN value", + ), + ArrayTestClass( + id="value_int64_max", + arrays=[{"k": "a", "v": INT64_MAX}], + expected={"a": INT64_MAX}, + msg="$arrayToObject should preserve INT64_MAX value", + ), + ArrayTestClass( + id="value_int64_min", + arrays=[{"k": "a", "v": INT64_MIN}], + expected={"a": INT64_MIN}, + msg="$arrayToObject should preserve INT64_MIN value", + ), + ArrayTestClass( + id="value_decimal128_max", + arrays=[{"k": "a", "v": DECIMAL128_MAX}], + expected={"a": DECIMAL128_MAX}, + msg="$arrayToObject should preserve DECIMAL128_MAX value", + ), + ArrayTestClass( + id="value_decimal128_min", + arrays=[{"k": "a", "v": DECIMAL128_MIN}], + 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[ArrayTestClass] = [ + ArrayTestClass( + id="nested_bson_in_object_value", + arrays=[{"k": "a", "v": {"x": Int64(1), "y": Decimal128("2.5")}}], + expected={"a": {"x": Int64(1), "y": Decimal128("2.5")}}, + msg="$arrayToObject should preserve nested BSON types in object value", + ), + ArrayTestClass( + id="nested_bson_in_array_value", + arrays=[ + { + "k": "a", + "v": [ + MinKey(), + datetime(2024, 1, 1, tzinfo=timezone.utc), + ObjectId("000000000000000000000001"), + ], + } + ], + expected={ + "a": [ + MinKey(), + datetime(2024, 1, 1, tzinfo=timezone.utc), + ObjectId("000000000000000000000001"), + ] + }, + msg="$arrayToObject should preserve nested BSON types in array value", + ), + ArrayTestClass( + id="deeply_nested_bson", + arrays=[{"k": "a", "v": {"x": [{"y": Decimal128("1.5")}, Timestamp(0, 0)]}}], + expected={"a": {"x": [{"y": Decimal128("1.5")}, Timestamp(0, 0)]}}, + msg="$arrayToObject should preserve deeply nested BSON types", + ), + ArrayTestClass( + id="nested_array_not_interpreted_as_kv", + arrays=[{"k": "a", "v": [["level2", {"x": 1}]]}], + 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[ArrayTestClass] = [ + ArrayTestClass( + id="duplicate_key_int_then_int64", + arrays=[{"k": "a", "v": 1}, {"k": "a", "v": Int64(2)}], + expected={"a": Int64(2)}, + msg="$arrayToObject should keep the last Int64 value for a duplicate key", + ), + ArrayTestClass( + id="duplicate_key_int_then_decimal128", + arrays=[{"k": "a", "v": 1}, {"k": "a", "v": Decimal128("2")}], + expected={"a": Decimal128("2")}, + msg="$arrayToObject should keep the last Decimal128 value for a duplicate key", + ), + ArrayTestClass( + id="duplicate_key_decimal128_then_double", + arrays=[{"k": "a", "v": Decimal128("1")}, {"k": "a", "v": 2.0}], + 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[ArrayTestClass] = [ + ArrayTestClass( + id="kv_mixed_bson_types", + arrays=[ + {"k": "int64", "v": Int64(1)}, + {"k": "dec", "v": Decimal128("1.5")}, + {"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()}, + ], + expected={ + "int64": Int64(1), + "dec": Decimal128("1.5"), + "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 +) + +TEST_SUBSET_FOR_LITERAL = [ + BSON_KV_TESTS[0], + BSON_KV_TESTS[10], + BSON_KV_TESTS[12], + BSON_PAIR_TESTS[0], + SPECIAL_NUMERIC_TESTS[0], + BOUNDARY_TESTS[0], + NESTED_BSON_TESTS[0], + MIXED_BSON_TESTS[0], +] + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_arrayToObject_bson_literal(collection, test): + """Test $arrayToObject BSON types with literal values.""" + result = execute_expression(collection, {"$arrayToObject": {"$literal": test.arrays}}) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(ALL_BSON_TESTS)) +def test_arrayToObject_bson_insert(collection, test): + """Test $arrayToObject BSON types with values from inserted documents.""" + result = execute_expression_with_insert( + collection, {"$arrayToObject": "$arr"}, {"arr": test.arrays} + ) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +# 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_expression_arrayToObject_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_expression_arrayToObject_core_behavior.py new file mode 100644 index 000000000..8c418b8f7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_expression_arrayToObject_core_behavior.py @@ -0,0 +1,339 @@ +""" +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.array.utils.array_test_case import ( # noqa: E501 + ArrayTestClass, +) +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 [K/V Form]: $arrayToObject builds an object from {k, v} document entries. +KV_FORM_TESTS: list[ArrayTestClass] = [ + ArrayTestClass( + id="kv_single_pair", + arrays=[{"k": "a", "v": 1}], + expected={"a": 1}, + msg="$arrayToObject should convert single k/v pair", + ), + ArrayTestClass( + id="kv_multiple_pairs", + arrays=[{"k": "a", "v": 1}, {"k": "b", "v": 2}, {"k": "c", "v": 3}], + expected={"a": 1, "b": 2, "c": 3}, + msg="$arrayToObject should convert multiple k/v pairs", + ), + ArrayTestClass( + id="kv_string_values", + arrays=[{"k": "name", "v": "Alice"}, {"k": "city", "v": "Mycity"}], + expected={"name": "Alice", "city": "Mycity"}, + msg="$arrayToObject should convert k/v pairs with string values", + ), + ArrayTestClass( + id="kv_mixed_value_types", + arrays=[ + {"k": "int", "v": 1}, + {"k": "str", "v": "hello"}, + {"k": "bool", "v": True}, + {"k": "null", "v": None}, + ], + expected={"int": 1, "str": "hello", "bool": True, "null": None}, + msg="$arrayToObject should convert k/v pairs with mixed value types", + ), + ArrayTestClass( + id="kv_nested_object_value", + arrays=[{"k": "obj", "v": {"x": 1, "y": 2}}], + expected={"obj": {"x": 1, "y": 2}}, + msg="$arrayToObject should convert k/v pair with nested object value", + ), + ArrayTestClass( + id="kv_array_value", + arrays=[{"k": "arr", "v": [1, 2, 3]}], + 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[ArrayTestClass] = [ + ArrayTestClass( + id="pair_single", + arrays=[["a", 1]], + expected={"a": 1}, + msg="$arrayToObject should convert single two-element pair", + ), + ArrayTestClass( + id="pair_multiple", + arrays=[["a", 1], ["b", 2], ["c", 3]], + expected={"a": 1, "b": 2, "c": 3}, + msg="$arrayToObject should convert multiple two-element pairs", + ), + ArrayTestClass( + id="pair_string_values", + arrays=[["name", "Alice"], ["city", "Mycity"]], + expected={"name": "Alice", "city": "Mycity"}, + msg="$arrayToObject should convert pairs with string values", + ), + ArrayTestClass( + id="pair_mixed_value_types", + arrays=[["int", 1], ["str", "hello"], ["bool", True], ["null", None]], + expected={"int": 1, "str": "hello", "bool": True, "null": None}, + msg="$arrayToObject should convert pairs with mixed value types", + ), + ArrayTestClass( + id="pair_nested_object_value", + arrays=[["obj", {"x": 1, "y": 2}]], + expected={"obj": {"x": 1, "y": 2}}, + msg="$arrayToObject should convert pair with nested object value", + ), + ArrayTestClass( + id="pair_array_value", + arrays=[["arr", [1, 2, 3]]], + 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[ArrayTestClass] = [ + ArrayTestClass( + id="empty_array", + arrays=[], + expected={}, + msg="$arrayToObject should return empty object for empty array", + ), + ArrayTestClass( + id="null_array", + arrays=None, + 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[ArrayTestClass] = [ + ArrayTestClass( + id="kv_duplicate_keys", + arrays=[{"k": "a", "v": 1}, {"k": "a", "v": 2}], + expected={"a": 2}, + msg="$arrayToObject should keep the last value for duplicate keys (k/v form)", + ), + ArrayTestClass( + id="pair_duplicate_keys", + arrays=[["a", 1], ["a", 2]], + expected={"a": 2}, + msg="$arrayToObject should keep the last value for duplicate keys (pair form)", + ), + ArrayTestClass( + id="kv_triple_duplicate", + arrays=[{"k": "x", "v": 1}, {"k": "x", "v": 2}, {"k": "x", "v": 3}], + expected={"x": 3}, + msg="$arrayToObject should keep the last of three duplicate keys", + ), + ArrayTestClass( + id="pair_dup_different_types", + arrays=[["a", 1], ["a", "hello"]], + expected={"a": "hello"}, + msg="$arrayToObject should keep the last value even with different value types", + ), + ArrayTestClass( + id="pair_dup_interspersed", + arrays=[["a", 1], ["b", 2], ["a", 3]], + expected={"a": 3, "b": 2}, + msg="$arrayToObject should keep the last value with interspersed duplicate keys", + ), + ArrayTestClass( + id="kv_dup_interspersed", + arrays=[{"k": "a", "v": 1}, {"k": "b", "v": 2}, {"k": "a", "v": 3}], + expected={"a": 3, "b": 2}, + msg="$arrayToObject should keep the last value with interspersed duplicates (k/v form)", + ), + ArrayTestClass( + id="kv_reversed_field_order", + arrays=[{"v": "val", "k": "key"}], + 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[ArrayTestClass] = [ + ArrayTestClass( + id="unicode_key", + arrays=[{"k": "日本語", "v": 1}], + expected={"日本語": 1}, + msg="$arrayToObject should accept a unicode key", + ), + ArrayTestClass( + id="emoji_key", + arrays=[{"k": "🔑", "v": "value"}], + expected={"🔑": "value"}, + msg="$arrayToObject should accept an emoji key", + ), + ArrayTestClass( + id="key_with_spaces", + arrays=[["key with spaces", 1]], + expected={"key with spaces": 1}, + msg="$arrayToObject should accept a key with spaces", + ), + ArrayTestClass( + id="numeric_string_keys", + arrays=[["0", "a"], ["1", "b"]], + expected={"0": "a", "1": "b"}, + msg="$arrayToObject should treat numeric string keys as strings", + ), + ArrayTestClass( + id="underscore_id_key", + arrays=[["_id", 1]], + expected={"_id": 1}, + msg="$arrayToObject should accept _id as a key", + ), + ArrayTestClass( + id="operator_like_key", + arrays=[["$set", 1]], + expected={"$set": 1}, + msg="$arrayToObject should accept an operator-like key", + ), + ArrayTestClass( + id="very_long_key", + arrays=[["k" * 1024, 1]], + expected={"k" * 1024: 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). +EDGE_CASE_TESTS: list[ArrayTestClass] = [ + ArrayTestClass( + id="case_sensitive_keys_kv", + arrays=[{"k": "price", "v": 24}, {"k": "PRICE", "v": 100}], + expected={"price": 24, "PRICE": 100}, + msg="$arrayToObject should treat case-differing keys as distinct", + ), + ArrayTestClass( + id="case_sensitive_keys_pair", + arrays=[["price", 24], ["PRICE", 100]], + expected={"price": 24, "PRICE": 100}, + msg="$arrayToObject should treat case-differing keys as distinct (pair form)", + ), + ArrayTestClass( + id="deeply_nested_object_value", + arrays=[["key", {"a": {"b": {"c": {"d": 1}}}}]], + expected={"key": {"a": {"b": {"c": {"d": 1}}}}}, + msg="$arrayToObject should handle deeply nested object", + ), + ArrayTestClass( + id="deeply_nested_array_value", + arrays=[["key", [[[[1]]]]]], + expected={"key": [[[[1]]]]}, + msg="$arrayToObject should handle deeply nested array", + ), + ArrayTestClass( + id="empty_object_value", + arrays=[["key", {}]], + expected={"key": {}}, + msg="$arrayToObject should handle empty object value", + ), + ArrayTestClass( + id="empty_array_value", + arrays=[["key", []]], + expected={"key": []}, + msg="$arrayToObject should handle empty array value", + ), + ArrayTestClass( + id="empty_string_value", + arrays=[["key", ""]], + expected={"key": ""}, + msg="$arrayToObject should handle empty string value", + ), + ArrayTestClass( + id="large_string_value", + arrays=[["key", "x" * 10240]], + expected={"key": "x" * 10240}, + 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 = [ + KV_FORM_TESTS[0], + KV_FORM_TESTS[3], + TWO_ELEM_FORM_TESTS[0], + EMPTY_AND_NULL_ARRAY_TESTS[0], + DUPLICATE_KEY_TESTS[0], + EDGE_CASE_TESTS[0], +] + + +@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, {"$arrayToObject": {"$literal": test.arrays}}) + 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, {"$arrayToObject": "$arr"}, {"arr": test.arrays} + ) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize( + "large_arr", + [ + pytest.param([[f"key_{i}", i] for i in range(10_000)], id="two_element_pairs"), + pytest.param([{"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_expression_arrayToObject_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_expression_arrayToObject_errors.py new file mode 100644 index 000000000..0a4042841 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_expression_arrayToObject_errors.py @@ -0,0 +1,421 @@ +""" +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.array.utils.array_test_case import ( # noqa: E501 + ArrayTestClass, +) +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[ArrayTestClass] = [ + ArrayTestClass( + id="string_input", + arrays="hello", + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject string input", + ), + ArrayTestClass( + id="int_input", + arrays=42, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject int input", + ), + ArrayTestClass( + id="bool_input", + arrays=True, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject bool input", + ), + ArrayTestClass( + id="object_input", + arrays={"a": 1}, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject object input", + ), + ArrayTestClass( + id="double_input", + arrays=3.14, + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject double input", + ), + ArrayTestClass( + id="decimal128_input", + arrays=Decimal128("1"), + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject decimal128 input", + ), + ArrayTestClass( + id="int64_input", + arrays=Int64(1), + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject int64 input", + ), + ArrayTestClass( + id="objectid_input", + arrays=ObjectId(), + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject objectid input", + ), + ArrayTestClass( + id="datetime_input", + arrays=datetime(2024, 1, 1), + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject datetime input", + ), + ArrayTestClass( + id="binary_input", + arrays=Binary(b"x", 0), + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject binary input", + ), + ArrayTestClass( + id="regex_input", + arrays=Regex("x"), + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject regex input", + ), + ArrayTestClass( + id="maxkey_input", + arrays=MaxKey(), + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject maxkey input", + ), + ArrayTestClass( + id="minkey_input", + arrays=MinKey(), + error_code=ARRAY_TO_OBJECT_NOT_ARRAY_ERROR, + msg="$arrayToObject should reject minkey input", + ), + ArrayTestClass( + id="timestamp_input", + arrays=Timestamp(0, 0), + 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[ArrayTestClass] = [ + ArrayTestClass( + id="element_is_string", + arrays=["not_a_pair"], + error_code=ARRAY_TO_OBJECT_INVALID_ELEMENT_ERROR, + msg="$arrayToObject should reject string element", + ), + ArrayTestClass( + id="element_is_int", + arrays=[42], + error_code=ARRAY_TO_OBJECT_INVALID_ELEMENT_ERROR, + msg="$arrayToObject should reject int element", + ), + ArrayTestClass( + id="element_is_null", + arrays=[None], + error_code=ARRAY_TO_OBJECT_INVALID_ELEMENT_ERROR, + msg="$arrayToObject should reject null element", + ), + ArrayTestClass( + id="element_is_bool", + arrays=[True], + error_code=ARRAY_TO_OBJECT_INVALID_ELEMENT_ERROR, + msg="$arrayToObject should reject bool element", + ), + ArrayTestClass( + id="element_is_double", + arrays=[3.14], + error_code=ARRAY_TO_OBJECT_INVALID_ELEMENT_ERROR, + msg="$arrayToObject should reject double element", + ), + ArrayTestClass( + id="element_is_objectid", + arrays=[ObjectId()], + error_code=ARRAY_TO_OBJECT_INVALID_ELEMENT_ERROR, + msg="$arrayToObject should reject ObjectId element", + ), + ArrayTestClass( + id="kv_missing_v", + arrays=[{"k": "a"}], + error_code=ARRAY_TO_OBJECT_INVALID_KV_DOC_ERROR, + msg="$arrayToObject should reject k/v doc missing v field", + ), + ArrayTestClass( + id="kv_missing_k", + arrays=[{"v": 1}], + error_code=ARRAY_TO_OBJECT_INVALID_KV_DOC_ERROR, + msg="$arrayToObject should reject k/v doc missing k field", + ), + ArrayTestClass( + id="kv_extra_field", + arrays=[{"k": "a", "v": 1, "extra": 2}], + error_code=ARRAY_TO_OBJECT_INVALID_KV_DOC_ERROR, + msg="$arrayToObject should reject k/v doc with extra field", + ), + ArrayTestClass( + id="kv_empty_doc", + arrays=[{}], + error_code=ARRAY_TO_OBJECT_INVALID_KV_DOC_ERROR, + msg="$arrayToObject should reject empty document", + ), + ArrayTestClass( + id="kv_wrong_field_names", + arrays=[{"y": "x", "x": "y"}], + error_code=ARRAY_TO_OBJECT_WRONG_FIELD_NAMES_ERROR, + msg="$arrayToObject should reject wrong field names", + ), + ArrayTestClass( + id="kv_uppercase_K", + arrays=[{"K": "k1", "v": 2}], + error_code=ARRAY_TO_OBJECT_WRONG_FIELD_NAMES_ERROR, + msg="$arrayToObject should reject uppercase K (case-sensitive)", + ), + ArrayTestClass( + id="kv_uppercase_V", + arrays=[{"k": "k1", "V": 2}], + error_code=ARRAY_TO_OBJECT_WRONG_FIELD_NAMES_ERROR, + msg="$arrayToObject should reject uppercase V (case-sensitive)", + ), + ArrayTestClass( + id="kv_key_value_names", + arrays=[{"key": "k1", "value": "v1"}], + error_code=ARRAY_TO_OBJECT_WRONG_FIELD_NAMES_ERROR, + msg="$arrayToObject should reject 'key'/'value' instead of 'k'/'v'", + ), + ArrayTestClass( + id="pair_one_element", + arrays=[["a"]], + error_code=ARRAY_TO_OBJECT_INVALID_PAIR_ERROR, + msg="$arrayToObject should reject one-element array pair", + ), + ArrayTestClass( + id="pair_three_elements", + arrays=[["a", 1, 2]], + error_code=ARRAY_TO_OBJECT_INVALID_PAIR_ERROR, + msg="$arrayToObject should reject three-element array pair", + ), + ArrayTestClass( + id="pair_empty_array", + arrays=[[]], + 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[ArrayTestClass] = [ + ArrayTestClass( + id="kv_int_key", + arrays=[{"k": 1, "v": "val"}], + error_code=ARRAY_TO_OBJECT_KV_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject int key in k/v form", + ), + ArrayTestClass( + id="kv_bool_key", + arrays=[{"k": True, "v": "val"}], + error_code=ARRAY_TO_OBJECT_KV_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject bool key in k/v form", + ), + ArrayTestClass( + id="kv_null_key", + arrays=[{"k": None, "v": "val"}], + error_code=ARRAY_TO_OBJECT_KV_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject null key in k/v form", + ), + ArrayTestClass( + id="kv_array_key", + arrays=[{"k": [1], "v": "val"}], + error_code=ARRAY_TO_OBJECT_KV_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject array key in k/v form", + ), + ArrayTestClass( + id="kv_object_key", + arrays=[{"k": {"x": 1}, "v": "val"}], + error_code=ARRAY_TO_OBJECT_KV_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject object key in k/v form", + ), + ArrayTestClass( + id="kv_double_key", + arrays=[{"k": 1.5, "v": "val"}], + error_code=ARRAY_TO_OBJECT_KV_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject double key in k/v form", + ), + ArrayTestClass( + id="kv_int64_key", + arrays=[{"k": Int64(1), "v": "val"}], + error_code=ARRAY_TO_OBJECT_KV_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject Int64 key in k/v form", + ), + ArrayTestClass( + id="kv_decimal128_key", + arrays=[{"k": Decimal128("1"), "v": "val"}], + error_code=ARRAY_TO_OBJECT_KV_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject Decimal128 key in k/v form", + ), + ArrayTestClass( + id="pair_int_key", + arrays=[[1, "val"]], + error_code=ARRAY_TO_OBJECT_PAIR_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject int key in pair form", + ), + ArrayTestClass( + id="pair_bool_key", + arrays=[[True, "val"]], + error_code=ARRAY_TO_OBJECT_PAIR_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject bool key in pair form", + ), + ArrayTestClass( + id="pair_null_key", + arrays=[[None, "val"]], + error_code=ARRAY_TO_OBJECT_PAIR_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject null key in pair form", + ), + ArrayTestClass( + id="pair_array_key", + arrays=[[[1], "val"]], + error_code=ARRAY_TO_OBJECT_PAIR_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject array key in pair form", + ), + ArrayTestClass( + id="pair_object_key", + arrays=[[{"x": 1}, "val"]], + error_code=ARRAY_TO_OBJECT_PAIR_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject object key in pair form", + ), + ArrayTestClass( + id="pair_double_key", + arrays=[[1.5, "val"]], + error_code=ARRAY_TO_OBJECT_PAIR_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject double key in pair form", + ), + ArrayTestClass( + id="pair_int64_key", + arrays=[[Int64(1), "val"]], + error_code=ARRAY_TO_OBJECT_PAIR_KEY_NOT_STRING_ERROR, + msg="$arrayToObject should reject Int64 key in pair form", + ), + ArrayTestClass( + id="pair_decimal128_key", + arrays=[[Decimal128("1"), "val"]], + 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[ArrayTestClass] = [ + ArrayTestClass( + id="mixed_kv_then_pair", + arrays=[{"k": "price", "v": 24}, ["item", "apple"]], + error_code=ARRAY_TO_OBJECT_MIXED_KV_THEN_PAIR_ERROR, + msg="$arrayToObject should reject a k/v doc followed by a pair", + ), + ArrayTestClass( + id="mixed_pair_then_kv", + arrays=[["item", "apple"], {"k": "price", "v": 24}], + error_code=ARRAY_TO_OBJECT_MIXED_PAIR_THEN_KV_ERROR, + msg="$arrayToObject should reject a pair followed by a k/v doc", + ), + ArrayTestClass( + id="mixed_pair_then_non_array", + arrays=[["a", 1], 123], + 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[ArrayTestClass] = [ + ArrayTestClass( + id="null_byte_in_key_pair", + arrays=[["a\x00b", "value"]], + error_code=ARRAY_TO_OBJECT_NULL_BYTE_PAIR_KEY_ERROR, + msg="$arrayToObject should reject a null byte in a key (pair form)", + ), + ArrayTestClass( + id="null_byte_in_key_kv", + arrays=[{"k": "a\x00b", "v": "value"}], + 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, {"$arrayToObject": "$arr"}, {"arr": test.arrays} + ) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +TEST_SUBSET_FOR_LITERAL = [ + NOT_ARRAY_ERROR_TESTS[0], + NOT_ARRAY_ERROR_TESTS[3], + INVALID_ELEMENT_TESTS[0], + INVALID_ELEMENT_TESTS[6], + KEY_NOT_STRING_TESTS[0], + KEY_NOT_STRING_TESTS[8], + MIXED_FORMAT_TESTS[0], +] + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_arrayToObject_literal(collection, test): + """Test $arrayToObject error cases with literal values.""" + # Use $literal for array inputs to prevent MongoDB from interpreting them as arguments + expr = {"$literal": test.arrays} if isinstance(test.arrays, list) else test.arrays + result = execute_expression(collection, {"$arrayToObject": expr}) + 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_expression_arrayToObject_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_expression_arrayToObject_expressions.py new file mode 100644 index 000000000..dad81ddab --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/arrayToObject/test_expression_arrayToObject_expressions.py @@ -0,0 +1,180 @@ +""" +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. +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. +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_expression_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_expression_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_expression_concatArrays_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_expression_concatArrays_bson_types.py new file mode 100644 index 000000000..e3036c052 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_expression_concatArrays_bson_types.py @@ -0,0 +1,218 @@ +""" +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.array.utils.array_test_case import ( # noqa: E501 + ArrayTestClass, +) +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 +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + 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[ArrayTestClass] = [ + ArrayTestClass( + id="int64_values", + arrays=[[Int64(1), Int64(2)], [Int64(3)]], + expected=[Int64(1), Int64(2), Int64(3)], + msg="$concatArrays should preserve Int64 values", + ), + ArrayTestClass( + id="decimal128_values", + arrays=[[Decimal128("1.5")], [Decimal128("2.5"), Decimal128("3.5")]], + expected=[Decimal128("1.5"), Decimal128("2.5"), Decimal128("3.5")], + msg="$concatArrays should preserve Decimal128 values", + ), + ArrayTestClass( + id="datetime_values", + arrays=[ + [datetime(2024, 1, 1, tzinfo=timezone.utc)], + [datetime(2024, 6, 1, tzinfo=timezone.utc)], + ], + expected=[ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 6, 1, tzinfo=timezone.utc), + ], + msg="$concatArrays should preserve datetime values", + ), + ArrayTestClass( + id="objectid_values", + arrays=[ + [ObjectId("000000000000000000000001")], + [ObjectId("000000000000000000000002")], + ], + expected=[ + ObjectId("000000000000000000000001"), + ObjectId("000000000000000000000002"), + ], + msg="$concatArrays should preserve ObjectId values", + ), + ArrayTestClass( + id="binary_values", + arrays=[[Binary(b"\x01", 0)], [Binary(b"\x02", 0)]], + expected=[b"\x01", b"\x02"], + msg="$concatArrays should preserve Binary values", + ), + ArrayTestClass( + id="regex_values", + arrays=[[Regex("^a", "i")], [Regex("^b", "i")]], + expected=[Regex("^a", "i"), Regex("^b", "i")], + msg="$concatArrays should preserve Regex values", + ), + ArrayTestClass( + id="timestamp_values", + arrays=[[Timestamp(1, 0)], [Timestamp(2, 0)]], + expected=[Timestamp(1, 0), Timestamp(2, 0)], + msg="$concatArrays should preserve Timestamp values", + ), + ArrayTestClass( + id="minkey_maxkey", + arrays=[[MinKey()], [MaxKey()]], + expected=[MinKey(), MaxKey()], + msg="$concatArrays should preserve MinKey/MaxKey values", + ), + ArrayTestClass( + id="uuid_values", + arrays=[ + [Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210"))], + [Binary.from_uuid(UUID("fedcba98-7654-3210-0123-456789abcdef"))], + ], + expected=[ + Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), + Binary.from_uuid(UUID("fedcba98-7654-3210-0123-456789abcdef")), + ], + msg="$concatArrays should preserve UUID binary values", + ), +] + +# Property [Mixed Types]: $concatArrays concatenates arrays holding mixed BSON element types. +MIXED_BSON_TESTS: list[ArrayTestClass] = [ + ArrayTestClass( + id="mixed_bson_types", + arrays=[[1, "two", Int64(3)], [Decimal128("4"), True, None, MinKey()]], + expected=[1, "two", Int64(3), Decimal128("4"), True, None, MinKey()], + msg="$concatArrays should concatenate mixed BSON types preserving each", + ), + ArrayTestClass( + id="mixed_dates_and_ids", + arrays=[ + [datetime(2024, 1, 1, tzinfo=timezone.utc), ObjectId("000000000000000000000001")], + [Timestamp(1, 0), Binary(b"\x01", 0)], + ], + expected=[ + datetime(2024, 1, 1, tzinfo=timezone.utc), + ObjectId("000000000000000000000001"), + Timestamp(1, 0), + b"\x01", + ], + msg="$concatArrays should concatenate dates, ObjectIds, timestamps, and binary", + ), + ArrayTestClass( + id="mixed_extremes", + arrays=[[MinKey(), FLOAT_NEGATIVE_INFINITY, None], [FLOAT_INFINITY, MaxKey()]], + 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[ArrayTestClass] = [ + ArrayTestClass( + id="infinity_values", + arrays=[[FLOAT_INFINITY], [FLOAT_NEGATIVE_INFINITY]], + expected=[FLOAT_INFINITY, FLOAT_NEGATIVE_INFINITY], + msg="$concatArrays should preserve infinity values", + ), + ArrayTestClass( + id="decimal128_infinity", + arrays=[[DECIMAL128_INFINITY], [DECIMAL128_NEGATIVE_INFINITY]], + expected=[DECIMAL128_INFINITY, DECIMAL128_NEGATIVE_INFINITY], + msg="$concatArrays should preserve Decimal128 infinity values", + ), + ArrayTestClass( + id="boundary_values", + arrays=[[INT32_MIN, INT32_MAX], [INT64_MIN, INT64_MAX]], + expected=[INT32_MIN, INT32_MAX, INT64_MIN, INT64_MAX], + msg="$concatArrays should preserve numeric boundary values", + ), + ArrayTestClass( + id="negative_zero", + arrays=[[DOUBLE_NEGATIVE_ZERO], [DECIMAL128_NEGATIVE_ZERO]], + 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[ArrayTestClass] = [ + ArrayTestClass( + id="decimal128_trailing_zeros", + arrays=[[Decimal128("1.0")], [Decimal128("1.00"), Decimal128("1.000")]], + expected=[Decimal128("1.0"), Decimal128("1.00"), Decimal128("1.000")], + msg="$concatArrays should preserve Decimal128 trailing zeros", + ), + ArrayTestClass( + id="decimal128_nan", + arrays=[[DECIMAL128_NAN], [Decimal128("1")]], + 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.""" + doc = {f"arr{i}": a for i, a in enumerate(test.arrays)} + refs = [f"$arr{i}" for i in range(len(test.arrays))] + result = execute_expression_with_insert(collection, {"$concatArrays": refs}, doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +TEST_SUBSET_FOR_LITERAL = [ + BSON_TYPE_TESTS[0], + BSON_TYPE_TESTS[4], + MIXED_BSON_TESTS[0], + SPECIAL_NUMERIC_TESTS[0], +] + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_concatArrays_bson_literal(collection, test): + """Test $concatArrays BSON types with literal values.""" + args = [{"$literal": a} if isinstance(a, list) else a for a in test.arrays] + result = execute_expression(collection, {"$concatArrays": args}) + 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_expression_concatArrays_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_expression_concatArrays_core_behavior.py new file mode 100644 index 000000000..0080c58d3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_expression_concatArrays_core_behavior.py @@ -0,0 +1,298 @@ +""" +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.array.utils.array_test_case import ( # noqa: E501 + ArrayTestClass, +) +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[ArrayTestClass] = [ + ArrayTestClass( + id="two_int_arrays", + arrays=[[1, 2], [3, 4]], + expected=[1, 2, 3, 4], + msg="$concatArrays should concatenate two int arrays", + ), + ArrayTestClass( + id="two_string_arrays", + arrays=[["a", "b"], ["c", "d"]], + expected=["a", "b", "c", "d"], + msg="$concatArrays should concatenate two string arrays", + ), + ArrayTestClass( + id="three_arrays", + arrays=[[1, 2], [3, 4], [5, 6]], + expected=[1, 2, 3, 4, 5, 6], + msg="$concatArrays should concatenate three arrays", + ), + ArrayTestClass( + id="mixed_type_elements", + arrays=[[1, "two"], [True, None, {"a": 1}]], + 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[ArrayTestClass] = [ + ArrayTestClass( + id="both_empty", + arrays=[[], []], + expected=[], + msg="$concatArrays should return empty array for two empty arrays", + ), + ArrayTestClass( + id="first_empty", + arrays=[[], [1, 2]], + expected=[1, 2], + msg="$concatArrays should return second array when first is empty", + ), + ArrayTestClass( + id="second_empty", + arrays=[[1, 2], []], + expected=[1, 2], + msg="$concatArrays should return first array when second is empty", + ), + ArrayTestClass( + id="all_empty", + arrays=[[], [], []], + expected=[], + msg="$concatArrays should return empty array for all empty inputs", + ), + ArrayTestClass( + id="no_arguments", + arrays=[], + expected=[], + msg="$concatArrays should return an empty array for no arguments", + ), + ArrayTestClass( + id="empty_between_nonempty", + arrays=[[1], [], [2]], + expected=[1, 2], + msg="$concatArrays should skip an empty array between non-empty arrays", + ), + ArrayTestClass( + id="multiple_empty", + arrays=[[], [], [], []], + 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[ArrayTestClass] = [ + ArrayTestClass( + id="single_array", + arrays=[[1, 2, 3]], + expected=[1, 2, 3], + msg="$concatArrays should return the single array unchanged", + ), + ArrayTestClass( + id="single_empty_array", + arrays=[[]], + 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[ArrayTestClass] = [ + ArrayTestClass( + id="nested_subarrays", + arrays=[[[1, 2]], [[3, 4]]], + expected=[[1, 2], [3, 4]], + msg="$concatArrays should concatenate top-level, not flatten subarrays", + ), + ArrayTestClass( + id="mixed_nested", + arrays=[[[1], "two"], [[3, 4]]], + expected=[[1], "two", [3, 4]], + msg="$concatArrays should concatenate mixed nested elements", + ), + ArrayTestClass( + id="deeply_nested", + arrays=[[[[1]]], [[[2]]]], + expected=[[[1]], [[2]]], + msg="$concatArrays should preserve deeply nested array elements", + ), + ArrayTestClass( + id="empty_nested", + arrays=[[[]], [[]]], + expected=[[], []], + msg="$concatArrays should preserve empty nested arrays as elements", + ), +] + +# Property [Duplicates]: $concatArrays keeps duplicate elements from the inputs. +DUPLICATE_TESTS: list[ArrayTestClass] = [ + ArrayTestClass( + id="duplicate_elements", + arrays=[[1, 2, 3], [2, 3, 4]], + expected=[1, 2, 3, 2, 3, 4], + msg="$concatArrays should preserve duplicate elements across arrays", + ), + ArrayTestClass( + id="identical_arrays", + arrays=[[1, 2], [1, 2]], + 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[ArrayTestClass] = [ + ArrayTestClass( + id="null_first_arg", + arrays=[None, [1, 2]], + expected=None, + msg="$concatArrays should return null when first argument is null", + ), + ArrayTestClass( + id="null_second_arg", + arrays=[[1, 2], None], + expected=None, + msg="$concatArrays should return null when second argument is null", + ), + ArrayTestClass( + id="all_null", + arrays=[None, None], + expected=None, + msg="$concatArrays should return null when all arguments are null", + ), + ArrayTestClass( + id="null_among_three", + arrays=[[1], None, [2]], + expected=None, + msg="$concatArrays should return null when any argument is null", + ), + ArrayTestClass( + id="null_elements_in_arrays", + arrays=[[1, None], [None, 2]], + 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[ArrayTestClass] = [ + ArrayTestClass( + id="arrays_of_objects", + arrays=[[{"a": 1}], [{"b": 2}]], + expected=[{"a": 1}, {"b": 2}], + msg="$concatArrays should concatenate arrays of objects", + ), + ArrayTestClass( + id="objects_with_arrays", + arrays=[[{"items": [1, 2]}], [{"items": [3, 4]}]], + 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)) + +LARGE_ARRAY_TESTS: list[ArrayTestClass] = [ + ArrayTestClass( + id="large_arrays", + arrays=[_LARGE_A, _LARGE_B], + expected=list(range(1000)), + msg="$concatArrays should concatenate large arrays", + ), + ArrayTestClass( + id="two_5000_arrays", + arrays=[list(range(5000)), list(range(5000, 10000))], + expected=list(range(10000)), + msg="$concatArrays should concatenate two large arrays into 10,000 elements", + ), + ArrayTestClass( + id="one_large_one_small", + arrays=[list(range(10000)), [10000]], + expected=list(range(10001)), + msg="$concatArrays should concatenate a large array and a small array", + ), + ArrayTestClass( + id="100_single_element_arrays", + arrays=[[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[ArrayTestClass] = [ + ArrayTestClass( + id="five_arrays", + arrays=[[1], [2], [3], [4], [5]], + expected=[1, 2, 3, 4, 5], + msg="$concatArrays should concatenate five arrays", + ), + ArrayTestClass( + id="ten_empty_arrays", + arrays=[[] for _ in range(10)], + expected=[], + msg="$concatArrays should concatenate ten empty arrays", + ), + ArrayTestClass( + id="fifty_arrays", + arrays=[[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.""" + doc = {f"arr{i}": a for i, a in enumerate(test.arrays)} + refs = [f"$arr{i}" for i in range(len(test.arrays))] + result = execute_expression_with_insert(collection, {"$concatArrays": refs}, doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +TEST_SUBSET_FOR_LITERAL = [ + BASIC_TESTS[0], + BASIC_TESTS[2], + EMPTY_TESTS[0], + SINGLE_ARRAY_TESTS[0], + NESTED_ARRAY_TESTS[0], +] + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_concatArrays_literal(collection, test): + """Test $concatArrays with literal values.""" + args = [{"$literal": a} if isinstance(a, list) else a for a in test.arrays] + result = execute_expression(collection, {"$concatArrays": args}) + 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_expression_concatArrays_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_expression_concatArrays_errors.py new file mode 100644 index 000000000..930242160 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_expression_concatArrays_errors.py @@ -0,0 +1,308 @@ +""" +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.array.utils.array_test_case import ( # noqa: E501 + ArrayTestClass, +) +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 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[ArrayTestClass] = [ + ArrayTestClass( + id="string_input", + arrays=["hello", [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject string input", + ), + ArrayTestClass( + id="int_input", + arrays=[42, [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject int input", + ), + ArrayTestClass( + id="negative_int_input", + arrays=[-42, [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject negative int input", + ), + ArrayTestClass( + id="bool_input", + arrays=[True, [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject bool input", + ), + ArrayTestClass( + id="object_input", + arrays=[{"a": 1}, [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject object input", + ), + ArrayTestClass( + id="double_input", + arrays=[3.14, [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject double input", + ), + ArrayTestClass( + id="negative_double_input", + arrays=[-3.14, [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject negative double input", + ), + ArrayTestClass( + id="decimal128_input", + arrays=[Decimal128("1"), [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject decimal128 input", + ), + ArrayTestClass( + id="int64_input", + arrays=[Int64(1), [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject int64 input", + ), + ArrayTestClass( + id="objectid_input", + arrays=[ObjectId(), [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject objectid input", + ), + ArrayTestClass( + id="datetime_input", + arrays=[datetime(2024, 1, 1, tzinfo=timezone.utc), [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject datetime input", + ), + ArrayTestClass( + id="binary_input", + arrays=[Binary(b"x", 0), [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject binary input", + ), + ArrayTestClass( + id="regex_input", + arrays=[Regex("x"), [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject regex input", + ), + ArrayTestClass( + id="maxkey_input", + arrays=[MaxKey(), [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject maxkey input", + ), + ArrayTestClass( + id="minkey_input", + arrays=[MinKey(), [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject minkey input", + ), + ArrayTestClass( + id="timestamp_input", + arrays=[Timestamp(0, 0), [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject timestamp input", + ), + ArrayTestClass( + id="non_array_second_arg", + arrays=[[1], 42], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject non-array in second position", + ), + ArrayTestClass( + id="non_array_middle_arg", + arrays=[[1], "bad", [2]], + 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[ArrayTestClass] = [ + ArrayTestClass( + id="nan_input", + arrays=[FLOAT_NAN, [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject NaN input", + ), + ArrayTestClass( + id="inf_input", + arrays=[FLOAT_INFINITY, [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject Infinity input", + ), + ArrayTestClass( + id="neg_inf_input", + arrays=[FLOAT_NEGATIVE_INFINITY, [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject -Infinity input", + ), + ArrayTestClass( + id="neg_zero_input", + arrays=[DOUBLE_NEGATIVE_ZERO, [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject negative zero input", + ), + ArrayTestClass( + id="decimal128_nan_input", + arrays=[DECIMAL128_NAN, [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject Decimal128 NaN input", + ), + ArrayTestClass( + id="decimal128_inf_input", + arrays=[DECIMAL128_INFINITY, [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject Decimal128 Infinity input", + ), + ArrayTestClass( + id="decimal128_neg_inf_input", + arrays=[DECIMAL128_NEGATIVE_INFINITY, [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject Decimal128 -Infinity input", + ), + ArrayTestClass( + id="decimal128_neg_zero_input", + arrays=[DECIMAL128_NEGATIVE_ZERO, [1]], + 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[ArrayTestClass] = [ + ArrayTestClass( + id="int32_max_input", + arrays=[INT32_MAX, [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject INT32_MAX input", + ), + ArrayTestClass( + id="int32_min_input", + arrays=[INT32_MIN, [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject INT32_MIN input", + ), + ArrayTestClass( + id="int64_max_input", + arrays=[INT64_MAX, [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject INT64_MAX input", + ), + ArrayTestClass( + id="int64_min_input", + arrays=[INT64_MIN, [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject INT64_MIN input", + ), + ArrayTestClass( + id="decimal128_max_input", + arrays=[DECIMAL128_MAX, [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject DECIMAL128_MAX input", + ), + ArrayTestClass( + id="decimal128_min_input", + arrays=[DECIMAL128_MIN, [1]], + 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[ArrayTestClass] = [ + ArrayTestClass( + id="comma_separated_string_input", + arrays=["1, 2, 3", [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject comma-separated string", + ), + ArrayTestClass( + id="json_like_string_input", + arrays=["[1, 2, 3]", [1]], + error_code=CONCAT_ARRAYS_NOT_ARRAY_ERROR, + msg="$concatArrays should reject JSON-like string", + ), + ArrayTestClass( + id="empty_object_input", + arrays=[{}, [1]], + 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.""" + doc = {f"arr{i}": a for i, a in enumerate(test.arrays)} + refs = [f"$arr{i}" for i in range(len(test.arrays))] + result = execute_expression_with_insert(collection, {"$concatArrays": refs}, doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +TEST_SUBSET_FOR_LITERAL = [ + NOT_ARRAY_ERROR_TESTS[0], + NOT_ARRAY_ERROR_TESTS[-3], + SPECIAL_NUMERIC_ERROR_TESTS[0], + BOUNDARY_ERROR_TESTS[0], +] + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_concatArrays_not_array_literal(collection, test): + """Test $concatArrays error with non-array literal input.""" + args = [{"$literal": a} if isinstance(a, list) else a for a in test.arrays] + result = execute_expression(collection, {"$concatArrays": args}) + 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_expression_concatArrays_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_expression_concatArrays_expressions.py new file mode 100644 index 000000000..3a7154c51 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/concatArrays/test_expression_concatArrays_expressions.py @@ -0,0 +1,327 @@ +""" +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. +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_expression_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_expression_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/array/utils/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/utils/array_test_case.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/utils/array_test_case.py new file mode 100644 index 000000000..7279ae3c1 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/utils/array_test_case.py @@ -0,0 +1,24 @@ +""" +Shared test case for array expression operator tests. + +Used across the $arrayElemAt, $arrayToObject, and $concatArrays test files. +""" + +from dataclasses import dataclass +from typing import Any + +from documentdb_tests.framework.test_case import BaseTestCase + + +@dataclass(frozen=True) +class ArrayTestClass(BaseTestCase): + """Test case for array expression operators. + + Attributes: + idx: An index argument (e.g. $arrayElemAt). + arrays: The array input. Holds a single array for $arrayElemAt and + $arrayToObject, or a list of arrays for $concatArrays. + """ + + idx: Any = None + arrays: Any = None 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..5916a8e97 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_array_arrayElemAt_indexOfArray_in_slice.py @@ -0,0 +1,384 @@ +""" +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. +""" + +from dataclasses import dataclass +from typing import Any + +import pytest +from bson import Decimal128, MaxKey, MinKey, Regex + +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 +from documentdb_tests.framework.test_case import BaseTestCase + + +@dataclass(frozen=True) +class CombinationTest(BaseTestCase): + """Test case for combination expression tests.""" + + expr: Any = None + + +# --------------------------------------------------------------------------- +# $arrayElemAt combinations +# --------------------------------------------------------------------------- +ARRAY_ELEM_AT_COMBINATION_TESTS: list[CombinationTest] = [ + CombinationTest( + id="arrayElemAt_index_from_indexOfArray", + expr={"$arrayElemAt": [[10, 20, 30], {"$indexOfArray": [[10, 20, 30], 30]}]}, + expected=30, + msg="Should use $indexOfArray result as index", + ), + CombinationTest( + id="arrayElemAt_last_element_via_size", + expr={"$arrayElemAt": [[10, 20, 30], {"$subtract": [{"$size": [[10, 20, 30]]}, 1]}]}, + expected=30, + msg="Should access last element via $size - 1", + ), + CombinationTest( + id="arrayElemAt_elem_from_slice", + expr={"$arrayElemAt": [{"$slice": [[10, 20, 30, 40], -2]}, 0]}, + expected=30, + msg="Should access element from $slice result", + ), + CombinationTest( + id="arrayElemAt_elem_from_slice_3arg", + expr={"$arrayElemAt": [{"$slice": [[10, 20, 30, 40], 1, 2]}, 1]}, + expected=30, + msg="Should access element from $slice 3-arg result", + ), + CombinationTest( + id="arrayElemAt_elem_from_reverseArray", + expr={"$arrayElemAt": [{"$reverseArray": [[10, 20, 30]]}, 0]}, + expected=30, + msg="Should access element from $reverseArray result", + ), + CombinationTest( + id="arrayElemAt_elem_from_concatArrays", + expr={"$arrayElemAt": [{"$concatArrays": [[10, 20], [30, 40]]}, 2]}, + expected=30, + msg="Should access element from $concatArrays result", + ), + CombinationTest( + id="arrayElemAt_computed_index", + expr={"$arrayElemAt": [[10, 20, 30], {"$subtract": [3, 1]}]}, + expected=30, + msg="Should use computed index from $subtract", + ), +] + +# --------------------------------------------------------------------------- +# $in combinations +# --------------------------------------------------------------------------- +IN_COMBINATION_TESTS: list[CombinationTest] = [ + CombinationTest( + id="in_value_from_add", + expr={"$in": [{"$add": [1, 1]}, [1, 2, 3]]}, + expected=True, + msg="Should find value computed by $add", + ), + CombinationTest( + id="in_array_from_concatArrays", + expr={"$in": [3, {"$concatArrays": [[1, 2], [3, 4]]}]}, + expected=True, + msg="Should search in $concatArrays result", + ), + CombinationTest( + id="in_value_from_arrayElemAt", + expr={"$in": [{"$arrayElemAt": [[10, 20, 30], 1]}, [5, 20, 35]]}, + expected=True, + msg="Should find value from $arrayElemAt", + ), + CombinationTest( + id="in_array_from_filter", + expr={ + "$in": [ + 4, + { + "$filter": { + "input": [1, 2, 3, 4, 5], + "as": "n", + "cond": {"$gte": ["$$n", 3]}, + } + }, + ] + }, + expected=True, + msg="Should search in $filter result", + ), + CombinationTest( + id="in_array_from_map", + expr={ + "$in": [ + 20, + { + "$map": { + "input": [1, 2, 3], + "as": "n", + "in": {"$multiply": ["$$n", 10]}, + } + }, + ] + }, + expected=True, + msg="Should search in $map result", + ), + CombinationTest( + id="in_array_from_reverseArray", + expr={"$in": [1, {"$reverseArray": [[1, 2, 3]]}]}, + expected=True, + msg="Should search in $reverseArray result", + ), + CombinationTest( + id="in_cond_with_inner_in", + expr={"$in": [5, {"$cond": [{"$in": ["a", ["a", "b"]]}, [5, 6], [7, 8]]}]}, + expected=True, + msg="Should search in $cond-selected array", + ), + CombinationTest( + id="in_inside_cond", + expr={"$cond": [{"$in": [2, [1, 2, 3]]}, "found", "not_found"]}, + expected="found", + msg="Should use $in result in $cond", + ), + CombinationTest( + id="in_value_from_indexOfArray", + expr={"$in": [{"$indexOfArray": [[10, 20, 30], 20]}, [0, 1, 2]]}, + expected=True, + msg="Should find $indexOfArray result in array", + ), + CombinationTest( + id="in_nested_decimal128", + expr={ + "$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", + ), +] + +# --------------------------------------------------------------------------- +# $indexOfArray combinations +# --------------------------------------------------------------------------- +INDEX_OF_ARRAY_COMBINATION_TESTS: list[CombinationTest] = [ + CombinationTest( + id="indexOfArray_result_as_arrayElemAt_index", + expr={"$arrayElemAt": [[10, 20, 30], {"$indexOfArray": [[10, 20, 30], 20]}]}, + expected=20, + msg="Should use $indexOfArray result as $arrayElemAt index", + ), + CombinationTest( + id="indexOfArray_search_from_add", + expr={"$indexOfArray": [[1, 2, 3], {"$add": [1, 1]}]}, + expected=1, + msg="Should search for value computed by $add", + ), + CombinationTest( + id="indexOfArray_array_from_concatArrays", + expr={"$indexOfArray": [{"$concatArrays": [[1, 2], [3, 4]]}, 3]}, + expected=2, + msg="Should search in $concatArrays result", + ), + CombinationTest( + id="indexOfArray_array_from_filter", + expr={ + "$indexOfArray": [ + {"$filter": {"input": [1, 2, 3, 4, 5], "cond": {"$gt": ["$$this", 2]}}}, + 4, + ] + }, + expected=1, + msg="Should search in $filter result", + ), + CombinationTest( + id="indexOfArray_result_in_cond", + expr={"$cond": [{"$gte": [{"$indexOfArray": [[1, 2, 3], 2]}, 0]}, "found", "not_found"]}, + expected="found", + msg="Should use $indexOfArray result in $cond", + ), + CombinationTest( + id="indexOfArray_start_from_subtract", + expr={"$indexOfArray": [[1, 2, 1, 2], 1, {"$subtract": [3, 1]}]}, + expected=2, + msg="Should use $subtract result as start index", + ), + CombinationTest( + id="indexOfArray_via_arrayElemAt", + expr={ + "$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", + ), + CombinationTest( + id="indexOfArray_subarray_mixed_bson", + expr={ + "$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", + ), + CombinationTest( + id="indexOfArray_triple_nested_decimal128", + expr={ + "$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", + ), +] + +# --------------------------------------------------------------------------- +# $slice combinations +# --------------------------------------------------------------------------- +SLICE_COMBINATION_TESTS: list[CombinationTest] = [ + CombinationTest( + id="slice_array_from_concatArrays", + expr={"$slice": [{"$concatArrays": [[1, 2], [3, 4, 5]]}, 3]}, + expected=[1, 2, 3], + msg="Should slice $concatArrays result", + ), + CombinationTest( + id="slice_n_from_subtract", + expr={"$slice": [[1, 2, 3, 4, 5], {"$subtract": [5, 2]}]}, + expected=[1, 2, 3], + msg="Should use $subtract result as n", + ), + CombinationTest( + id="slice_array_from_filter", + expr={ + "$slice": [ + { + "$filter": { + "input": [1, 2, 3, 4, 5], + "as": "n", + "cond": {"$gte": ["$$n", 3]}, + } + }, + 2, + ] + }, + expected=[3, 4], + msg="Should slice $filter result", + ), + CombinationTest( + id="slice_position_from_indexOfArray", + expr={ + "$slice": [ + [10, 20, 30, 40, 50], + {"$indexOfArray": [[10, 20, 30, 40, 50], 30]}, + 2, + ] + }, + expected=[30, 40], + msg="Should use $indexOfArray result as position", + ), + CombinationTest( + id="slice_array_from_map", + expr={ + "$slice": [ + { + "$map": { + "input": [1, 2, 3], + "as": "n", + "in": {"$multiply": ["$$n", 10]}, + } + }, + 2, + ] + }, + expected=[10, 20], + msg="Should slice $map result", + ), + CombinationTest( + id="slice_array_from_reverseArray", + expr={"$slice": [{"$reverseArray": [[1, 2, 3, 4, 5]]}, 3]}, + expected=[5, 4, 3], + msg="Should slice $reverseArray result", + ), + CombinationTest( + id="slice_n_from_size", + expr={"$slice": [[10, 20, 30, 40], {"$subtract": [{"$size": [[10, 20, 30, 40]]}, 1]}]}, + expected=[10, 20, 30], + msg="Should use $size-based computation as n", + ), +] + +# --------------------------------------------------------------------------- +# Aggregate all combination tests +# --------------------------------------------------------------------------- +ALL_COMBINATION_TESTS = ( + ARRAY_ELEM_AT_COMBINATION_TESTS + + IN_COMBINATION_TESTS + + INDEX_OF_ARRAY_COMBINATION_TESTS + + SLICE_COMBINATION_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.expr) + assertResult(result, expected=[{"result": test.expected}], msg=test.msg) + + +# --------------------------------------------------------------------------- +# Standalone tests for behavior that doesn't fit the dataclass pattern +# --------------------------------------------------------------------------- +def test_arrayElemAt_oob_is_missing_not_null(collection): + """Test out-of-bounds result is truly MISSING (field absent), not null.""" + result = execute_expression(collection, {"$type": {"$arrayElemAt": [[1, 2, 3], 10]}}) + assertResult( + result, + expected=[{"result": "missing"}], + msg="Out-of-bounds $arrayElemAt should produce missing field, not null", + ) + + +def test_arrayElemAt_regex_type_preserved(collection): + """Test $arrayElemAt preserves regex element type.""" + result = execute_expression(collection, {"$type": {"$arrayElemAt": [[Regex("abc")], 0]}}) + assertResult( + result, + expected=[{"result": "regex"}], + msg="$arrayElemAt should preserve regex element type", + ) 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)