From 7105a684b35ecc90671b31fdb0939861b1b80539 Mon Sep 17 00:00:00 2001 From: "Victor [C] Tsang" Date: Fri, 24 Jul 2026 18:07:26 +0000 Subject: [PATCH 1/4] Add expression array tests for $objectToArray, $reverseArray, $sortArray Co-authored-by: Leszek Kurzyna Signed-off-by: Victor [C] Tsang --- .../array/objectToArray/__init__.py | 0 ...est_expression_objectToArray_bson_types.py | 334 ++++++++++ ..._expression_objectToArray_core_behavior.py | 279 ++++++++ .../test_expression_objectToArray_errors.py | 221 +++++++ ...st_expression_objectToArray_expressions.py | 244 +++++++ .../array/reverseArray/__init__.py | 0 ...test_expression_reverseArray_bson_types.py | 221 +++++++ ...t_expression_reverseArray_core_behavior.py | 226 +++++++ .../test_expression_reverseArray_errors.py | 344 ++++++++++ ...est_expression_reverseArray_expressions.py | 325 ++++++++++ .../expressions/array/sortArray/__init__.py | 0 .../test_expression_sortArray_bson_types.py | 555 ++++++++++++++++ ...test_expression_sortArray_core_behavior.py | 524 ++++++++++++++++ .../test_expression_sortArray_errors.py | 593 ++++++++++++++++++ .../test_expression_sortArray_expressions.py | 270 ++++++++ ...t_expressions_combination_objectToArray.py | 75 +++ ...st_expressions_combination_reverseArray.py | 88 +++ .../test_expressions_combination_sortArray.py | 111 ++++ 18 files changed, 4410 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_bson_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/reverseArray/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/reverseArray/test_expression_reverseArray_bson_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/reverseArray/test_expression_reverseArray_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/reverseArray/test_expression_reverseArray_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/reverseArray/test_expression_reverseArray_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_bson_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_objectToArray.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_reverseArray.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_sortArray.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_bson_types.py new file mode 100644 index 000000000..975728b61 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_bson_types.py @@ -0,0 +1,334 @@ +""" +BSON type tests for $objectToArray expression. + +Tests that various BSON value types are preserved when converting +objects to k/v arrays, including special numeric values, boundary values, +UUID binary, and nested BSON values. +""" + +from datetime import datetime, timezone +from uuid import UUID + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + 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 [Literal-path parity]: representative cases also run through the +# literal-value path, defined by name (not positional index) and appended to +# ALL_BSON_TESTS below for insert coverage too. Also holds the sole mixed-types +# case. +TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="value_int64", + doc={"obj": {"a": Int64(99)}}, + expected=[{"k": "a", "v": Int64(99)}], + msg="Should preserve Int64 value", + ), + ExpressionTestCase( + id="value_binary", + doc={"obj": {"a": Binary(b"\x01\x02", 0)}}, + expected=[{"k": "a", "v": b"\x01\x02"}], + msg="Should preserve Binary value", + ), + ExpressionTestCase( + id="value_uuid", + doc={"obj": {"a": Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210"))}}, + expected=[{"k": "a", "v": Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210"))}], + msg="Should preserve UUID binary value", + ), + ExpressionTestCase( + id="value_infinity", + doc={"obj": {"a": FLOAT_INFINITY}}, + expected=[{"k": "a", "v": FLOAT_INFINITY}], + msg="Should preserve Infinity value", + ), + ExpressionTestCase( + id="value_int32_max", + doc={"obj": {"a": INT32_MAX}}, + expected=[{"k": "a", "v": INT32_MAX}], + msg="Should preserve INT32_MAX value", + ), + ExpressionTestCase( + id="nested_bson_in_object_value", + doc={"obj": {"a": {"x": Int64(1), "y": Decimal128("2.5")}}}, + expected=[{"k": "a", "v": {"x": Int64(1), "y": Decimal128("2.5")}}], + msg="Should preserve nested BSON types in object value", + ), + ExpressionTestCase( + id="mixed_bson_types", + doc={ + "obj": { + "int64": Int64(1), + "dec": Decimal128("1.5"), + "dt": datetime(2024, 1, 1, tzinfo=timezone.utc), + "oid": ObjectId("000000000000000000000001"), + "bin": Binary(b"\x01", 0), + "ts": Timestamp(0, 0), + "min": MinKey(), + } + }, + expected=[ + {"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": b"\x01"}, + {"k": "ts", "v": Timestamp(0, 0)}, + {"k": "min", "v": MinKey()}, + ], + msg="Should preserve multiple mixed BSON types in one conversion", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_objectToArray_bson_literal(collection, test): + """Test $objectToArray BSON types with literal values.""" + result = execute_expression(collection, {"$objectToArray": test.doc["obj"]}) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +# Property [BSON type preservation]: each non-object BSON value type survives +# the object→array conversion unchanged — no coercion, widening, or precision +# loss (e.g. Binary subtype 0 decodes to bytes, UUID binary round-trips exactly). +BSON_VALUE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="value_decimal128", + doc={"obj": {"a": Decimal128("3.14")}}, + expected=[{"k": "a", "v": Decimal128("3.14")}], + msg="Should preserve Decimal128 value", + ), + ExpressionTestCase( + id="value_datetime", + doc={"obj": {"a": datetime(2024, 1, 1, tzinfo=timezone.utc)}}, + expected=[{"k": "a", "v": datetime(2024, 1, 1, tzinfo=timezone.utc)}], + msg="Should preserve datetime value", + ), + ExpressionTestCase( + id="value_objectid", + doc={"obj": {"a": ObjectId("000000000000000000000001")}}, + expected=[{"k": "a", "v": ObjectId("000000000000000000000001")}], + msg="Should preserve ObjectId value", + ), + ExpressionTestCase( + id="value_bool_false", + doc={"obj": {"a": False}}, + expected=[{"k": "a", "v": False}], + msg="Should preserve false value", + ), + ExpressionTestCase( + id="value_bool_true", + doc={"obj": {"a": True}}, + expected=[{"k": "a", "v": True}], + msg="Should preserve true value", + ), + ExpressionTestCase( + id="value_regex", + doc={"obj": {"a": Regex("^abc", "i")}}, + expected=[{"k": "a", "v": Regex("^abc", "i")}], + msg="Should preserve regex value", + ), + ExpressionTestCase( + id="value_minkey", + doc={"obj": {"a": MinKey()}}, + expected=[{"k": "a", "v": MinKey()}], + msg="Should preserve MinKey value", + ), + ExpressionTestCase( + id="value_maxkey", + doc={"obj": {"a": MaxKey()}}, + expected=[{"k": "a", "v": MaxKey()}], + msg="Should preserve MaxKey value", + ), + ExpressionTestCase( + id="value_timestamp", + doc={"obj": {"a": Timestamp(1234567890, 1)}}, + expected=[{"k": "a", "v": Timestamp(1234567890, 1)}], + msg="Should preserve Timestamp value", + ), + ExpressionTestCase( + id="value_code", + doc={"obj": {"a": Code("x")}}, + expected=[{"k": "a", "v": Code("x")}], + msg="Should preserve JavaScript Code value", + ), +] + +# Property [Special numeric values]: IEEE-754/Decimal128 special values +# (±Infinity, ±0, NaN, full precision, exponent/trailing-zero notation, +# subnormal zero) pass through without normalization. +SPECIAL_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="value_neg_infinity", + doc={"obj": {"a": FLOAT_NEGATIVE_INFINITY}}, + expected=[{"k": "a", "v": FLOAT_NEGATIVE_INFINITY}], + msg="Should preserve -Infinity value", + ), + ExpressionTestCase( + id="value_neg_zero", + doc={"obj": {"a": DOUBLE_NEGATIVE_ZERO}}, + expected=[{"k": "a", "v": DOUBLE_NEGATIVE_ZERO}], + msg="Should preserve negative zero value", + ), + ExpressionTestCase( + id="value_decimal128_nan", + doc={"obj": {"a": DECIMAL128_NAN}}, + expected=[{"k": "a", "v": DECIMAL128_NAN}], + msg="Should preserve Decimal128 NaN value", + ), + ExpressionTestCase( + id="value_decimal128_infinity", + doc={"obj": {"a": DECIMAL128_INFINITY}}, + expected=[{"k": "a", "v": DECIMAL128_INFINITY}], + msg="Should preserve Decimal128 Infinity value", + ), + ExpressionTestCase( + id="value_decimal128_neg_infinity", + doc={"obj": {"a": DECIMAL128_NEGATIVE_INFINITY}}, + expected=[{"k": "a", "v": DECIMAL128_NEGATIVE_INFINITY}], + msg="Should preserve Decimal128 -Infinity value", + ), + ExpressionTestCase( + id="value_decimal128_neg_zero", + doc={"obj": {"a": DECIMAL128_NEGATIVE_ZERO}}, + expected=[{"k": "a", "v": DECIMAL128_NEGATIVE_ZERO}], + msg="Should preserve Decimal128 -0 value", + ), + ExpressionTestCase( + id="value_decimal128_high_precision", + doc={"obj": {"a": Decimal128("1.234567890123456789012345678901234")}}, + expected=[{"k": "a", "v": Decimal128("1.234567890123456789012345678901234")}], + msg="Should preserve full Decimal128 precision", + ), + ExpressionTestCase( + id="value_decimal128_zero_exponent", + doc={"obj": {"a": Decimal128("0E+10")}}, + expected=[{"k": "a", "v": Decimal128("0E+10")}], + msg="Should preserve Decimal128 exponent notation", + ), + ExpressionTestCase( + id="value_decimal128_trailing_zeros", + doc={"obj": {"a": Decimal128("1.00000")}}, + expected=[{"k": "a", "v": Decimal128("1.00000")}], + msg="Should preserve Decimal128 trailing zeros", + ), + ExpressionTestCase( + id="value_decimal128_subnormal_zero", + doc={"obj": {"a": Decimal128("0E-6176")}}, + expected=[{"k": "a", "v": Decimal128("0E-6176")}], + msg="Should preserve Decimal128 subnormal zero", + ), +] + +# Property [Boundary values]: min/max values for each numeric BSON type +# (Int32, Int64, Decimal128) are preserved exactly at the boundary. +BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="value_int32_min", + doc={"obj": {"a": INT32_MIN}}, + expected=[{"k": "a", "v": INT32_MIN}], + msg="Should preserve INT32_MIN value", + ), + ExpressionTestCase( + id="value_int64_max", + doc={"obj": {"a": INT64_MAX}}, + expected=[{"k": "a", "v": INT64_MAX}], + msg="Should preserve INT64_MAX value", + ), + ExpressionTestCase( + id="value_int64_min", + doc={"obj": {"a": INT64_MIN}}, + expected=[{"k": "a", "v": INT64_MIN}], + msg="Should preserve INT64_MIN value", + ), + ExpressionTestCase( + id="value_decimal128_max", + doc={"obj": {"a": DECIMAL128_MAX}}, + expected=[{"k": "a", "v": DECIMAL128_MAX}], + msg="Should preserve DECIMAL128_MAX value", + ), + ExpressionTestCase( + id="value_decimal128_min", + doc={"obj": {"a": DECIMAL128_MIN}}, + expected=[{"k": "a", "v": DECIMAL128_MIN}], + msg="Should preserve DECIMAL128_MIN value", + ), +] + +# Property [Nested BSON types]: BSON-typed values nested inside an object or +# array value (not just at the top level) retain their exact type through the +# conversion, at any nesting depth. +NESTED_BSON_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="nested_bson_in_array_value", + doc={ + "obj": { + "a": [ + MinKey(), + datetime(2024, 1, 1, tzinfo=timezone.utc), + ObjectId("000000000000000000000001"), + ] + } + }, + expected=[ + { + "k": "a", + "v": [ + MinKey(), + datetime(2024, 1, 1, tzinfo=timezone.utc), + ObjectId("000000000000000000000001"), + ], + } + ], + msg="Should preserve nested BSON types in array value", + ), + ExpressionTestCase( + id="deeply_nested_bson", + doc={"obj": {"a": {"x": [{"y": Decimal128("1.5")}, Timestamp(0, 0)]}}}, + expected=[{"k": "a", "v": {"x": [{"y": Decimal128("1.5")}, Timestamp(0, 0)]}}], + msg="Should preserve deeply nested BSON types", + ), +] + +ALL_BSON_TESTS = ( + BSON_VALUE_TESTS + + SPECIAL_NUMERIC_TESTS + + BOUNDARY_TESTS + + NESTED_BSON_TESTS + + TEST_SUBSET_FOR_LITERAL +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_BSON_TESTS)) +def test_objectToArray_bson_insert(collection, test): + """Test $objectToArray BSON types with values from inserted documents.""" + result = execute_expression_with_insert(collection, {"$objectToArray": "$obj"}, 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/objectToArray/test_expression_objectToArray_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_core_behavior.py new file mode 100644 index 000000000..84221bf77 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_core_behavior.py @@ -0,0 +1,279 @@ +""" +Core behavior tests for $objectToArray expression. + +Tests conversion of objects to k/v arrays, empty and null objects, special +key names, non-recursive behavior, field order preservation (including +stability across a prior $addFields stage), case sensitivity, and null value +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, + execute_expression_with_insert, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.lazy_payload import materialize +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Literal-path parity]: representative cases from each group below +# also run through the literal-value path (not just via inserted documents). +# Defined here directly (not by positional index into the groups below) so the +# mapping is name-stable, and appended to ALL_TESTS below so they also get +# insert coverage. +TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="single_field", + doc={"obj": {"a": 1}}, + expected=[{"k": "a", "v": 1}], + msg="Should convert single-field object", + ), + ExpressionTestCase( + id="mixed_value_types", + doc={"obj": {"int": 1, "str": "hello", "bool": True, "null": None}}, + expected=[ + {"k": "int", "v": 1}, + {"k": "str", "v": "hello"}, + {"k": "bool", "v": True}, + {"k": "null", "v": None}, + ], + msg="Should convert object with mixed value types", + ), + ExpressionTestCase( + id="empty_object", + doc={"obj": {}}, + expected=[], + msg="Should return empty array for empty object", + ), + ExpressionTestCase( + id="null_object", + doc={"obj": None}, + expected=None, + msg="Should return null for null object", + ), + ExpressionTestCase( + id="deeply_nested_not_recursive", + doc={"obj": {"a": {"b": {"c": {"d": 1}}}}}, + expected=[{"k": "a", "v": {"b": {"c": {"d": 1}}}}], + msg="Should NOT recursively decompose nested docs", + ), + ExpressionTestCase( + id="case_sensitive_keys", + doc={"obj": {"a": 1, "A": 2}}, + expected=[{"k": "a", "v": 1}, {"k": "A", "v": 2}], + msg="Case-different keys should be distinct", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_objectToArray_literal(collection, test): + """Test $objectToArray with literal values.""" + result = execute_expression(collection, {"$objectToArray": test.doc["obj"]}) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +# Property [Basic conversion]: a plain object converts to an array of {k, v} +# pairs, preserving each field's value type (string, nested object, array). +BASIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="multiple_fields", + doc={"obj": {"a": 1, "b": 2, "c": 3}}, + expected=[{"k": "a", "v": 1}, {"k": "b", "v": 2}, {"k": "c", "v": 3}], + msg="Should convert multi-field object", + ), + ExpressionTestCase( + id="string_values", + doc={"obj": {"name": "Alice", "city": "Mycity"}}, + expected=[{"k": "name", "v": "Alice"}, {"k": "city", "v": "Mycity"}], + msg="Should convert object with string values", + ), + ExpressionTestCase( + id="nested_object_value", + doc={"obj": {"obj": {"x": 1, "y": 2}}}, + expected=[{"k": "obj", "v": {"x": 1, "y": 2}}], + msg="Should convert object with nested object value", + ), + ExpressionTestCase( + id="array_value", + doc={"obj": {"arr": [1, 2, 3]}}, + expected=[{"k": "arr", "v": [1, 2, 3]}], + msg="Should convert object with array value", + ), + ExpressionTestCase( + id="empty_array_value", + doc={"obj": {"a": []}}, + expected=[{"k": "a", "v": []}], + msg="Should convert object with an empty-array value", + ), +] + +# Property [Key preservation]: every key shape (empty, dotted, unicode, +# dollar-prefixed, spaced, numeric-looking) is carried into `k` verbatim as a +# string, with no special-casing or reinterpretation. +SPECIAL_KEY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="empty_string_key", + doc={"obj": {"": 1}}, + expected=[{"k": "", "v": 1}], + msg="Should handle empty string key", + ), + ExpressionTestCase( + id="dotted_key", + doc={"obj": {"a.b.c": 1}}, + expected=[{"k": "a.b.c", "v": 1}], + msg="Should handle dotted key", + ), + ExpressionTestCase( + id="unicode_key", + doc={"obj": {"日本語": 1}}, + expected=[{"k": "日本語", "v": 1}], + msg="Should handle unicode key", + ), + ExpressionTestCase( + id="dollar_sign_key", + doc={"obj": {"$field": 1}}, + expected=[{"k": "$field", "v": 1}], + msg="Should handle dollar-sign key", + ), + ExpressionTestCase( + id="key_with_spaces", + doc={"obj": {"my key": 1}}, + expected=[{"k": "my key", "v": 1}], + msg="Should handle key with spaces", + ), + ExpressionTestCase( + id="numeric_string_keys", + doc={"obj": {"0": "a", "1": "b"}}, + expected=[{"k": "0", "v": "a"}, {"k": "1", "v": "b"}], + msg="Should handle numeric-looking keys as strings", + ), +] + +# Property [Non-recursive]: only the top-level object is decomposed; nested +# objects, arrays, and values that themselves look like a k/v pair are kept +# intact as the `v` payload rather than being expanded or reinterpreted. +NON_RECURSIVE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="value_looks_like_kv_pair", + doc={"obj": {"a": {"k": "x", "v": 1}}}, + expected=[{"k": "a", "v": {"k": "x", "v": 1}}], + msg="Should NOT interpret value as pre-existing k/v pair", + ), + ExpressionTestCase( + id="nested_array_with_object", + doc={"obj": {"k1": [1, {"a": {"b": 1}}]}}, + expected=[{"k": "k1", "v": [1, {"a": {"b": 1}}]}], + msg="Should preserve nested array containing objects", + ), +] + +# Property [Order, case, and null preservation]: field order (including 10+ +# fields) matches document field order, case-different keys stay distinct, and +# null values are preserved rather than omitted. +EDGE_CASE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="preserves_field_order", + doc={"obj": {"qty": 25, "item": "abc123"}}, + expected=[{"k": "qty", "v": 25}, {"k": "item", "v": "abc123"}], + msg="Should preserve field order", + ), + ExpressionTestCase( + id="many_fields_order", + doc={"obj": {f"field_{i}": i for i in range(12)}}, + expected=[{"k": f"field_{i}", "v": i} for i in range(12)], + msg="Should preserve order for 10+ fields", + ), + ExpressionTestCase( + id="null_value_in_object", + doc={"obj": {"a": None, "b": 1}}, + expected=[{"k": "a", "v": None}, {"k": "b", "v": 1}], + msg="Should preserve null values", + ), + ExpressionTestCase( + id="all_null_values", + doc={"obj": {"a": None, "b": None}}, + expected=[{"k": "a", "v": None}, {"k": "b", "v": None}], + msg="Should preserve all null values", + ), + ExpressionTestCase( + id="long_key_name", + doc={"obj": {"k" * 1000: 1}}, + expected=[{"k": "k" * 1000, "v": 1}], + msg="Should preserve 1000-char key name", + ), +] + +ALL_TESTS = ( + BASIC_TESTS + + SPECIAL_KEY_TESTS + + NON_RECURSIVE_TESTS + + EDGE_CASE_TESTS + + TEST_SUBSET_FOR_LITERAL +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_objectToArray_insert(collection, test): + """Test $objectToArray with values from inserted documents.""" + result = execute_expression_with_insert(collection, {"$objectToArray": "$obj"}, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +def _run_objectToArray_after_addFields(collection, doc, add_fields): + """Insert doc, apply $addFields, then $objectToArray over $$ROOT.""" + collection.insert_one(materialize(doc) or {}) + return execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$addFields": materialize(add_fields)}, + {"$project": {"_id": 0, "result": {"$objectToArray": "$$ROOT"}}}, + ], + "cursor": {}, + }, + ) + + +def test_objectToArray_addFields_new_field_placed_last(collection): + """A field added by a prior $addFields stage appears last in the output (manual B18).""" + result = _run_objectToArray_after_addFields( + collection, doc={"_id": 1, "a": 1, "b": 2}, add_fields={"c": 99} + ) + assert_expression_result( + result, + expected=[ + {"k": "_id", "v": 1}, + {"k": "a", "v": 1}, + {"k": "b", "v": 2}, + {"k": "c", "v": 99}, + ], + msg="Field added by $addFields should appear last in $objectToArray output", + ) + + +def test_objectToArray_addFields_existing_field_keeps_position(collection): + """Updating an existing field via $addFields keeps its original position (manual B18).""" + result = _run_objectToArray_after_addFields( + collection, doc={"_id": 2, "a": 1, "b": 2, "c": 3}, add_fields={"a": 100} + ) + assert_expression_result( + result, + expected=[ + {"k": "_id", "v": 2}, + {"k": "a", "v": 100}, + {"k": "b", "v": 2}, + {"k": "c", "v": 3}, + ], + msg="Updating an existing field via $addFields should keep its original position", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_errors.py new file mode 100644 index 000000000..182342712 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_errors.py @@ -0,0 +1,221 @@ +""" +Error tests for $objectToArray expression. + +Tests non-object input (all BSON types), wrong arity, and non-object input +resolved via field-path/expression (e.g. composite array paths). +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + EXPRESSION_TYPE_MISMATCH_ERROR, + OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + FLOAT_NAN, +) + +# Property [Literal-path parity]: representative non-object rejections also +# run through the literal-value path (not just via inserted documents). +# Defined here directly (not by positional index into NOT_OBJECT_ERROR_TESTS +# below) so the mapping is name-stable, and appended to the insert list below +# so they also get insert coverage. +TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="string_input", + doc={"obj": "hello"}, + error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, + msg="Should reject string input", + ), + ExpressionTestCase( + id="int_input", + doc={"obj": 42}, + error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, + msg="Should reject int input", + ), + ExpressionTestCase( + id="timestamp_input", + doc={"obj": Timestamp(0, 0)}, + error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, + msg="Should reject timestamp input", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_objectToArray_not_object_literal(collection, test): + """Test $objectToArray error cases with literal values.""" + result = execute_expression(collection, {"$objectToArray": test.doc["obj"]}) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +# Property [Non-object rejection]: every non-object BSON type (scalar, array, +# and edge values like empty string/array, false, NaN) is rejected with +# OBJECT_TO_ARRAY_NOT_OBJECT_ERROR (40390) — no type is silently coerced. +NOT_OBJECT_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="double_input", + doc={"obj": 3.14}, + error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, + msg="Should reject double input", + ), + ExpressionTestCase( + id="bool_input", + doc={"obj": True}, + error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, + msg="Should reject bool input", + ), + ExpressionTestCase( + id="array_input", + doc={"obj": [1, 2, 3]}, + error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, + msg="Should reject array input", + ), + ExpressionTestCase( + id="decimal128_input", + doc={"obj": Decimal128("1")}, + error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, + msg="Should reject decimal128 input", + ), + ExpressionTestCase( + id="int64_input", + doc={"obj": Int64(1)}, + error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, + msg="Should reject int64 input", + ), + ExpressionTestCase( + id="objectid_input", + doc={"obj": ObjectId()}, + error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, + msg="Should reject objectid input", + ), + ExpressionTestCase( + id="datetime_input", + doc={"obj": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, + msg="Should reject datetime input", + ), + ExpressionTestCase( + id="binary_input", + doc={"obj": Binary(b"x", 0)}, + error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, + msg="Should reject binary input", + ), + ExpressionTestCase( + id="regex_input", + doc={"obj": Regex("x")}, + error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, + msg="Should reject regex input", + ), + ExpressionTestCase( + id="maxkey_input", + doc={"obj": MaxKey()}, + error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, + msg="Should reject maxkey input", + ), + ExpressionTestCase( + id="minkey_input", + doc={"obj": MinKey()}, + error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, + msg="Should reject minkey input", + ), + ExpressionTestCase( + id="nan_input", + doc={"obj": FLOAT_NAN}, + error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, + msg="Should reject NaN input", + ), + ExpressionTestCase( + id="bool_false_input", + doc={"obj": False}, + error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, + msg="Should reject bool false input", + ), + ExpressionTestCase( + id="empty_array_input", + doc={"obj": []}, + error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, + msg="Should reject empty array input", + ), + ExpressionTestCase( + id="empty_string_input", + doc={"obj": ""}, + error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, + msg="Should reject empty string input", + ), + ExpressionTestCase( + id="decimal128_nan_input", + doc={"obj": DECIMAL128_NAN}, + error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, + msg="Should reject Decimal128 NaN input", + ), + ExpressionTestCase( + id="decimal128_infinity_input", + doc={"obj": DECIMAL128_INFINITY}, + error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, + msg="Should reject Decimal128 Infinity input", + ), + ExpressionTestCase( + id="decimal128_neg_infinity_input", + doc={"obj": DECIMAL128_NEGATIVE_INFINITY}, + error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, + msg="Should reject Decimal128 -Infinity input", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(NOT_OBJECT_ERROR_TESTS + TEST_SUBSET_FOR_LITERAL)) +def test_objectToArray_not_object_insert(collection, test): + """Test $objectToArray error cases with values from inserted documents.""" + result = execute_expression_with_insert(collection, {"$objectToArray": "$obj"}, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +def test_objectToArray_two_args_error(collection): + """Test $objectToArray errors with two arguments.""" + result = execute_expression(collection, {"$objectToArray": [{"a": 1}, {"b": 2}]}) + assert_expression_result(result, error_code=EXPRESSION_TYPE_MISMATCH_ERROR) + + +def test_objectToArray_zero_args_error(collection): + """Test $objectToArray errors with zero arguments. + + A literal empty argument array is treated as zero arguments (16020), which + is distinct from an empty array *value* resolved via a field path (40390, + OBJECT_TO_ARRAY_NOT_OBJECT_ERROR — see empty_array_input above). + """ + result = execute_expression(collection, {"$objectToArray": []}) + assert_expression_result(result, error_code=EXPRESSION_TYPE_MISMATCH_ERROR) + + +def test_objectToArray_composite_array_path_error(collection): + """Test $objectToArray error case from field-path/expression resolution.""" + result = execute_expression_with_insert( + collection, + {"$objectToArray": "$a.b"}, + {"a": [{"b": {"x": 1}}, {"b": {"y": 2}}]}, + ) + assert_expression_result( + result, + error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, + msg="Composite array path should resolve to non-object", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_expressions.py new file mode 100644 index 000000000..7f0415128 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_expressions.py @@ -0,0 +1,244 @@ +""" +Expression and field path tests for $objectToArray expression. + +Tests field path lookups (including nested paths), nested object values, +$let variables, system variables ($$ROOT/$$CURRENT), null/missing field +handling, field references inside literal objects, expression-type operands +($mergeObjects / $literal / computed-value objects), algebraic properties +($arrayToObject round-trip inverse and output-length invariant via $size), and +BSON type distinction (k always string; v subtypes not collapsed) via $map/$type. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Field-path operand]: the operand may be a (possibly nested) field +# path, not just a literal object; the object it resolves to is converted the +# same way as a literal. +FIELD_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="nested_field_path", + expression={"$objectToArray": "$a.b"}, + doc={"a": {"b": {"x": 1}}}, + expected=[{"k": "x", "v": 1}], + msg="Should resolve nested field path", + ), + ExpressionTestCase( + id="deeply_nested_field", + expression={"$objectToArray": "$a.b.c"}, + doc={"a": {"b": {"c": {"x": 1}}}}, + expected=[{"k": "x", "v": 1}], + msg="Should resolve deeply nested field path", + ), +] + +# Property [$let variable operand]: the operand may be a $let-bound variable +# referencing an object, resolved before conversion. +LET_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="let_variable", + expression={"$let": {"vars": {"obj": "$obj"}, "in": {"$objectToArray": "$$obj"}}}, + doc={"obj": {"a": 1, "b": 2}}, + expected=[{"k": "a", "v": 1}, {"k": "b", "v": 2}], + msg="Should convert $let variable object", + ), +] + +# Property [System variable operand]: the operand may be a system variable +# ($$ROOT for the whole document including _id, $$CURRENT for the field-path +# equivalent of the current document) rather than a plain field path. +SYSTEM_VAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="root_variable", + expression={"$objectToArray": "$$ROOT"}, + doc={"_id": 4, "a": 1, "b": 2, "c": 3}, + expected=[{"k": "_id", "v": 4}, {"k": "a", "v": 1}, {"k": "b", "v": 2}, {"k": "c", "v": 3}], + msg="$$ROOT should include all fields including _id", + ), + ExpressionTestCase( + id="current_variable_field_path", + expression={"$objectToArray": "$$CURRENT.obj"}, + doc={"_id": 5, "obj": {"a": 1, "b": 2}}, + expected=[{"k": "a", "v": 1}, {"k": "b", "v": 2}], + msg="$$CURRENT. should resolve like the field path $", + ), +] + +# Property [Null/missing operand]: a missing field path or a null-valued +# operand returns null rather than an error or an empty array. +NULL_MISSING_EXPR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="missing_field", + expression={"$objectToArray": "$nonexistent"}, + doc={"other": 1}, + expected=None, + msg="Missing field should return null", + ), + ExpressionTestCase( + id="null_type_check", + expression={"$type": {"$objectToArray": "$obj"}}, + doc={"obj": None}, + expected="null", + msg="Type of null input result should be 'null'", + ), +] + +# Property [Field references inside a literal object operand]: field paths +# nested as values within a literal object operand are resolved before +# conversion; a reference to a missing field omits that key from the result. +FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="literal_object_with_field_ref", + expression={"$objectToArray": {"key1": "$a"}}, + doc={"a": 1}, + expected=[{"k": "key1", "v": 1}], + msg="Should resolve field reference in literal object", + ), + ExpressionTestCase( + id="literal_object_with_missing_field_ref", + expression={"$objectToArray": {"key1": "$t"}}, + doc={"a": 1}, + expected=[], + msg="Missing field reference in literal object should exclude field", + ), +] + +# Property [Algebraic]: round-trip inverse ($arrayToObject undoes +# $objectToArray) and output-length invariant (one element per top-level field). +ALGEBRAIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="roundtrip_single_field", + expression={"$arrayToObject": {"$objectToArray": "$obj"}}, + doc={"obj": {"only": 5}}, + expected={"only": 5}, + msg="Round-trip should preserve a single-field object", + ), + ExpressionTestCase( + id="roundtrip_empty_object", + expression={"$arrayToObject": {"$objectToArray": "$obj"}}, + doc={"obj": {}}, + expected={}, + msg="Round-trip should preserve an empty object", + ), + ExpressionTestCase( + id="size_zero_fields", + expression={"$size": {"$objectToArray": "$obj"}}, + doc={"obj": {}}, + expected=0, + msg="Output length should equal the number of top-level fields (0)", + ), + ExpressionTestCase( + id="size_one_field", + expression={"$size": {"$objectToArray": "$obj"}}, + doc={"obj": {"a": 1}}, + expected=1, + msg="Output length should equal the number of top-level fields (1)", + ), + ExpressionTestCase( + id="size_five_fields", + expression={"$size": {"$objectToArray": "$obj"}}, + doc={"obj": {"a": 1, "b": 2, "c": 3, "d": 4, "e": 5}}, + expected=5, + msg="Output length should equal the number of top-level fields (5)", + ), +] + +# Property [Expression-type operand]: the operand may be produced by an +# expression operator, a $literal wrapper, or an object with computed values +# (Rule 3), not just a plain literal object or field path. +EXPRESSION_OPERAND_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="operand_from_mergeObjects", + expression={"$objectToArray": {"$mergeObjects": [{"a": 1}, {"b": 2}]}}, + doc={"x": 0}, + expected=[{"k": "a", "v": 1}, {"k": "b", "v": 2}], + msg="Should accept operand produced by $mergeObjects", + ), + ExpressionTestCase( + id="operand_literal_object", + expression={"$objectToArray": {"$literal": {"a": 1, "b": 2}}}, + doc={"x": 0}, + expected=[{"k": "a", "v": 1}, {"k": "b", "v": 2}], + msg="Should accept a $literal-wrapped object operand", + ), + ExpressionTestCase( + id="operand_object_computed_values", + expression={"$objectToArray": {"a": {"$add": [1, 2]}, "b": "$x"}}, + doc={"x": 9}, + expected=[{"k": "a", "v": 3}, {"k": "b", "v": 9}], + msg="Should resolve computed values inside an object operand", + ), +] + +# Property [BSON type distinction] (Rule 11): k is always a string; v +# preserves the source BSON type without collapsing numerically-equal subtypes. +BSON_DISTINCTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="k_is_always_string", + expression={ + "$map": { + "input": {"$objectToArray": "$obj"}, + "as": "kv", + "in": {"$type": "$$kv.k"}, + } + }, + doc={"obj": {"a": 1, "0": 2, "$x": 3}}, + expected=["string", "string", "string"], + msg="Every k in the output should be BSON type string", + ), + ExpressionTestCase( + id="v_numeric_subtypes_not_collapsed", + expression={ + "$map": { + "input": {"$objectToArray": "$obj"}, + "as": "kv", + "in": {"$type": "$$kv.v"}, + } + }, + doc={"obj": {"a": 1, "b": 1.0, "c": Int64(1), "d": Decimal128("1")}}, + expected=["int", "double", "long", "decimal"], + msg="Numerically-equal values retain distinct BSON subtypes (not collapsed)", + ), + ExpressionTestCase( + id="v_empty_string_vs_null_distinct", + expression={ + "$map": { + "input": {"$objectToArray": "$obj"}, + "as": "kv", + "in": {"$type": "$$kv.v"}, + } + }, + doc={"obj": {"a": "", "b": None}}, + expected=["string", "null"], + msg="Empty string and null values remain distinct types", + ), +] + +ALL_EXPR_TESTS = ( + FIELD_LOOKUP_TESTS + + LET_TESTS + + SYSTEM_VAR_TESTS + + NULL_MISSING_EXPR_TESTS + + FIELD_REF_TESTS + + ALGEBRAIC_TESTS + + EXPRESSION_OPERAND_TESTS + + BSON_DISTINCTION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_EXPR_TESTS)) +def test_objectToArray_expression(collection, test): + """Test $objectToArray 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/reverseArray/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reverseArray/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/reverseArray/test_expression_reverseArray_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reverseArray/test_expression_reverseArray_bson_types.py new file mode 100644 index 000000000..0ba7a2ee1 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reverseArray/test_expression_reverseArray_bson_types.py @@ -0,0 +1,221 @@ +""" +BSON type element preservation tests for $reverseArray expression. + +Tests that various BSON types are preserved when reversing arrays, +including special numeric values and boundary values. +""" + +from datetime import datetime, timezone +from uuid import UUID + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + 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 [Literal-path parity]: representative BSON-type cases also run +# through the literal-value path (not just via inserted documents). Defined +# here directly (not by positional index into the groups below) so the +# mapping is name-stable, and appended to ALL_TESTS below so they also get +# insert coverage. +TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="int64_values", + doc={"arr": [Int64(1), Int64(2), Int64(3)]}, + expected=[Int64(3), Int64(2), Int64(1)], + msg="Should preserve Int64 values", + ), + ExpressionTestCase( + id="binary_values", + doc={"arr": [Binary(b"\x01", 0), Binary(b"\x02", 0)]}, + expected=[b"\x02", b"\x01"], + msg="Should preserve Binary values", + ), + ExpressionTestCase( + id="mixed_bson_types", + doc={"arr": [1, "two", Int64(3), Decimal128("4"), True, None, MinKey()]}, + expected=[MinKey(), None, True, Decimal128("4"), Int64(3), "two", 1], + msg="Should reverse mixed BSON types preserving each", + ), + ExpressionTestCase( + id="infinity_values", + doc={"arr": [FLOAT_NEGATIVE_INFINITY, 0, FLOAT_INFINITY]}, + expected=[FLOAT_INFINITY, 0, FLOAT_NEGATIVE_INFINITY], + msg="Should preserve infinity values", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_reverseArray_bson_literal(collection, test): + """Test $reverseArray BSON types with literal values.""" + result = execute_expression(collection, {"$reverseArray": {"$literal": test.doc["arr"]}}) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +# Property [BSON type preservation]: each BSON value type survives reversal +# unchanged — no coercion, widening, or precision loss (e.g. Binary subtype +# and UUID binary round-trip exactly, Regex flags are preserved). +BSON_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="decimal128_values", + doc={"arr": [Decimal128("1.5"), Decimal128("2.5"), Decimal128("3.5")]}, + expected=[Decimal128("3.5"), Decimal128("2.5"), Decimal128("1.5")], + msg="Should preserve Decimal128 values", + ), + ExpressionTestCase( + id="datetime_values", + doc={ + "arr": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 6, 1, tzinfo=timezone.utc), + datetime(2024, 12, 1, tzinfo=timezone.utc), + ] + }, + expected=[ + datetime(2024, 12, 1, tzinfo=timezone.utc), + datetime(2024, 6, 1, tzinfo=timezone.utc), + datetime(2024, 1, 1, tzinfo=timezone.utc), + ], + msg="Should preserve datetime values", + ), + ExpressionTestCase( + id="objectid_values", + doc={ + "arr": [ + ObjectId("000000000000000000000001"), + ObjectId("000000000000000000000002"), + ObjectId("000000000000000000000003"), + ] + }, + expected=[ + ObjectId("000000000000000000000003"), + ObjectId("000000000000000000000002"), + ObjectId("000000000000000000000001"), + ], + msg="Should preserve ObjectId values", + ), + ExpressionTestCase( + id="binary_subtype_preservation", + doc={"arr": [Binary(b"\x01", 128), Binary(b"\x02", 128)]}, + expected=[Binary(b"\x02", 128), Binary(b"\x01", 128)], + msg="Should preserve Binary subtype", + ), + ExpressionTestCase( + id="regex_values", + doc={"arr": [Regex("^a", "i"), Regex("^b", "i")]}, + expected=[Regex("^b", "i"), Regex("^a", "i")], + msg="Should preserve Regex values", + ), + ExpressionTestCase( + id="timestamp_values", + doc={"arr": [Timestamp(1, 0), Timestamp(2, 0), Timestamp(3, 0)]}, + expected=[Timestamp(3, 0), Timestamp(2, 0), Timestamp(1, 0)], + msg="Should preserve Timestamp values", + ), + ExpressionTestCase( + id="minkey_maxkey", + doc={"arr": [MinKey(), MaxKey()]}, + expected=[MaxKey(), MinKey()], + msg="Should preserve MinKey/MaxKey values", + ), + ExpressionTestCase( + id="uuid_values", + doc={ + "arr": [ + Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), + Binary.from_uuid(UUID("fedcba98-7654-3210-0123-456789abcdef")), + ] + }, + expected=[ + Binary.from_uuid(UUID("fedcba98-7654-3210-0123-456789abcdef")), + Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), + ], + msg="Should preserve UUID binary values", + ), +] + +# Property [Special numeric value preservation]: IEEE-754/Decimal128 special +# values (±Infinity) and numeric boundary values (Int32/Int64 min/max) pass +# through reversal without normalization, precision loss, or overflow. +SPECIAL_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="decimal128_infinity", + doc={"arr": [DECIMAL128_NEGATIVE_INFINITY, Decimal128("0"), DECIMAL128_INFINITY]}, + expected=[DECIMAL128_INFINITY, Decimal128("0"), DECIMAL128_NEGATIVE_INFINITY], + msg="Should preserve Decimal128 infinity values", + ), + ExpressionTestCase( + id="boundary_values", + doc={"arr": [INT32_MIN, INT32_MAX, INT64_MIN, INT64_MAX]}, + expected=[INT64_MAX, INT64_MIN, INT32_MAX, INT32_MIN], + msg="Should preserve numeric boundary values", + ), +] + +# Property [Element-level numeric edge preservation]: subtle numeric +# representations (Decimal128 trailing zeros, double/Decimal128 negative +# zero, Decimal128 NaN) as individual array elements survive reversal without +# normalization or collapsing distinct representations. +ELEMENT_PRESERVATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="decimal128_trailing_zeros", + doc={"arr": [Decimal128("1.0"), Decimal128("1.00"), Decimal128("1.000")]}, + expected=[Decimal128("1.000"), Decimal128("1.00"), Decimal128("1.0")], + msg="Decimal128 trailing zeros preserved", + ), + ExpressionTestCase( + id="double_negative_zero", + doc={"arr": [1, DOUBLE_NEGATIVE_ZERO, -1]}, + expected=[-1, DOUBLE_NEGATIVE_ZERO, 1], + msg="Double negative zero preserved", + ), + ExpressionTestCase( + id="decimal128_negative_zero", + doc={"arr": [Decimal128("1"), DECIMAL128_NEGATIVE_ZERO]}, + expected=[DECIMAL128_NEGATIVE_ZERO, Decimal128("1")], + msg="Decimal128 negative zero preserved", + ), + ExpressionTestCase( + id="decimal128_nan_element", + doc={"arr": [DECIMAL128_NAN, Decimal128("1")]}, + expected=[Decimal128("1"), DECIMAL128_NAN], + msg="Decimal128 NaN element preserved", + ), +] + +ALL_TESTS = ( + BSON_TYPE_TESTS + SPECIAL_NUMERIC_TESTS + ELEMENT_PRESERVATION_TESTS + TEST_SUBSET_FOR_LITERAL +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_reverseArray_bson_insert(collection, test): + """Test $reverseArray BSON types with values from inserted documents.""" + result = execute_expression_with_insert(collection, {"$reverseArray": "$arr"}, 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/reverseArray/test_expression_reverseArray_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reverseArray/test_expression_reverseArray_core_behavior.py new file mode 100644 index 000000000..501997687 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reverseArray/test_expression_reverseArray_core_behavior.py @@ -0,0 +1,226 @@ +""" +Core behavior tests for $reverseArray expression. + +Tests reversing arrays of various element types, empty arrays, +single elements, nested arrays (top-level only), duplicates, +large arrays, null input and null-element handling, and element/object +integrity preservation. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Literal-path parity]: representative cases from each group below +# also run through the literal-value path (not just via inserted documents). +# Defined here directly (not by positional index into the groups below) so +# the mapping is name-stable, and appended to ALL_TESTS below so they also +# get insert coverage. +TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="ints", + doc={"arr": [1, 2, 3]}, + expected=[3, 2, 1], + msg="Should reverse int array", + ), + ExpressionTestCase( + id="strings", + doc={"arr": ["a", "b", "c"]}, + expected=["c", "b", "a"], + msg="Should reverse string array", + ), + ExpressionTestCase( + id="empty_array", + doc={"arr": []}, + expected=[], + msg="Should return empty array for empty input", + ), + ExpressionTestCase( + id="nested_arrays", + doc={"arr": [[1, 2, 3], [4, 5, 6]]}, + expected=[[4, 5, 6], [1, 2, 3]], + msg="Should reverse top-level only, not subarrays", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_reverseArray_literal(collection, test): + """Test $reverseArray with literal values.""" + result = execute_expression(collection, {"$reverseArray": {"$literal": test.doc["arr"]}}) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +# Property [Basic reversal]: arrays of each common scalar/object element type +# (int, string, double, boolean, object, mixed numeric) are reversed in order, +# with each element's value and type preserved. +BASIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="doubles", + doc={"arr": [1.1, 2.2, 3.3]}, + expected=[3.3, 2.2, 1.1], + msg="Should reverse double array", + ), + ExpressionTestCase( + id="booleans", + doc={"arr": [True, True, False]}, + expected=[False, True, True], + msg="Should reverse boolean array", + ), + ExpressionTestCase( + id="objects", + doc={"arr": [{"a": 1}, {"b": 2}, {"c": 3}]}, + expected=[{"c": 3}, {"b": 2}, {"a": 1}], + msg="Should reverse array of objects", + ), + ExpressionTestCase( + id="numeric_cross_types", + doc={"arr": [1, Int64(2), 3.0, Decimal128("4")]}, + expected=[Decimal128("4"), 3.0, Int64(2), 1], + msg="Should reverse mixed numeric types", + ), +] + +# Property [Degenerate lengths]: arrays of length 0, 1, and 2 are handled +# correctly by the reversal (no swap loop, no-op single element, single swap). +DEGENERATE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="single_element", + doc={"arr": [42]}, + expected=[42], + msg="Should return single element unchanged", + ), + ExpressionTestCase( + id="two_elements", + doc={"arr": [1, 2]}, + expected=[2, 1], + msg="Should swap two elements", + ), +] + +# Property [Top-level only]: reversal only reorders the outer array; nested +# arrays and mixed nested elements keep their own element order untouched. +NESTED_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="nested_mixed", + doc={"arr": [[1], "two", [3, 4]]}, + expected=[[3, 4], "two", [1]], + msg="Should reverse top-level with mixed nested", + ), + ExpressionTestCase( + id="deeply_nested", + doc={"arr": [[[1, 2]], [[3, 4]]]}, + expected=[[[3, 4]], [[1, 2]]], + msg="Should reverse top-level of deeply nested arrays", + ), +] + +# Property [Duplicate/palindrome invariance]: arrays with repeated values or +# palindromic symmetry are reversed by position, so their visible content is +# unchanged even though the underlying operation still ran. +DUPLICATE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="all_same", + doc={"arr": [5, 5, 5]}, + expected=[5, 5, 5], + msg="Should handle all identical values", + ), + ExpressionTestCase( + id="palindrome", + doc={"arr": [1, 2, 3, 2, 1]}, + expected=[1, 2, 3, 2, 1], + msg="Palindrome array should be unchanged", + ), +] + +_LARGE = list(range(1000)) + +# Property [Scales to large arrays]: reversal correctness holds for an array +# large enough (1000 elements) to rule out small-input-only implementations. +LARGE_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="large_array", + doc={"arr": _LARGE}, + expected=list(reversed(_LARGE)), + msg="Should reverse large array", + ), +] + +# Property [Null propagation]: a null input returns null (rather than an +# error), and null elements inside an array are preserved like any other +# element through the reversal. +NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="null_input", + doc={"arr": None}, + expected=None, + msg="Should return null for null input", + ), + ExpressionTestCase( + id="array_with_nulls", + doc={"arr": [1, None, 3]}, + expected=[3, None, 1], + msg="Null elements preserved", + ), + ExpressionTestCase( + id="array_all_nulls", + doc={"arr": [None, None]}, + expected=[None, None], + msg="All null array reversed", + ), +] + +# Property [Element integrity]: reordering elements never mutates them — +# object field order/keys and embedded inner arrays inside each element are +# byte-for-byte identical to the original, just relocated. +ELEMENT_PRESERVATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="object_field_order", + doc={"arr": [{"a": 1, "b": 2}, {"c": 3, "d": 4}]}, + expected=[{"c": 3, "d": 4}, {"a": 1, "b": 2}], + msg="Object field order preserved", + ), + ExpressionTestCase( + id="embedded_docs_with_arrays", + doc={"arr": [{"items": [1, 2]}, {"items": [3, 4]}]}, + expected=[{"items": [3, 4]}, {"items": [1, 2]}], + msg="Inner arrays in docs not reversed", + ), + ExpressionTestCase( + id="array_of_objects", + doc={"arr": [{"name": "a", "val": 1}, {"name": "b", "val": 2}, {"name": "c", "val": 3}]}, + expected=[{"name": "c", "val": 3}, {"name": "b", "val": 2}, {"name": "a", "val": 1}], + msg="Object integrity preserved", + ), +] + +ALL_TESTS = ( + BASIC_TESTS + + DEGENERATE_TESTS + + NESTED_ARRAY_TESTS + + DUPLICATE_TESTS + + LARGE_ARRAY_TESTS + + NULL_TESTS + + ELEMENT_PRESERVATION_TESTS + + TEST_SUBSET_FOR_LITERAL +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_reverseArray_insert(collection, test): + """Test $reverseArray with values from inserted documents.""" + result = execute_expression_with_insert(collection, {"$reverseArray": "$arr"}, 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/reverseArray/test_expression_reverseArray_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reverseArray/test_expression_reverseArray_errors.py new file mode 100644 index 000000000..8a615edec --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reverseArray/test_expression_reverseArray_errors.py @@ -0,0 +1,344 @@ +""" +Error tests for $reverseArray expression. + +Tests non-array input (all BSON types, special numeric values, boundary values, +string edge cases), wrong arity, and literal-wrapped non-array resolution. +Note: unlike $size, $reverseArray propagates null — null/missing field-path +tests are in test_expression_reverseArray_expressions.py. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + EXPRESSION_TYPE_MISMATCH_ERROR, + REVERSE_ARRAY_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 [Literal-path parity]: representative non-array rejections also run +# through the literal-value path (not just via inserted documents). Defined +# here directly (not by positional index into the groups below) so the +# mapping is name-stable, and appended to ALL_TESTS below so they also get +# insert coverage. +TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="string_input", + doc={"arr": "hello"}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject string input", + ), + ExpressionTestCase( + id="timestamp_input", + doc={"arr": Timestamp(0, 0)}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject timestamp input", + ), + ExpressionTestCase( + id="nan_input", + doc={"arr": FLOAT_NAN}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject NaN input", + ), + ExpressionTestCase( + id="int32_max_input", + doc={"arr": INT32_MAX}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject INT32_MAX input", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_reverseArray_not_array_literal(collection, test): + """Test $reverseArray error with non-array literal input.""" + result = execute_expression(collection, {"$reverseArray": test.doc["arr"]}) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +# Property [Non-array rejection]: every non-array BSON type (scalar, object, +# and edge values like empty object/negative numbers) is rejected with +# REVERSE_ARRAY_NOT_ARRAY_ERROR — no type is silently coerced or unwrapped. +NOT_ARRAY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="int_input", + doc={"arr": 42}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject int input", + ), + ExpressionTestCase( + id="negative_int_input", + doc={"arr": -42}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject negative int input", + ), + ExpressionTestCase( + id="bool_input", + doc={"arr": True}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject bool input", + ), + ExpressionTestCase( + id="object_input", + doc={"arr": {"a": 1}}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject object input", + ), + ExpressionTestCase( + id="empty_object_input", + doc={"arr": {}}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject empty object input (contrast with [] which succeeds)", + ), + ExpressionTestCase( + id="double_input", + doc={"arr": 3.14}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject double input", + ), + ExpressionTestCase( + id="negative_double_input", + doc={"arr": -3.14}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject negative double input", + ), + ExpressionTestCase( + id="decimal128_input", + doc={"arr": Decimal128("1")}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject decimal128 input", + ), + ExpressionTestCase( + id="int64_input", + doc={"arr": Int64(1)}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject int64 input", + ), + ExpressionTestCase( + id="objectid_input", + doc={"arr": ObjectId()}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject objectid input", + ), + ExpressionTestCase( + id="datetime_input", + doc={"arr": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject datetime input", + ), + ExpressionTestCase( + id="binary_input", + doc={"arr": Binary(b"x", 0)}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject binary input", + ), + ExpressionTestCase( + id="regex_input", + doc={"arr": Regex("x")}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject regex input", + ), + ExpressionTestCase( + id="maxkey_input", + doc={"arr": MaxKey()}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject maxkey input", + ), + ExpressionTestCase( + id="minkey_input", + doc={"arr": MinKey()}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject minkey input", + ), +] + +# Property [Special numeric non-array rejection]: IEEE-754/Decimal128 special +# numeric values (±Infinity, ±0, NaN) are still scalars, not arrays, and are +# rejected the same as any other non-array input. +SPECIAL_NUMERIC_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="inf_input", + doc={"arr": FLOAT_INFINITY}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject Infinity input", + ), + ExpressionTestCase( + id="neg_inf_input", + doc={"arr": FLOAT_NEGATIVE_INFINITY}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject -Infinity input", + ), + ExpressionTestCase( + id="neg_zero_input", + doc={"arr": DOUBLE_NEGATIVE_ZERO}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject negative zero input", + ), + ExpressionTestCase( + id="decimal128_nan_input", + doc={"arr": DECIMAL128_NAN}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject Decimal128 NaN input", + ), + ExpressionTestCase( + id="decimal128_neg_nan_input", + doc={"arr": Decimal128("-NaN")}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject Decimal128 -NaN input", + ), + ExpressionTestCase( + id="decimal128_inf_input", + doc={"arr": DECIMAL128_INFINITY}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject Decimal128 Infinity input", + ), + ExpressionTestCase( + id="decimal128_neg_inf_input", + doc={"arr": DECIMAL128_NEGATIVE_INFINITY}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject Decimal128 -Infinity input", + ), + ExpressionTestCase( + id="decimal128_neg_zero_input", + doc={"arr": DECIMAL128_NEGATIVE_ZERO}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject Decimal128 -0 input", + ), +] + +# Property [Boundary-value non-array rejection]: min/max values for each +# numeric BSON type (Int32, Int64, Decimal128) are rejected the same as any +# other non-array scalar — the not-array check has no numeric-magnitude +# special-casing at the boundaries. +BOUNDARY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="int32_min_input", + doc={"arr": INT32_MIN}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject INT32_MIN input", + ), + ExpressionTestCase( + id="int64_max_input", + doc={"arr": INT64_MAX}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject INT64_MAX input", + ), + ExpressionTestCase( + id="int64_min_input", + doc={"arr": INT64_MIN}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject INT64_MIN input", + ), + ExpressionTestCase( + id="decimal128_max_input", + doc={"arr": DECIMAL128_MAX}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject DECIMAL128_MAX input", + ), + ExpressionTestCase( + id="decimal128_min_input", + doc={"arr": DECIMAL128_MIN}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject DECIMAL128_MIN input", + ), +] + +# Property [String-shape non-array rejection]: strings that merely look like +# array syntax (comma-separated or JSON array text) are still the string BSON +# type and are rejected, not parsed or reinterpreted as an array. +STRING_EDGE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="comma_separated_string_input", + doc={"arr": "1, 2, 3"}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject comma-separated string", + ), + ExpressionTestCase( + id="json_like_string_input", + doc={"arr": "[1, 2, 3]"}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject JSON-like string", + ), +] + +ALL_TESTS = ( + NOT_ARRAY_ERROR_TESTS + + SPECIAL_NUMERIC_ERROR_TESTS + + BOUNDARY_ERROR_TESTS + + STRING_EDGE_ERROR_TESTS + + TEST_SUBSET_FOR_LITERAL +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_reverseArray_not_array_insert(collection, test): + """Test $reverseArray error with non-array input from inserted documents.""" + result = execute_expression_with_insert(collection, {"$reverseArray": "$arr"}, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +ARITY_ERROR_TESTS = [ + pytest.param({"$reverseArray": [[], []]}, id="two_args"), + pytest.param({"$reverseArray": ["$a", "$b", "$c"]}, id="three_args"), +] + + +@pytest.mark.parametrize("expr", ARITY_ERROR_TESTS) +def test_reverseArray_arity_error(collection, expr): + """Test $reverseArray errors with wrong number of arguments.""" + result = execute_expression(collection, expr) + assert_expression_result(result, error_code=EXPRESSION_TYPE_MISMATCH_ERROR) + + +# Property [Expression-resolved non-array rejection]: an operand built by +# wrapping a non-array field reference in an array literal (e.g. [$a] where +# a=1) still resolves to a non-array value once evaluated, and is rejected +# like any other non-array input. +EXPRESSION_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="field_ref_wrapped_non_array", + expression={"$reverseArray": ["$a"]}, + doc={"a": 1}, + error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, + msg="[$a] where a=1 resolves to int, not array", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(EXPRESSION_ERROR_TESTS)) +def test_reverseArray_expression_error(collection, test): + """Test $reverseArray error cases from field-path/expression resolution.""" + 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/reverseArray/test_expression_reverseArray_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reverseArray/test_expression_reverseArray_expressions.py new file mode 100644 index 000000000..51bbb79a9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reverseArray/test_expression_reverseArray_expressions.py @@ -0,0 +1,325 @@ +""" +Expression and field path tests for $reverseArray expression. + +Tests field path lookups (including composite array-of-objects paths), +$let and system variables ($$ROOT/$$CURRENT/$$REMOVE), null/missing field +handling, self-composition (nested $reverseArray), expression-operator +operands (Rule 3), algebraic invariants (length and multiset preservation, +Rule 17), the array return-type assertion (Rule 1), and float NaN element +preservation. +""" + +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 +from documentdb_tests.framework.test_constants import FLOAT_NAN + +# Property [Field-path operand]: the operand may be a (possibly nested) field +# path rather than a literal array; a path segment that doesn't resolve to a +# value returns null instead of erroring. +FIELD_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="nested_field_path", + expression={"$reverseArray": "$a.b"}, + doc={"a": {"b": [1, 2, 3]}}, + expected=[3, 2, 1], + msg="Should resolve nested field path", + ), + ExpressionTestCase( + id="deeply_nested_field", + expression={"$reverseArray": "$a.b.c"}, + doc={"a": {"b": {"c": [10, 20, 30]}}}, + expected=[30, 20, 10], + msg="Should resolve deeply nested field path", + ), + ExpressionTestCase( + id="nonexistent_field_null", + expression={"$reverseArray": "$a.nonexistent"}, + doc={"a": {"missing": 1}}, + expected=None, + msg="Non-existent field should return null", + ), +] + +# Property [Composite/positional path resolution]: field paths that traverse +# array-of-objects (composite paths) or numeric-looking path segments resolve +# per the engine's path semantics (object key vs. array index vs. no match) +# before being reversed, distinct from actual indexing via $arrayElemAt. +COMPOSITE_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="composite_array", + expression={"$reverseArray": "$x.y"}, + doc={"x": [{"y": 10}, {"y": 20}, {"y": 30}]}, + expected=[30, 20, 10], + msg="Composite array path from array-of-objects", + ), + ExpressionTestCase( + id="composite_array_path_nested", + expression={"$reverseArray": "$a.b"}, + doc={"a": [{"b": [1, 2]}, {"b": [3, 4]}]}, + expected=[[3, 4], [1, 2]], + msg="Composite array path reverses", + ), + ExpressionTestCase( + id="array_index_on_object_key", + expression={"$reverseArray": "$a.0.b"}, + doc={"a": {"0": {"b": [1, 2, 3]}}}, + expected=[3, 2, 1], + msg="Numeric key on object resolves correctly", + ), + ExpressionTestCase( + id="object_key_zero", + expression={"$reverseArray": "$a.0"}, + doc={"a": {"0": [1, 2, 3]}}, + expected=[3, 2, 1], + msg="$a.0 resolves as field named '0' on object", + ), + ExpressionTestCase( + id="numeric_index_on_array", + expression={"$reverseArray": "$a.0"}, + doc={"a": [[3, 2, 1], [6, 5, 4]]}, + expected=[], + msg="Numeric index $a.0 on array-of-arrays resolves to empty", + ), + ExpressionTestCase( + id="arrayElemAt_index_on_array", + expression={"$reverseArray": {"$arrayElemAt": ["$arr", 0]}}, + doc={"arr": [[1, 2], [3, 4]]}, + expected=[2, 1], + msg="Contrast with numeric_index_on_array: $arrayElemAt actually indexes" + " (unlike the $a.0 path segment) and returns the element to reverse", + ), +] + +# Property [$let and system-variable operand]: the operand may be a +# $let-bound variable or a system variable ($$ROOT/$$CURRENT), resolved to its +# underlying array the same way a plain field path would be. +LET_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="let_field_ref", + expression={"$let": {"vars": {"myArr": "$arr"}, "in": {"$reverseArray": "$$myArr"}}}, + doc={"arr": [1, 2]}, + expected=[2, 1], + msg="$let with field reference", + ), + ExpressionTestCase( + id="root_variable", + expression={"$reverseArray": "$$ROOT.values"}, + doc={"_id": 1, "values": [1, 2, 3]}, + expected=[3, 2, 1], + msg="Should work with $$ROOT", + ), + ExpressionTestCase( + id="current_variable", + expression={"$reverseArray": "$$CURRENT.values"}, + doc={"_id": 2, "values": [1, 2, 3]}, + expected=[3, 2, 1], + msg="$$CURRENT should be equivalent to field path", + ), +] + +# Property [Null/missing operand via expression resolution]: a missing field, +# $$REMOVE, or an array literal built from field references (rather than a +# plain field path) each resolve correctly before reversal — null propagates, +# and constructed array literals are reversed like any other array. +NULL_MISSING_EXPR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="missing_field", + expression={"$reverseArray": "$nonexistent"}, + doc={"other": 1}, + expected=None, + msg="Missing field should return null", + ), + ExpressionTestCase( + id="missing_input_type_is_null", + expression={"$type": {"$reverseArray": "$nonexistent"}}, + doc={"x": 1}, + expected="null", + msg="Missing field should produce null type", + ), + ExpressionTestCase( + id="remove_variable", + expression={"$reverseArray": "$$REMOVE"}, + doc={"x": 1}, + expected=None, + msg="$$REMOVE returns null", + ), + ExpressionTestCase( + id="field_ref_wrapped_array", + expression={"$reverseArray": ["$arr"]}, + doc={"arr": [1, 2, 3]}, + expected=[3, 2, 1], + msg="[$arr] where arr is array resolves correctly", + ), + ExpressionTestCase( + id="array_of_field_refs", + expression={"$reverseArray": [["$x", "$y"]]}, + doc={"x": 1, "y": 2}, + expected=[2, 1], + msg="Array literal built from multiple field refs, then reversed", + ), +] + +# Property [Self-composition]: $reverseArray may be nested inside itself; +# applying it twice is the identity transform (reverse undoes reverse). +SELF_COMPOSITION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="double_reverse", + expression={"$reverseArray": {"$reverseArray": "$arr"}}, + doc={"arr": [1, 2, 3]}, + expected=[1, 2, 3], + msg="Double reverse = identity", + ), +] + +# Property [Expression-operator operand] (Rule 3): the operand may be the +# output of another expression operator, not just a literal or field +# reference. +EXPRESSION_OPERAND_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="operand_from_slice", + expression={"$reverseArray": {"$slice": [[1, 2, 3, 4], 2]}}, + doc={"x": 0}, + expected=[2, 1], + msg="Should reverse an operand produced by $slice", + ), +] + +# Property [Algebraic invariants] (Rule 17): +# - Length invariant: reversing never changes the array length. Includes an +# even-length case (length_invariant_six) alongside the odd-length case +# (length_invariant_five) to exercise the swap-loop boundary in both +# parities — an off-by-one in an in-place swap-based reverse is more +# likely to surface on one parity than the other. +# - Multiset preservation: the reversed array has the same elements, with the +# same per-value counts, as the input (compared order-independently by +# sorting both sides). Uses an input with an element repeated 3x +# (multiset_preservation_duplicate_counts) so an implementation bug that +# preserves the *set* of distinct values but drops/duplicates individual +# occurrences (e.g. a faulty swap that overwrites one duplicate with +# another) would be caught; a simple set-equality check would not catch it. +ALGEBRAIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="length_invariant_empty", + expression={"$eq": [{"$size": {"$reverseArray": "$arr"}}, {"$size": "$arr"}]}, + doc={"arr": []}, + expected=True, + msg="Reversed length equals input length (size 0)", + ), + ExpressionTestCase( + id="length_invariant_single", + expression={"$eq": [{"$size": {"$reverseArray": "$arr"}}, {"$size": "$arr"}]}, + doc={"arr": [42]}, + expected=True, + msg="Reversed length equals input length (size 1)", + ), + ExpressionTestCase( + id="length_invariant_five", + expression={"$eq": [{"$size": {"$reverseArray": "$arr"}}, {"$size": "$arr"}]}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=True, + msg="Reversed length equals input length (size 5, odd — exercises a" + " middle-element swap boundary)", + ), + ExpressionTestCase( + id="length_invariant_six", + expression={"$eq": [{"$size": {"$reverseArray": "$arr"}}, {"$size": "$arr"}]}, + doc={"arr": [1, 2, 3, 4, 5, 6]}, + expected=True, + msg="Reversed length equals input length (size 6, even — exercises the" + " opposite swap-loop parity from size 5)", + ), + ExpressionTestCase( + id="multiset_preservation", + expression={ + "$eq": [ + {"$sortArray": {"input": {"$reverseArray": "$arr"}, "sortBy": 1}}, + {"$sortArray": {"input": "$arr", "sortBy": 1}}, + ] + }, + doc={"arr": [3, 1, 2, 3, 5]}, + expected=True, + msg="Reversed array preserves the same multiset of elements", + ), + ExpressionTestCase( + id="multiset_preservation_duplicate_counts", + expression={ + "$eq": [ + {"$sortArray": {"input": {"$reverseArray": "$arr"}, "sortBy": 1}}, + {"$sortArray": {"input": "$arr", "sortBy": 1}}, + ] + }, + doc={"arr": [7, 7, 7, 2, 9]}, + expected=True, + msg="Reversed array preserves exact per-value counts, not just the set" + " of distinct values (value 7 appears exactly 3 times either way)", + ), +] + +# Property [NaN element preservation]: float NaN elements (including multiple +# NaNs in the same array) survive reversal unchanged — reversal never +# normalizes or collapses NaN payloads. +FLOAT_NAN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="float_nan_preserved", + expression={"$arrayElemAt": [{"$reverseArray": "$arr"}, 2]}, + doc={"arr": [FLOAT_NAN, 1, 2]}, + expected=pytest.approx(FLOAT_NAN, nan_ok=True), + msg="Float NaN element preserved after reversal", + ), + ExpressionTestCase( + id="multiple_float_nan_preserved", + expression={"$arrayElemAt": [{"$reverseArray": "$arr"}, 1]}, + doc={"arr": [FLOAT_NAN, FLOAT_NAN, 1]}, + expected=pytest.approx(FLOAT_NAN, nan_ok=True), + msg="Multiple float NaN elements preserved after reversal", + ), +] + +# Property [Return type] (Rule 1): a successful $reverseArray yields BSON +# type "array". +RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="return_type_is_array", + expression={"$type": {"$reverseArray": [[1, 2, 3]]}}, + doc={"x": 0}, + expected="array", + msg="Result of $reverseArray should be BSON type 'array'", + ), + ExpressionTestCase( + id="outer_array_wrap_value", + expression={"$reverseArray": [[1, 2, 3]]}, + doc={"x": 0}, + expected=[3, 2, 1], + msg="Outer-array-wrapped operand [[1,2,3]] unwraps to [1,2,3] and reverses" + " to the exact value, not just BSON type 'array'", + ), +] + +ALL_EXPR_TESTS = ( + FIELD_LOOKUP_TESTS + + COMPOSITE_PATH_TESTS + + LET_TESTS + + NULL_MISSING_EXPR_TESTS + + SELF_COMPOSITION_TESTS + + EXPRESSION_OPERAND_TESTS + + ALGEBRAIC_TESTS + + RETURN_TYPE_TESTS + + FLOAT_NAN_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_EXPR_TESTS)) +def test_reverseArray_expression(collection, test): + """Test $reverseArray 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/sortArray/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_bson_types.py new file mode 100644 index 000000000..c87cd01e0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_bson_types.py @@ -0,0 +1,555 @@ +""" +BSON type and mixed type sorting tests for $sortArray expression. + +Tests sorting arrays containing various BSON types (by value and by document +field), mixed types (lexicographic BSON ordering, including cross-type object +and array elements), numeric boundary values, documents with missing sort +fields, special values (Decimal128 NaN, numeric equivalence, negative zero, +Unicode codepoint order), and valid sortBy type variants (int/double/int64/ +decimal128, 1 and -1). +""" + +from datetime import datetime, timezone +from uuid import UUID + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + 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, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT64_MAX, + INT64_MIN, +) + +# Property [Literal-path parity]: representative cases from each group below +# also run through the literal-value path (not just via inserted documents), +# and are appended to ALL_TESTS below so they also get insert coverage. +LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="sort_int64_asc", + expression={ + "$sortArray": { + "input": {"$literal": [Int64(30), Int64(10), Int64(20)]}, + "sortBy": 1, + } + }, + expected=[Int64(10), Int64(20), Int64(30)], + msg="Should sort Int64 values ascending", + ), + ExpressionTestCase( + id="mixed_numbers_and_strings_asc", + expression={ + "$sortArray": { + "input": {"$literal": [20, 4, "Gratis", 6, 21, 5]}, + "sortBy": 1, + } + }, + expected=[4, 5, 6, 20, 21, "Gratis"], + msg="Should sort numbers before strings", + ), + ExpressionTestCase( + id="int32_boundaries", + expression={ + "$sortArray": { + "input": {"$literal": [0, INT32_MAX, INT32_MIN]}, + "sortBy": 1, + } + }, + expected=[INT32_MIN, 0, INT32_MAX], + msg="Should sort INT32 boundary values correctly", + ), + ExpressionTestCase( + id="sort_docs_by_int64_field", + expression={ + "$sortArray": { + "input": {"$literal": [{"v": Int64(30)}, {"v": Int64(10)}, {"v": Int64(20)}]}, + "sortBy": {"v": 1}, + } + }, + expected=[{"v": Int64(10)}, {"v": Int64(20)}, {"v": Int64(30)}], + msg="Should sort documents by Int64 field ascending", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(LITERAL_TESTS)) +def test_sortArray_bson_literal(collection, test): + """Test $sortArray BSON types with literal values.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +# Property [BSON type ordering by value]: each BSON value type sorts +# correctly within its own type — Int64/Decimal128/datetime/ObjectId/bool/ +# Timestamp/Binary(+subtype)/Regex/UUID — with values compared meaningfully +# rather than by memory/insertion order. +BSON_VALUE_SORT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="sort_int64_asc", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [Int64(30), Int64(10), Int64(20)]}, + expected=[Int64(10), Int64(20), Int64(30)], + msg="Should sort Int64 values ascending", + ), + ExpressionTestCase( + id="sort_decimal128_asc", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [Decimal128("3.14"), Decimal128("1.5"), Decimal128("2.7")]}, + expected=[Decimal128("1.5"), Decimal128("2.7"), Decimal128("3.14")], + msg="Should sort Decimal128 values ascending", + ), + ExpressionTestCase( + id="sort_datetime_asc", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={ + "arr": [ + datetime(2024, 3, 1, tzinfo=timezone.utc), + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 2, 1, tzinfo=timezone.utc), + ] + }, + expected=[ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 2, 1, tzinfo=timezone.utc), + datetime(2024, 3, 1, tzinfo=timezone.utc), + ], + msg="Should sort datetime values ascending", + ), + ExpressionTestCase( + id="sort_objectid_asc", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={ + "arr": [ + ObjectId("000000000000000000000003"), + ObjectId("000000000000000000000001"), + ObjectId("000000000000000000000002"), + ] + }, + expected=[ + ObjectId("000000000000000000000001"), + ObjectId("000000000000000000000002"), + ObjectId("000000000000000000000003"), + ], + msg="Should sort ObjectId values ascending", + ), + ExpressionTestCase( + id="sort_bool_asc", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [True, False, True, False]}, + expected=[False, False, True, True], + msg="Should sort booleans ascending (false < true)", + ), + ExpressionTestCase( + id="sort_timestamp_asc", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [Timestamp(3, 0), Timestamp(1, 0), Timestamp(2, 0)]}, + expected=[Timestamp(1, 0), Timestamp(2, 0), Timestamp(3, 0)], + msg="Should sort Timestamp values ascending", + ), + ExpressionTestCase( + id="sort_binary_asc", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [Binary(b"\x03"), Binary(b"\x01"), Binary(b"\x02")]}, + expected=[b"\x01", b"\x02", b"\x03"], + msg="Should sort Binary values ascending", + ), + ExpressionTestCase( + id="sort_binary_subtype_asc", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [Binary(b"\x03", 128), Binary(b"\x01", 128), Binary(b"\x02", 128)]}, + expected=[Binary(b"\x01", 128), Binary(b"\x02", 128), Binary(b"\x03", 128)], + msg="Should sort Binary with subtype preserved ascending", + ), + ExpressionTestCase( + id="sort_regex_asc", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [Regex("c.*"), Regex("a.*"), Regex("b.*")]}, + expected=[Regex("a.*"), Regex("b.*"), Regex("c.*")], + msg="Should sort Regex values ascending by pattern", + ), + ExpressionTestCase( + id="sort_uuid_asc", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={ + "arr": [ + Binary.from_uuid(UUID("cccccccc-cccc-cccc-cccc-cccccccccccc")), + Binary.from_uuid(UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")), + Binary.from_uuid(UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")), + ] + }, + expected=[ + Binary.from_uuid(UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")), + Binary.from_uuid(UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")), + Binary.from_uuid(UUID("cccccccc-cccc-cccc-cccc-cccccccccccc")), + ], + msg="Should sort UUID binary values ascending", + ), +] + +# Property [Canonical cross-type ordering]: when an array mixes multiple BSON +# types, elements sort by the canonical BSON type order first, then by value +# within a type. Per docs, whole-array sort is lexicographic. +# BSON order: MinKey < null < numbers < string < object < array < +# binary < objectId < bool < date < timestamp < regex < MaxKey +MIXED_TYPE_SORT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="mixed_numbers_and_strings_asc", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [20, 4, "Gratis", 6, 21, 5]}, + expected=[4, 5, 6, 20, 21, "Gratis"], + msg="Should sort numbers before strings", + ), + ExpressionTestCase( + id="mixed_bool_and_numbers_asc", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [True, 1, False, 0]}, + expected=[0, 1, False, True], + msg="Should sort numbers before booleans", + ), + ExpressionTestCase( + id="mixed_minkey_maxkey_asc", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [MaxKey(), 1, MinKey(), "a"]}, + expected=[MinKey(), 1, "a", MaxKey()], + msg="MinKey should sort first and MaxKey last per BSON ordering", + ), + ExpressionTestCase( + id="mixed_binary_regex_asc", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [Regex("x"), Binary(b"\x01"), ObjectId("000000000000000000000001"), True]}, + expected=[b"\x01", ObjectId("000000000000000000000001"), True, Regex("x")], + msg="Should follow BSON order: binary < objectId < bool < regex", + ), + ExpressionTestCase( + id="cross_type_number_string_object_array_asc", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [1, [1], {"a": 1}, "1"]}, + expected=[1, "1", {"a": 1}, [1]], + msg="Canonical type order ascending: numbers < strings < objects < arrays", + ), + ExpressionTestCase( + id="cross_type_number_string_object_array_desc", + expression={"$sortArray": {"input": "$arr", "sortBy": -1}}, + doc={"arr": [1, [1], {"a": 1}, "1"]}, + expected=[[1], {"a": 1}, "1", 1], + msg="Canonical type order descending: arrays < objects < strings < numbers", + ), + ExpressionTestCase( + id="array_of_arrays_elementwise", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [[2], [1], [1, 2]]}, + expected=[[1], [1, 2], [2]], + msg="Array elements sorted element-wise (prefix sorts before longer) — manual B13", + ), +] + +# Property [Missing sort field]: documents or scalars without the specified +# sortBy field sort as equal to each other (per docs), whether some or all +# elements lack the field. +MISSING_FIELD_SORT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="docs_some_missing_field", + expression={"$sortArray": {"input": "$arr", "sortBy": {"a": 1}}}, + doc={"arr": [{"a": 3}, {"b": 1}, {"a": 1}]}, + expected=[{"b": 1}, {"a": 1}, {"a": 3}], + msg="Documents missing sort field should sort before those with it", + ), + ExpressionTestCase( + id="docs_all_missing_field", + expression={"$sortArray": {"input": "$arr", "sortBy": {"a": 1}}}, + doc={"arr": [{"b": 3}, {"b": 1}, {"b": 2}]}, + expected=[{"b": 3}, {"b": 1}, {"b": 2}], + msg="All documents missing sort field should maintain relative order", + ), +] + +# Property [Numeric boundary values]: min/max/±Infinity values for each +# numeric BSON type sort correctly at the extremes, without overflow or +# precision loss affecting order. +BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="int32_boundaries", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [0, INT32_MAX, INT32_MIN]}, + expected=[INT32_MIN, 0, INT32_MAX], + msg="Should sort INT32 boundary values correctly", + ), + ExpressionTestCase( + id="int64_boundaries", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [Int64(0), INT64_MAX, INT64_MIN]}, + expected=[INT64_MIN, Int64(0), INT64_MAX], + msg="Should sort INT64 boundary values correctly", + ), + ExpressionTestCase( + id="infinity_values", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [FLOAT_INFINITY, 0, FLOAT_NEGATIVE_INFINITY]}, + expected=[FLOAT_NEGATIVE_INFINITY, 0, FLOAT_INFINITY], + msg="Should sort infinity values correctly", + ), + ExpressionTestCase( + id="decimal128_infinity", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [DECIMAL128_INFINITY, Decimal128("0"), DECIMAL128_NEGATIVE_INFINITY]}, + expected=[DECIMAL128_NEGATIVE_INFINITY, Decimal128("0"), DECIMAL128_INFINITY], + msg="Should sort Decimal128 infinity values correctly", + ), +] + +# Property [Sort by typed field]: sorting by a named document field works +# correctly across every BSON field-value type, including mixed numeric +# types within the same field. +BSON_FIELD_SORT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="sort_docs_by_int64_field", + expression={"$sortArray": {"input": "$arr", "sortBy": {"v": 1}}}, + doc={"arr": [{"v": Int64(30)}, {"v": Int64(10)}, {"v": Int64(20)}]}, + expected=[{"v": Int64(10)}, {"v": Int64(20)}, {"v": Int64(30)}], + msg="Should sort documents by Int64 field ascending", + ), + ExpressionTestCase( + id="sort_docs_by_decimal128_field", + expression={"$sortArray": {"input": "$arr", "sortBy": {"v": 1}}}, + doc={"arr": [{"v": Decimal128("3")}, {"v": Decimal128("1")}, {"v": Decimal128("2")}]}, + expected=[{"v": Decimal128("1")}, {"v": Decimal128("2")}, {"v": Decimal128("3")}], + msg="Should sort documents by Decimal128 field ascending", + ), + ExpressionTestCase( + id="sort_docs_by_datetime_field", + expression={"$sortArray": {"input": "$arr", "sortBy": {"v": 1}}}, + doc={ + "arr": [ + {"v": datetime(2024, 3, 1, tzinfo=timezone.utc)}, + {"v": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + {"v": datetime(2024, 2, 1, tzinfo=timezone.utc)}, + ] + }, + expected=[ + {"v": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + {"v": datetime(2024, 2, 1, tzinfo=timezone.utc)}, + {"v": datetime(2024, 3, 1, tzinfo=timezone.utc)}, + ], + msg="Should sort documents by datetime field ascending", + ), + ExpressionTestCase( + id="sort_docs_by_cross_numeric_types", + expression={"$sortArray": {"input": "$arr", "sortBy": {"v": 1}}}, + doc={"arr": [{"v": Decimal128("2.5")}, {"v": 1}, {"v": Int64(3)}, {"v": 1.5}]}, + expected=[{"v": 1}, {"v": 1.5}, {"v": Decimal128("2.5")}, {"v": Int64(3)}], + msg="Should sort documents with mixed numeric field types", + ), + ExpressionTestCase( + id="sort_docs_by_binary_field", + expression={"$sortArray": {"input": "$arr", "sortBy": {"v": 1}}}, + doc={"arr": [{"v": Binary(b"\x03")}, {"v": Binary(b"\x01")}, {"v": Binary(b"\x02")}]}, + expected=[{"v": b"\x01"}, {"v": b"\x02"}, {"v": b"\x03"}], + msg="Should sort documents by Binary field ascending", + ), + ExpressionTestCase( + id="sort_docs_by_binary_subtype_field", + expression={"$sortArray": {"input": "$arr", "sortBy": {"v": 1}}}, + doc={ + "arr": [ + {"v": Binary(b"\x03", 128)}, + {"v": Binary(b"\x01", 128)}, + {"v": Binary(b"\x02", 128)}, + ] + }, + expected=[ + {"v": Binary(b"\x01", 128)}, + {"v": Binary(b"\x02", 128)}, + {"v": Binary(b"\x03", 128)}, + ], + msg="Should sort documents by Binary field with subtype preserved", + ), + ExpressionTestCase( + id="sort_docs_by_regex_field", + expression={"$sortArray": {"input": "$arr", "sortBy": {"v": 1}}}, + doc={"arr": [{"v": Regex("c.*")}, {"v": Regex("a.*")}, {"v": Regex("b.*")}]}, + expected=[{"v": Regex("a.*")}, {"v": Regex("b.*")}, {"v": Regex("c.*")}], + msg="Should sort documents by Regex field ascending", + ), + ExpressionTestCase( + id="sort_docs_by_minkey_maxkey_field", + expression={"$sortArray": {"input": "$arr", "sortBy": {"v": 1}}}, + doc={"arr": [{"v": MaxKey()}, {"v": 1}, {"v": MinKey()}]}, + expected=[{"v": MinKey()}, {"v": 1}, {"v": MaxKey()}], + msg="Should sort documents with MinKey before and MaxKey after all other types", + ), + ExpressionTestCase( + id="sort_docs_by_uuid_field", + expression={"$sortArray": {"input": "$arr", "sortBy": {"v": 1}}}, + doc={ + "arr": [ + {"v": Binary.from_uuid(UUID("cccccccc-cccc-cccc-cccc-cccccccccccc"))}, + {"v": Binary.from_uuid(UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"))}, + {"v": Binary.from_uuid(UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))}, + ] + }, + expected=[ + {"v": Binary.from_uuid(UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"))}, + {"v": Binary.from_uuid(UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))}, + {"v": Binary.from_uuid(UUID("cccccccc-cccc-cccc-cccc-cccccccccccc"))}, + ], + msg="Should sort documents by UUID binary field ascending", + ), +] + +# Property [Special numeric/string values]: Decimal128 NaN sorts first, +# numerically-equivalent cross-type values (including all-zero variants) +# compare equal and preserve stable order, high-precision Decimal128 values +# remain distinguishable, and strings sort by Unicode codepoint. +SPECIAL_VALUE_SORT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="nan_decimal128_ascending", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [Decimal128("3"), DECIMAL128_NAN, Decimal128("1")]}, + expected=[DECIMAL128_NAN, Decimal128("1"), Decimal128("3")], + msg="Decimal128 NaN should sort first", + ), + ExpressionTestCase( + id="numeric_equivalence_value", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [Int64(2), 1, Decimal128("3"), 2.0]}, + expected=[1, Int64(2), 2.0, Decimal128("3")], + msg="Numerically equivalent values sort together", + ), + ExpressionTestCase( + id="decimal128_high_precision", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={ + "arr": [ + Decimal128("1.000000000000000000000000000000002"), + Decimal128("1.000000000000000000000000000000001"), + ] + }, + expected=[ + Decimal128("1.000000000000000000000000000000001"), + Decimal128("1.000000000000000000000000000000002"), + ], + msg="Should distinguish high-precision Decimal128 values", + ), + ExpressionTestCase( + id="negative_zero_double", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [1, DOUBLE_NEGATIVE_ZERO, -1]}, + expected=[-1, DOUBLE_NEGATIVE_ZERO, 1], + msg="Negative zero sorts with zero", + ), + ExpressionTestCase( + id="cross_type_zero_equivalence", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [Decimal128("0"), DOUBLE_NEGATIVE_ZERO, Int64(0)]}, + expected=[Decimal128("0"), DOUBLE_NEGATIVE_ZERO, Int64(0)], + msg="All-zero values across Decimal128/double/Int64 compare equal and" + " keep stable (input) order", + ), + ExpressionTestCase( + id="unicode_codepoint", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": ["é", "a", "z"]}, + expected=["a", "z", "é"], + msg="Should sort by Unicode codepoint", + ), +] + +# Property [Valid sortBy type variants]: sortBy accepts any numeric BSON type +# whose value is exactly 1 or -1 (int, double, Int64, Decimal128), not just a +# plain int literal. +VALID_BSON_SORTBY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="sortby_int_1", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [3, 1, 2]}, + expected=[1, 2, 3], + msg="sortBy int 1 should sort ascending", + ), + ExpressionTestCase( + id="sortby_int_neg1", + expression={"$sortArray": {"input": "$arr", "sortBy": -1}}, + doc={"arr": [3, 1, 2]}, + expected=[3, 2, 1], + msg="sortBy int -1 should sort descending", + ), + ExpressionTestCase( + id="sortby_double_1", + expression={"$sortArray": {"input": "$arr", "sortBy": 1.0}}, + doc={"arr": [3, 1, 2]}, + expected=[1, 2, 3], + msg="sortBy 1.0 should sort ascending", + ), + ExpressionTestCase( + id="sortby_double_neg1", + expression={"$sortArray": {"input": "$arr", "sortBy": -1.0}}, + doc={"arr": [3, 1, 2]}, + expected=[3, 2, 1], + msg="sortBy -1.0 should sort descending", + ), + ExpressionTestCase( + id="sortby_int64_1", + expression={"$sortArray": {"input": "$arr", "sortBy": Int64(1)}}, + doc={"arr": [3, 1, 2]}, + expected=[1, 2, 3], + msg="sortBy Int64(1) should sort ascending", + ), + ExpressionTestCase( + id="sortby_int64_neg1", + expression={"$sortArray": {"input": "$arr", "sortBy": Int64(-1)}}, + doc={"arr": [3, 1, 2]}, + expected=[3, 2, 1], + msg="sortBy Int64(-1) should sort descending", + ), + ExpressionTestCase( + id="sortby_decimal128_1", + expression={"$sortArray": {"input": "$arr", "sortBy": Decimal128("1")}}, + doc={"arr": [3, 1, 2]}, + expected=[1, 2, 3], + msg="sortBy Decimal128('1') should sort ascending", + ), + ExpressionTestCase( + id="sortby_decimal128_neg1", + expression={"$sortArray": {"input": "$arr", "sortBy": Decimal128("-1")}}, + doc={"arr": [3, 1, 2]}, + expected=[3, 2, 1], + msg="sortBy Decimal128('-1') should sort descending", + ), +] + +ALL_TESTS = ( + BSON_VALUE_SORT_TESTS + + MIXED_TYPE_SORT_TESTS + + MISSING_FIELD_SORT_TESTS + + BOUNDARY_TESTS + + BSON_FIELD_SORT_TESTS + + SPECIAL_VALUE_SORT_TESTS + + VALID_BSON_SORTBY_TESTS + + LITERAL_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_sortArray_bson_insert(collection, test): + """Test $sortArray BSON types with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_core_behavior.py new file mode 100644 index 000000000..628d62583 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_core_behavior.py @@ -0,0 +1,524 @@ +""" +Core behavior tests for $sortArray expression. + +Tests sort by value (ascending/descending), sort by document field, +sort by subfield, sort by multiple fields, whole-value document sorting, +empty array, single element, duplicate values, numeric cross-type sorts, +large arrays, null input and null/missing-field handling, and edge cases +(no array traversal, stable order of field-less scalars). +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Literal-path parity]: representative cases from each group below +# also run through the literal-value path (not just via inserted documents), +# and are appended to ALL_TESTS below so they also get insert coverage. +LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="asc_ints", + expression={"$sortArray": {"input": {"$literal": [3, 1, 2]}, "sortBy": 1}}, + expected=[1, 2, 3], + msg="Should sort ints ascending", + ), + ExpressionTestCase( + id="desc_ints", + expression={"$sortArray": {"input": {"$literal": [3, 1, 2]}, "sortBy": -1}}, + expected=[3, 2, 1], + msg="Should sort ints descending", + ), + ExpressionTestCase( + id="sort_by_name_asc", + expression={ + "$sortArray": { + "input": { + "$literal": [ + {"name": "peter", "age": 30}, + {"name": "dorothy", "age": 36}, + {"name": "chloe", "age": 42}, + ] + }, + "sortBy": {"name": 1}, + } + }, + expected=[ + {"name": "chloe", "age": 42}, + {"name": "dorothy", "age": 36}, + {"name": "peter", "age": 30}, + ], + msg="Should sort documents by name ascending", + ), + ExpressionTestCase( + id="empty_array_value_sort", + expression={"$sortArray": {"input": {"$literal": []}, "sortBy": 1}}, + expected=[], + msg="Should return empty array for empty input", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(LITERAL_TESTS)) +def test_sortArray_literal(collection, test): + """Test $sortArray with literal values.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +# Property [Value sort direction]: a plain array of scalars is sorted +# ascending or descending by value, correctly handling already-sorted, +# reverse-sorted, and negative-number inputs. +SORT_ASC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="asc_ints", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [3, 1, 2]}, + expected=[1, 2, 3], + msg="Should sort ints ascending", + ), + ExpressionTestCase( + id="asc_strings", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": ["banana", "apple", "cherry"]}, + expected=["apple", "banana", "cherry"], + msg="Should sort strings ascending", + ), + ExpressionTestCase( + id="asc_doubles", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [3.3, 1.1, 2.2]}, + expected=[1.1, 2.2, 3.3], + msg="Should sort doubles ascending", + ), + ExpressionTestCase( + id="asc_already_sorted", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [1, 2, 3]}, + expected=[1, 2, 3], + msg="Should preserve already sorted array", + ), + ExpressionTestCase( + id="asc_reverse_sorted", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [3, 2, 1]}, + expected=[1, 2, 3], + msg="Should reverse descending array", + ), + ExpressionTestCase( + id="asc_negative_numbers", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [0, -3, 2, -1]}, + expected=[-3, -1, 0, 2], + msg="Should sort negative numbers ascending", + ), +] + +# Property [Value sort direction]: sortBy -1 sorts scalars descending, the +# mirror counterpart to SORT_ASC_TESTS above. +SORT_DESC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="desc_ints", + expression={"$sortArray": {"input": "$arr", "sortBy": -1}}, + doc={"arr": [3, 1, 2]}, + expected=[3, 2, 1], + msg="Should sort ints descending", + ), + ExpressionTestCase( + id="desc_strings", + expression={"$sortArray": {"input": "$arr", "sortBy": -1}}, + doc={"arr": ["banana", "apple", "cherry"]}, + expected=["cherry", "banana", "apple"], + msg="Should sort strings descending", + ), + ExpressionTestCase( + id="desc_doubles", + expression={"$sortArray": {"input": "$arr", "sortBy": -1}}, + doc={"arr": [1.1, 3.3, 2.2]}, + expected=[3.3, 2.2, 1.1], + msg="Should sort doubles descending", + ), +] + +# Property [Sort by field]: with a document sortBy spec, an array of +# documents is ordered by the named top-level field's value, ascending or +# descending. +SORT_BY_FIELD_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="sort_by_name_asc", + expression={"$sortArray": {"input": "$arr", "sortBy": {"name": 1}}}, + doc={ + "arr": [ + {"name": "peter", "age": 30}, + {"name": "dorothy", "age": 36}, + {"name": "chloe", "age": 42}, + ] + }, + expected=[ + {"name": "chloe", "age": 42}, + {"name": "dorothy", "age": 36}, + {"name": "peter", "age": 30}, + ], + msg="Should sort documents by name ascending", + ), + ExpressionTestCase( + id="sort_by_age_desc", + expression={"$sortArray": {"input": "$arr", "sortBy": {"age": -1}}}, + doc={ + "arr": [ + {"name": "peter", "age": 30}, + {"name": "dorothy", "age": 36}, + {"name": "chloe", "age": 42}, + ] + }, + expected=[ + {"name": "chloe", "age": 42}, + {"name": "dorothy", "age": 36}, + {"name": "peter", "age": 30}, + ], + msg="Should sort documents by age descending", + ), + ExpressionTestCase( + id="sort_by_age_asc", + expression={"$sortArray": {"input": "$arr", "sortBy": {"age": 1}}}, + doc={ + "arr": [ + {"name": "chloe", "age": 42}, + {"name": "peter", "age": 30}, + {"name": "dorothy", "age": 36}, + ] + }, + expected=[ + {"name": "peter", "age": 30}, + {"name": "dorothy", "age": 36}, + {"name": "chloe", "age": 42}, + ], + msg="Should sort documents by age ascending", + ), +] + +# Property [Sort by dotted subfield]: the sortBy field name may be a dotted +# path into a nested subdocument, not just a top-level field. +SORT_BY_SUBFIELD_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="sort_by_subfield_desc", + expression={"$sortArray": {"input": "$arr", "sortBy": {"address.city": -1}}}, + doc={ + "arr": [ + {"name": "peter", "address": {"city": "Long Beach"}}, + {"name": "dorothy", "address": {"city": "Portland"}}, + {"name": "chloe", "address": {"city": "New York"}}, + ] + }, + expected=[ + {"name": "dorothy", "address": {"city": "Portland"}}, + {"name": "chloe", "address": {"city": "New York"}}, + {"name": "peter", "address": {"city": "Long Beach"}}, + ], + msg="Should sort by subfield descending", + ), + ExpressionTestCase( + id="sort_by_subfield_asc", + expression={"$sortArray": {"input": "$arr", "sortBy": {"address.city": 1}}}, + doc={ + "arr": [ + {"name": "peter", "address": {"city": "Long Beach"}}, + {"name": "dorothy", "address": {"city": "Portland"}}, + {"name": "chloe", "address": {"city": "New York"}}, + ] + }, + expected=[ + {"name": "peter", "address": {"city": "Long Beach"}}, + {"name": "chloe", "address": {"city": "New York"}}, + {"name": "dorothy", "address": {"city": "Portland"}}, + ], + msg="Should sort by subfield ascending", + ), +] + +# Property [Compound sortBy]: multiple fields in the sortBy document are +# applied in declared order, with later fields acting as tiebreakers for +# equal values in earlier fields. +SORT_MULTIPLE_FIELDS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="compound_age_desc_name_asc", + expression={"$sortArray": {"input": "$arr", "sortBy": {"age": -1, "name": 1}}}, + doc={ + "arr": [ + {"name": "peter", "age": 30}, + {"name": "dorothy", "age": 36}, + {"name": "chloe", "age": 42}, + ] + }, + expected=[ + {"name": "chloe", "age": 42}, + {"name": "dorothy", "age": 36}, + {"name": "peter", "age": 30}, + ], + msg="Should sort by age desc then name asc", + ), + ExpressionTestCase( + id="compound_tiebreaker", + expression={"$sortArray": {"input": "$arr", "sortBy": {"score": -1, "name": 1}}}, + doc={ + "arr": [ + {"name": "bob", "score": 90}, + {"name": "alice", "score": 90}, + {"name": "chloe", "score": 80}, + ] + }, + expected=[ + {"name": "alice", "score": 90}, + {"name": "bob", "score": 90}, + {"name": "chloe", "score": 80}, + ], + msg="Should use second field as tiebreaker", + ), +] + +# Property [Degenerate lengths]: empty and single-element arrays are handled +# correctly under both a scalar (whole-value) and a field-document sortBy. +DEGENERATE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="empty_array_value_sort", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": []}, + expected=[], + msg="Should return empty array for empty input", + ), + ExpressionTestCase( + id="empty_array_field_sort", + expression={"$sortArray": {"input": "$arr", "sortBy": {"a": 1}}}, + doc={"arr": []}, + expected=[], + msg="Should return empty array for empty input with field sort", + ), + ExpressionTestCase( + id="single_element_value", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [42]}, + expected=[42], + msg="Should return single element unchanged", + ), + ExpressionTestCase( + id="single_element_doc", + expression={"$sortArray": {"input": "$arr", "sortBy": {"a": 1}}}, + doc={"arr": [{"a": 1}]}, + expected=[{"a": 1}], + msg="Should return single document unchanged", + ), +] + +# Property [Duplicate handling]: sorting groups equal/duplicate values +# together and preserves every occurrence, rather than deduplicating. +DUPLICATE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="all_same_values", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [5, 5, 5]}, + expected=[5, 5, 5], + msg="Should handle all identical values", + ), + ExpressionTestCase( + id="duplicates_mixed", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [1, 4, 1, 6, 12, 5]}, + expected=[1, 1, 4, 5, 6, 12], + msg="Should group duplicates together", + ), +] + +# Property [Numeric cross-type ordering]: numbers of different BSON numeric +# types (int, Int64, Decimal128) compare and interleave by numeric value, +# not by their BSON type tag. +NUMERIC_CROSS_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="int_and_double", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [3, 1.5, 2]}, + expected=[1.5, 2, 3], + msg="Should sort ints and doubles together", + ), + ExpressionTestCase( + id="int_and_int64", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [3, Int64(1), 2]}, + expected=[Int64(1), 2, 3], + msg="Should sort ints and int64 together", + ), + ExpressionTestCase( + id="int_and_decimal128", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [3, Decimal128("1.5"), 2]}, + expected=[Decimal128("1.5"), 2, 3], + msg="Should sort ints and decimal128 together", + ), +] + +_LARGE = list(range(1000, 0, -1)) + +# Property [Scales to large arrays]: sort correctness holds for an array large +# enough (1000 elements) to rule out small-input-only implementations, in +# both directions. +LARGE_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="large_array_asc", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": _LARGE}, + expected=list(range(1, 1001)), + msg="Should sort large array ascending", + ), + ExpressionTestCase( + id="large_array_desc", + expression={"$sortArray": {"input": "$arr", "sortBy": -1}}, + doc={"arr": list(range(1, 1001))}, + expected=_LARGE, + msg="Should sort large array descending", + ), +] + +# Property [Null propagation and null-element ordering]: a null input returns +# null (rather than an error), and null elements/fields sort as their own +# BSON type — first ascending, last descending — with null and a genuinely +# missing field treated equally. +NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="null_input_value_sort", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": None}, + expected=None, + msg="Should return null for null input with value sort", + ), + ExpressionTestCase( + id="null_input_field_sort", + expression={"$sortArray": {"input": "$arr", "sortBy": {"a": 1}}}, + doc={"arr": None}, + expected=None, + msg="Should return null for null input with field sort", + ), + ExpressionTestCase( + id="null_elements_asc", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [3, None, 1, None, 2]}, + expected=[None, None, 1, 2, 3], + msg="Null elements should sort before numbers", + ), + ExpressionTestCase( + id="null_elements_desc", + expression={"$sortArray": {"input": "$arr", "sortBy": -1}}, + doc={"arr": [3, None, 1, None, 2]}, + expected=[3, 2, 1, None, None], + msg="Null elements should sort last descending", + ), + ExpressionTestCase( + id="null_field_in_docs", + expression={"$sortArray": {"input": "$arr", "sortBy": {"a": 1}}}, + doc={"arr": [{"a": 3}, {"a": None}, {"a": 1}]}, + expected=[{"a": None}, {"a": 1}, {"a": 3}], + msg="Null field sorts first", + ), + ExpressionTestCase( + id="null_and_missing_sort_equally", + expression={"$sortArray": {"input": "$arr", "sortBy": {"a": 1}}}, + doc={"arr": [{"a": 2}, {"a": None}, {"b": 1}, {"a": 1}]}, + expected=[{"a": None}, {"b": 1}, {"a": 1}, {"a": 2}], + msg="Null and missing sort equally", + ), +] + +# Property [Comparison edge cases]: array-valued sort fields are compared as +# whole values (not traversed/flattened), sorting is stable and preserves all +# duplicate elements, scalars without the sort field keep their relative +# order, and `$`-prefixed field names in sortBy resolve normally. +EDGE_CASE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="no_array_traversal", + expression={"$sortArray": {"input": "$arr", "sortBy": {"a": 1}}}, + doc={"arr": [{"a": [3, 1]}, {"a": [2]}]}, + expected=[{"a": [2]}, {"a": [3, 1]}], + msg="Should compare arrays, not traverse into them", + ), + ExpressionTestCase( + id="preserves_all_elements", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [3, 1, 2, 1, 3]}, + expected=[1, 1, 2, 3, 3], + msg="Should preserve all elements including duplicates", + ), + ExpressionTestCase( + id="scalars_doc_sort", + expression={"$sortArray": {"input": "$arr", "sortBy": {"x": 1}}}, + doc={"arr": [1, "a", None]}, + expected=[1, "a", None], + msg="Scalars without sort field maintain order", + ), + ExpressionTestCase( + id="dollar_prefixed_sortby", + expression={"$sortArray": {"input": "$arr", "sortBy": {"$a": 1}}}, + doc={"arr": [{"$a": 2}, {"$a": 1}]}, + expected=[{"$a": 1}, {"$a": 2}], + msg="Should sort by $ prefixed field", + ), +] + +# Property [Whole-value document sort] (JS S1): with a scalar `sortBy`, an +# array of documents is ordered by whole-value BSON comparison (field-by-field: +# field name, then value; a shorter prefix document sorts before a longer one). +WHOLE_VALUE_DOC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="whole_value_docs_asc", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [{"a": 2}, {"a": 1}, {"a": 3}]}, + expected=[{"a": 1}, {"a": 2}, {"a": 3}], + msg="Whole-value sort of documents (ascending)", + ), + ExpressionTestCase( + id="whole_value_docs_desc", + expression={"$sortArray": {"input": "$arr", "sortBy": -1}}, + doc={"arr": [{"a": 2}, {"a": 1}, {"a": 3}]}, + expected=[{"a": 3}, {"a": 2}, {"a": 1}], + msg="Whole-value sort of documents (descending)", + ), + ExpressionTestCase( + id="whole_value_docs_mismatched_keys", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": [{"b": 1}, {"a": 1}, {"a": 1, "b": 1}, {"a": 2}]}, + expected=[{"a": 1}, {"a": 1, "b": 1}, {"a": 2}, {"b": 1}], + msg="Whole-value sort orders documents with mismatched keys by BSON key/value", + ), +] + +ALL_TESTS = ( + SORT_ASC_TESTS + + SORT_DESC_TESTS + + SORT_BY_FIELD_TESTS + + SORT_BY_SUBFIELD_TESTS + + SORT_MULTIPLE_FIELDS_TESTS + + WHOLE_VALUE_DOC_TESTS + + DEGENERATE_TESTS + + DUPLICATE_TESTS + + NUMERIC_CROSS_TYPE_TESTS + + LARGE_ARRAY_TESTS + + NULL_TESTS + + EDGE_CASE_TESTS + + LITERAL_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_sortArray_insert(collection, test): + """Test $sortArray with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_errors.py new file mode 100644 index 000000000..45e084534 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_errors.py @@ -0,0 +1,593 @@ +""" +Error tests for $sortArray expression. + +Tests non-array input (all BSON types, special numeric values, boundary values, +string edge cases) and argument structure validation. Note: $sortArray +propagates null — null/missing input tests are in +test_expression_sortArray_core_behavior.py. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + SORT_ARRAY_INVALID_SORT_DOC_ERROR, + SORT_ARRAY_INVALID_SORT_SCALAR_ERROR, + SORT_ARRAY_INVALID_SORT_TYPE_ERROR, + SORT_ARRAY_MISSING_INPUT_ERROR, + SORT_ARRAY_MISSING_SORTBY_ERROR, + SORT_ARRAY_NON_OBJECT_ARG_ERROR, + SORT_ARRAY_UNKNOWN_FIELD_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 [Literal-path parity]: representative non-array rejections also +# run through the literal-value path (not just via inserted documents), and +# are appended to ALL_TESTS below so they also get insert coverage. +LITERAL_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="string_input", + expression={"$sortArray": {"input": "hello", "sortBy": 1}}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject string input", + ), + ExpressionTestCase( + id="timestamp_input", + expression={"$sortArray": {"input": Timestamp(0, 0), "sortBy": 1}}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject timestamp input", + ), + ExpressionTestCase( + id="nan_input", + expression={"$sortArray": {"input": FLOAT_NAN, "sortBy": 1}}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject NaN input", + ), + ExpressionTestCase( + id="int32_max_input", + expression={"$sortArray": {"input": INT32_MAX, "sortBy": 1}}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject INT32_MAX input", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(LITERAL_ERROR_TESTS)) +def test_sortArray_not_array_literal(collection, test): + """Test $sortArray error with non-array literal input.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +# Property [Non-array input rejection]: every non-array BSON type (scalar, +# object, and edge values like negative numbers) is rejected with +# SORT_ARRAY_INPUT_NOT_ARRAY_ERROR — no type is silently coerced. +NOT_ARRAY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="string_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": "hello"}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject string input", + ), + ExpressionTestCase( + id="int_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": 42}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject int input", + ), + ExpressionTestCase( + id="negative_int_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": -42}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject negative int input", + ), + ExpressionTestCase( + id="bool_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": True}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject bool input", + ), + ExpressionTestCase( + id="object_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": {"a": 1}}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject object input", + ), + ExpressionTestCase( + id="double_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": 3.14}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject double input", + ), + ExpressionTestCase( + id="negative_double_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": -3.14}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject negative double input", + ), + ExpressionTestCase( + id="decimal128_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": Decimal128("1")}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject decimal128 input", + ), + ExpressionTestCase( + id="int64_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": Int64(1)}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject int64 input", + ), + ExpressionTestCase( + id="objectid_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": ObjectId()}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject objectid input", + ), + ExpressionTestCase( + id="datetime_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject datetime input", + ), + ExpressionTestCase( + id="binary_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": Binary(b"x", 0)}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject binary input", + ), + ExpressionTestCase( + id="regex_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": Regex("x")}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject regex input", + ), + ExpressionTestCase( + id="maxkey_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": MaxKey()}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject maxkey input", + ), + ExpressionTestCase( + id="minkey_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": MinKey()}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject minkey input", + ), + ExpressionTestCase( + id="timestamp_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": Timestamp(0, 0)}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject timestamp input", + ), +] + +# Property [Special numeric non-array rejection]: IEEE-754/Decimal128 special +# numeric values (±Infinity, ±0, NaN) are still scalars, not arrays, and are +# rejected the same as any other non-array input. +SPECIAL_NUMERIC_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="nan_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": FLOAT_NAN}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject NaN input", + ), + ExpressionTestCase( + id="inf_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": FLOAT_INFINITY}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject Infinity input", + ), + ExpressionTestCase( + id="neg_inf_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": FLOAT_NEGATIVE_INFINITY}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject -Infinity input", + ), + ExpressionTestCase( + id="neg_zero_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": DOUBLE_NEGATIVE_ZERO}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject negative zero input", + ), + ExpressionTestCase( + id="decimal128_nan_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": DECIMAL128_NAN}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject Decimal128 NaN input", + ), + ExpressionTestCase( + id="decimal128_neg_nan_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": Decimal128("-NaN")}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject Decimal128 -NaN input", + ), + ExpressionTestCase( + id="decimal128_inf_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": DECIMAL128_INFINITY}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject Decimal128 Infinity input", + ), + ExpressionTestCase( + id="decimal128_neg_inf_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": DECIMAL128_NEGATIVE_INFINITY}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject Decimal128 -Infinity input", + ), + ExpressionTestCase( + id="decimal128_neg_zero_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": DECIMAL128_NEGATIVE_ZERO}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject Decimal128 -0 input", + ), +] + +# Property [Boundary-value non-array rejection]: min/max values for each +# numeric BSON type are rejected the same as any other non-array scalar — +# the not-array check has no numeric-magnitude special-casing at the +# boundaries. +BOUNDARY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="int32_max_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": INT32_MAX}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject INT32_MAX input", + ), + ExpressionTestCase( + id="int32_min_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": INT32_MIN}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject INT32_MIN input", + ), + ExpressionTestCase( + id="int64_max_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": INT64_MAX}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject INT64_MAX input", + ), + ExpressionTestCase( + id="int64_min_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": INT64_MIN}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject INT64_MIN input", + ), + ExpressionTestCase( + id="decimal128_max_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": DECIMAL128_MAX}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject DECIMAL128_MAX input", + ), + ExpressionTestCase( + id="decimal128_min_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": DECIMAL128_MIN}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject DECIMAL128_MIN input", + ), +] + +# Property [String-shape non-array rejection]: strings that merely look like +# array syntax (comma-separated or JSON array text) are still the string BSON +# type and are rejected, not parsed or reinterpreted as an array. +STRING_EDGE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="comma_separated_string_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": "1, 2, 3"}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject comma-separated string", + ), + ExpressionTestCase( + id="json_like_string_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, + doc={"arr": "[1, 2, 3]"}, + error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, + msg="Should reject JSON-like string", + ), +] + +# Property [Invalid sortBy scalar value]: a scalar sortBy must be exactly 1 +# or -1; any other numeric value (0, out-of-range, fractional, or a valid +# numeric type holding an invalid value like Int64(2)/Decimal128("1.5")) is +# rejected with SORT_ARRAY_INVALID_SORT_SCALAR_ERROR. +INVALID_SORTBY_SCALAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="sortby_zero", + expression={"$sortArray": {"input": "$arr", "sortBy": 0}}, + doc={"arr": [1, 2]}, + error_code=SORT_ARRAY_INVALID_SORT_SCALAR_ERROR, + msg="sortBy 0 should error", + ), + ExpressionTestCase( + id="sortby_two", + expression={"$sortArray": {"input": "$arr", "sortBy": 2}}, + doc={"arr": [1, 2]}, + error_code=SORT_ARRAY_INVALID_SORT_SCALAR_ERROR, + msg="sortBy 2 should error", + ), + ExpressionTestCase( + id="sortby_negative_two", + expression={"$sortArray": {"input": "$arr", "sortBy": -2}}, + doc={"arr": [1, 2]}, + error_code=SORT_ARRAY_INVALID_SORT_SCALAR_ERROR, + msg="sortBy -2 (negative out-of-range) should error", + ), + ExpressionTestCase( + id="sortby_fractional", + expression={"$sortArray": {"input": "$arr", "sortBy": 1.5}}, + doc={"arr": [1, 2]}, + error_code=SORT_ARRAY_INVALID_SORT_SCALAR_ERROR, + msg="sortBy 1.5 should error", + ), + ExpressionTestCase( + id="sortby_decimal128_fractional", + expression={"$sortArray": {"input": "$arr", "sortBy": Decimal128("1.5")}}, + doc={"arr": [1, 2]}, + error_code=SORT_ARRAY_INVALID_SORT_SCALAR_ERROR, + msg="sortBy Decimal128('1.5') should error like the double 1.5 case", + ), + ExpressionTestCase( + id="sortby_int64_two", + expression={"$sortArray": {"input": "$arr", "sortBy": Int64(2)}}, + doc={"arr": [1, 2]}, + error_code=SORT_ARRAY_INVALID_SORT_SCALAR_ERROR, + msg="sortBy Int64(2) should error — valid numeric type, invalid value", + ), +] + +# Property [Invalid sortBy type]: sortBy must be a numeric scalar (±1) or a +# field-spec document; any other BSON type (string, null, bool, array) is +# rejected with SORT_ARRAY_INVALID_SORT_TYPE_ERROR. +INVALID_SORTBY_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="sortby_string", + expression={"$sortArray": {"input": "$arr", "sortBy": "invalid"}}, + doc={"arr": [1, 2]}, + error_code=SORT_ARRAY_INVALID_SORT_TYPE_ERROR, + msg="sortBy string should error", + ), + ExpressionTestCase( + id="sortby_null", + expression={"$sortArray": {"input": "$arr", "sortBy": None}}, + doc={"arr": [1, 2]}, + error_code=SORT_ARRAY_INVALID_SORT_TYPE_ERROR, + msg="sortBy null should error", + ), + ExpressionTestCase( + id="sortby_bool", + expression={"$sortArray": {"input": "$arr", "sortBy": True}}, + doc={"arr": [1, 2]}, + error_code=SORT_ARRAY_INVALID_SORT_TYPE_ERROR, + msg="sortBy bool should error", + ), + ExpressionTestCase( + id="sortby_array", + expression={"$sortArray": {"input": "$arr", "sortBy": [1]}}, + doc={"arr": [1, 2]}, + error_code=SORT_ARRAY_INVALID_SORT_TYPE_ERROR, + msg="sortBy array should error", + ), +] + +# Property [Invalid sortBy field-spec value]: within a sortBy document, each +# field's direction value must be exactly 1 or -1; any other value (0, 2, +# a string, or an empty document with no fields at all) is rejected with +# SORT_ARRAY_INVALID_SORT_DOC_ERROR. +INVALID_SORTBY_DOC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="sortby_doc_zero", + expression={"$sortArray": {"input": "$arr", "sortBy": {"a": 0}}}, + doc={"arr": [{"a": 1}]}, + error_code=SORT_ARRAY_INVALID_SORT_DOC_ERROR, + msg="sortBy {a:0} should error", + ), + ExpressionTestCase( + id="sortby_doc_two", + expression={"$sortArray": {"input": "$arr", "sortBy": {"a": 2}}}, + doc={"arr": [{"a": 1}]}, + error_code=SORT_ARRAY_INVALID_SORT_DOC_ERROR, + msg="sortBy {a:2} should error", + ), + ExpressionTestCase( + id="sortby_doc_string", + expression={"$sortArray": {"input": "$arr", "sortBy": {"a": "asc"}}}, + doc={"arr": [{"a": 1}]}, + error_code=SORT_ARRAY_INVALID_SORT_DOC_ERROR, + msg="sortBy {a:'asc'} should error", + ), + ExpressionTestCase( + id="sortby_empty_doc", + expression={"$sortArray": {"input": "$arr", "sortBy": {}}}, + doc={"arr": [{"a": 1}]}, + error_code=SORT_ARRAY_INVALID_SORT_DOC_ERROR, + msg="sortBy {} should error", + ), +] + +ALL_TESTS = ( + NOT_ARRAY_ERROR_TESTS + + SPECIAL_NUMERIC_ERROR_TESTS + + BOUNDARY_ERROR_TESTS + + STRING_EDGE_ERROR_TESTS + + INVALID_SORTBY_SCALAR_TESTS + + INVALID_SORTBY_TYPE_TESTS + + INVALID_SORTBY_DOC_TESTS + + LITERAL_ERROR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_sortArray_not_array_insert(collection, test): + """Test $sortArray error with non-array input from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +# Property [Argument structure validation]: the `$sortArray` argument must be +# a well-formed object with exactly `input` and `sortBy` and no others; a +# missing/unknown field or non-object argument is rejected with its own +# named error. +STRUCTURE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="empty_object", + expression={"$sortArray": {}}, + error_code=SORT_ARRAY_MISSING_INPUT_ERROR, + msg="Empty object should error", + ), + ExpressionTestCase( + id="missing_input", + expression={"$sortArray": {"sortBy": 1}}, + error_code=SORT_ARRAY_MISSING_INPUT_ERROR, + msg="Missing input should error", + ), + ExpressionTestCase( + id="missing_sortby", + expression={"$sortArray": {"input": [1, 2, 3]}}, + error_code=SORT_ARRAY_MISSING_SORTBY_ERROR, + msg="Missing sortBy should error", + ), + ExpressionTestCase( + id="unknown_field", + expression={"$sortArray": {"input": [1], "sortBy": 1, "extra": 1}}, + error_code=SORT_ARRAY_UNKNOWN_FIELD_ERROR, + msg="Unknown field should error", + ), + ExpressionTestCase( + id="non_object_arg_array", + expression={"$sortArray": [[1, 2, 3], 1]}, + error_code=SORT_ARRAY_NON_OBJECT_ARG_ERROR, + msg="Array argument should error", + ), + ExpressionTestCase( + id="non_object_arg_scalar", + expression={"$sortArray": 1}, + error_code=SORT_ARRAY_NON_OBJECT_ARG_ERROR, + msg="Scalar argument should error", + ), + ExpressionTestCase( + id="non_object_arg_string", + expression={"$sortArray": "hello"}, + error_code=SORT_ARRAY_NON_OBJECT_ARG_ERROR, + msg="String argument should error", + ), + ExpressionTestCase( + id="non_object_arg_null", + expression={"$sortArray": None}, + error_code=SORT_ARRAY_NON_OBJECT_ARG_ERROR, + msg="Null argument should error", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(STRUCTURE_ERROR_TESTS)) +def test_sortArray_argument_handling(collection, test): + """Test $sortArray argument structure validation.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +# Property [Error precedence] (Rule 18): when multiple argument problems +# co-occur, document which error wins, determined empirically — invalid +# sortBy beats non-array/null input, and missing-input beats missing-sortBy +# beats non-array input. +PRECEDENCE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="invalid_sortby_beats_non_array_input", + expression={"$sortArray": {"input": "hello", "sortBy": 0}}, + error_code=SORT_ARRAY_INVALID_SORT_SCALAR_ERROR, + msg="Invalid sortBy (0) is reported over non-array input", + ), + ExpressionTestCase( + id="invalid_sortby_beats_null_input", + expression={"$sortArray": {"input": "$arr", "sortBy": 0}}, + doc={"arr": None}, + error_code=SORT_ARRAY_INVALID_SORT_SCALAR_ERROR, + msg="Invalid sortBy (0) is reported instead of null propagation", + ), + ExpressionTestCase( + id="missing_input_beats_missing_sortby", + expression={"$sortArray": {}}, + error_code=SORT_ARRAY_MISSING_INPUT_ERROR, + msg="Missing input is reported before missing sortBy", + ), + ExpressionTestCase( + id="missing_sortby_beats_non_array_input", + expression={"$sortArray": {"input": "hello"}}, + error_code=SORT_ARRAY_MISSING_SORTBY_ERROR, + msg="Missing sortBy is reported before a non-array input type error", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(PRECEDENCE_ERROR_TESTS)) +def test_sortArray_error_precedence(collection, test): + """Document which error wins when multiple argument problems co-occur (Rule 18).""" + if test.doc is not None: + result = execute_expression_with_insert(collection, test.expression, test.doc) + else: + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_expressions.py new file mode 100644 index 000000000..5b5f57720 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_expressions.py @@ -0,0 +1,270 @@ +""" +Expression and field path tests for $sortArray expression. + +Tests field path lookups (including composite array-of-objects paths), +$let and system variables ($$ROOT/$$CURRENT/$$REMOVE), null/missing field +handling, and float NaN sort ordering. +""" + +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 +from documentdb_tests.framework.test_constants import FLOAT_NAN + +# Property [Field-path operand]: the input may be a (possibly nested) field +# path rather than a literal array; a path segment that doesn't resolve to a +# value returns null instead of erroring. +FIELD_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="nested_field_path", + expression={"$sortArray": {"input": "$a.b", "sortBy": 1}}, + doc={"a": {"b": [3, 1, 2]}}, + expected=[1, 2, 3], + msg="Should resolve nested field path", + ), + ExpressionTestCase( + id="deeply_nested_field", + expression={"$sortArray": {"input": "$a.b.c", "sortBy": -1}}, + doc={"a": {"b": {"c": [30, 10, 20]}}}, + expected=[30, 20, 10], + msg="Should resolve deeply nested field path", + ), + ExpressionTestCase( + id="nonexistent_field_null", + expression={"$sortArray": {"input": "$a.nonexistent", "sortBy": 1}}, + doc={"a": {"missing": 1}}, + expected=None, + msg="Non-existent field should return null", + ), +] + +# Property [Composite/positional path resolution]: field paths that traverse +# array-of-objects (composite paths) or numeric-looking path segments resolve +# per the engine's path semantics (object key vs. array index vs. no match) +# before being sorted. +COMPOSITE_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="composite_array", + expression={"$sortArray": {"input": "$x.y", "sortBy": 1}}, + doc={"x": [{"y": 30}, {"y": 10}, {"y": 20}]}, + expected=[10, 20, 30], + msg="Composite array path from array-of-objects", + ), + ExpressionTestCase( + id="object_key_zero", + expression={"$sortArray": {"input": "$a.0", "sortBy": 1}}, + doc={"a": {"0": [3, 1, 2]}}, + expected=[1, 2, 3], + msg="$a.0 resolves as field named '0' on object", + ), + ExpressionTestCase( + id="numeric_index_on_array", + expression={"$sortArray": {"input": "$a.0", "sortBy": 1}}, + doc={"a": [[3, 1, 2], [6, 4, 5]]}, + expected=[], + msg="Numeric index $a.0 on array-of-arrays resolves to empty", + ), +] + +# Property [$let and system-variable operand]: the input may be a $let-bound +# variable or a system variable ($$ROOT/$$CURRENT), resolved to its +# underlying array the same way a plain field path would be. +SYSTEM_VAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="let_variable", + expression={ + "$let": {"vars": {"arr": "$arr"}, "in": {"$sortArray": {"input": "$$arr", "sortBy": 1}}} + }, + doc={"arr": [3, 1, 2]}, + expected=[1, 2, 3], + msg="Should work with $let variable", + ), + ExpressionTestCase( + id="root_variable", + expression={"$sortArray": {"input": "$$ROOT.values", "sortBy": 1}}, + doc={"_id": 1, "values": [3, 1, 2]}, + expected=[1, 2, 3], + msg="Should work with $$ROOT", + ), + ExpressionTestCase( + id="current_variable", + expression={"$sortArray": {"input": "$$CURRENT.values", "sortBy": 1}}, + doc={"_id": 2, "values": [3, 1, 2]}, + expected=[1, 2, 3], + msg="$$CURRENT should be equivalent to field path", + ), +] + +# Property [Null/missing input via expression resolution]: a missing field or +# $$REMOVE resolves to null and propagates as null rather than erroring. +NULL_MISSING_EXPR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="missing_field", + expression={"$sortArray": {"input": "$nonexistent", "sortBy": 1}}, + doc={"other": 1}, + expected=None, + msg="Missing field should return null", + ), + ExpressionTestCase( + id="missing_input_type_is_null", + expression={"$type": {"$sortArray": {"input": "$nonexistent", "sortBy": 1}}}, + doc={"x": 1}, + expected="null", + msg="Missing field should produce null type", + ), + ExpressionTestCase( + id="remove_variable", + expression={"$sortArray": {"input": "$$REMOVE", "sortBy": 1}}, + doc={"x": 1}, + expected=None, + msg="$$REMOVE returns null", + ), +] + +# Property [NaN sort ordering]: float NaN elements sort before all numbers +# (including other NaNs, which retain their relative positions among +# themselves), asserted via $arrayElemAt + pytest.approx since NaN can't be +# equality-compared inside a list. +FLOAT_NAN_SORT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="float_nan_sorts_first", + expression={"$arrayElemAt": [{"$sortArray": {"input": "$arr", "sortBy": 1}}, 0]}, + doc={"arr": [1, FLOAT_NAN, 3, FLOAT_NAN, 2]}, + expected=pytest.approx(FLOAT_NAN, nan_ok=True), + msg="Float NaN should sort before numbers", + ), + ExpressionTestCase( + id="float_nan_second_position", + expression={"$arrayElemAt": [{"$sortArray": {"input": "$arr", "sortBy": 1}}, 1]}, + doc={"arr": [1, FLOAT_NAN, 3, FLOAT_NAN, 2]}, + expected=pytest.approx(FLOAT_NAN, nan_ok=True), + msg="Second NaN should also sort before numbers", + ), + ExpressionTestCase( + id="non_nan_after_nan", + expression={"$arrayElemAt": [{"$sortArray": {"input": "$arr", "sortBy": 1}}, 2]}, + doc={"arr": [1, FLOAT_NAN, 3, FLOAT_NAN, 2]}, + expected=1, + msg="First non-NaN value after NaN sorting", + ), +] + +# Property [Expression-operator input] (Rule 3): `input` may be produced by +# an expression operator or an array expression of field refs, not just a +# literal/field path. +EXPRESSION_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="input_from_concatArrays", + expression={"$sortArray": {"input": {"$concatArrays": [[3], [1, 2]]}, "sortBy": 1}}, + doc={"x": 0}, + expected=[1, 2, 3], + msg="Should sort input produced by $concatArrays", + ), + ExpressionTestCase( + id="input_array_of_field_refs", + expression={"$sortArray": {"input": ["$x", "$y"], "sortBy": 1}}, + doc={"x": 3, "y": 1}, + expected=[1, 3], + msg="Should sort an array expression built from field references", + ), +] + +# Property [Algebraic invariants] (Rule 17): idempotency, the sortBy-direction +# relationship, and length / multiset (permutation) invariants. (Stability is +# covered elsewhere.) +ALGEBRAIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="idempotent_sort", + expression={ + "$eq": [ + { + "$sortArray": { + "input": {"$sortArray": {"input": "$arr", "sortBy": 1}}, + "sortBy": 1, + } + }, + {"$sortArray": {"input": "$arr", "sortBy": 1}}, + ] + }, + doc={"arr": [3, 1, 2]}, + expected=True, + msg="Idempotency: sorting an already-sorted array yields the same result", + ), + ExpressionTestCase( + id="direction_reverse_of_asc_equals_desc", + expression={ + "$eq": [ + {"$reverseArray": {"$sortArray": {"input": "$arr", "sortBy": 1}}}, + {"$sortArray": {"input": "$arr", "sortBy": -1}}, + ] + }, + doc={"arr": [3, 1, 2, 5, 4]}, + expected=True, + msg="Direction relationship: reverse of asc-sort equals desc-sort (no cross-type ties)", + ), + ExpressionTestCase( + id="length_invariant_empty", + expression={ + "$eq": [{"$size": {"$sortArray": {"input": "$arr", "sortBy": 1}}}, {"$size": "$arr"}] + }, + doc={"arr": []}, + expected=True, + msg="Length invariant (size 0)", + ), + ExpressionTestCase( + id="length_invariant_single", + expression={ + "$eq": [{"$size": {"$sortArray": {"input": "$arr", "sortBy": 1}}}, {"$size": "$arr"}] + }, + doc={"arr": [9]}, + expected=True, + msg="Length invariant (size 1)", + ), + ExpressionTestCase( + id="length_invariant_five", + expression={ + "$eq": [{"$size": {"$sortArray": {"input": "$arr", "sortBy": 1}}}, {"$size": "$arr"}] + }, + doc={"arr": [5, 4, 3, 2, 1]}, + expected=True, + msg="Length invariant (size 5)", + ), + ExpressionTestCase( + id="multiset_permutation_invariant", + expression={ + "$eq": [ + {"$sortArray": {"input": "$a", "sortBy": 1}}, + {"$sortArray": {"input": "$b", "sortBy": 1}}, + ] + }, + doc={"a": [3, 1, 2, 3], "b": [2, 3, 1, 3]}, + expected=True, + msg="Multiset/permutation invariant: two permutations of a multiset sort identically", + ), +] + +ALL_EXPR_TESTS = ( + FIELD_LOOKUP_TESTS + + COMPOSITE_PATH_TESTS + + SYSTEM_VAR_TESTS + + NULL_MISSING_EXPR_TESTS + + EXPRESSION_INPUT_TESTS + + ALGEBRAIC_TESTS + + FLOAT_NAN_SORT_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_EXPR_TESTS)) +def test_sortArray_expression(collection, test): + """Test $sortArray 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/test_expressions_combination_objectToArray.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_objectToArray.py new file mode 100644 index 000000000..de9b8a912 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_objectToArray.py @@ -0,0 +1,75 @@ +""" +Combination tests for $objectToArray 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 ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +OBJECT_TO_ARRAY_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="objectToArray_roundtrip_with_arrayToObject", + expression={"$arrayToObject": {"$objectToArray": "$obj"}}, + doc={"obj": {"a": 1, "b": 2, "c": 3}}, + expected={"a": 1, "b": 2, "c": 3}, + msg="Roundtrip should restore original object", + ), + ExpressionTestCase( + id="objectToArray_roundtrip_preserves_null_in_nested", + expression={"$arrayToObject": {"$objectToArray": "$nested"}}, + doc={"nested": {"empty_nested": {}, "null_nested": None}}, + expected={"empty_nested": {}, "null_nested": None}, + msg="Round-trip should preserve null values in nested doc", + ), + ExpressionTestCase( + id="objectToArray_of_arrayToObject", + expression={"$objectToArray": {"$arrayToObject": "$kv"}}, + doc={"kv": [{"k": "a", "v": 1}, {"k": "b", "v": 2}]}, + expected=[{"k": "a", "v": 1}, {"k": "b", "v": 2}], + msg="Array-side inverse: objectToArray(arrayToObject(kv_array)) restores the kv array", + ), + ExpressionTestCase( + id="objectToArray_on_mergeObjects", + expression={"$objectToArray": {"$mergeObjects": ["$a", "$b"]}}, + doc={"a": {"a": 1}, "b": {"b": 2}}, + expected=[{"k": "a", "v": 1}, {"k": "b", "v": 2}], + msg="Should convert merged object", + ), + ExpressionTestCase( + id="objectToArray_1000_fields", + expression={"$size": {"$objectToArray": "$obj"}}, + doc={"obj": {f"field_{i}": i for i in range(1000)}}, + expected=1000, + msg="Should handle 1000 fields", + ), + ExpressionTestCase( + id="objectToArray_with_arrayElemAt", + expression={"$arrayElemAt": [{"$objectToArray": "$obj"}, 0]}, + doc={"obj": {"a": 1, "b": 2, "c": 3}}, + expected={"k": "a", "v": 1}, + msg="Should return first element", + ), + ExpressionTestCase( + id="objectToArray_with_concatArrays", + expression={"$concatArrays": [{"$objectToArray": "$a"}, {"$objectToArray": "$b"}]}, + doc={"a": {"a": 1}, "b": {"b": 2}}, + expected=[{"k": "a", "v": 1}, {"k": "b", "v": 2}], + msg="Should concatenate two arrays", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(OBJECT_TO_ARRAY_COMBINATION_TESTS)) +def test_objectToArray_combination(collection, test): + """Test $objectToArray composed with other operators.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_reverseArray.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_reverseArray.py new file mode 100644 index 000000000..992483d62 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_reverseArray.py @@ -0,0 +1,88 @@ +""" +Combination tests for $reverseArray 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 ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +REVERSE_ARRAY_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="reverseArray_on_concatArrays", + expression={"$reverseArray": {"$concatArrays": ["$a", "$b"]}}, + doc={"a": [1, 2], "b": [3, 4]}, + expected=[4, 3, 2, 1], + msg="Reverse concatenated arrays", + ), + ExpressionTestCase( + id="reverseArray_on_sortArray", + expression={"$reverseArray": {"$sortArray": {"input": "$arr", "sortBy": 1}}}, + doc={"arr": [3, 1, 2]}, + expected=[3, 2, 1], + msg="Reverse sorted array", + ), + ExpressionTestCase( + id="reverseArray_then_slice_last_n_reversed_order", + expression={"$slice": [{"$reverseArray": "$arr"}, 2]}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[5, 4], + msg="reverse then $slice yields the last N elements in reversed order", + ), + ExpressionTestCase( + id="reverseArray_on_objectToArray", + expression={"$reverseArray": {"$objectToArray": "$obj"}}, + doc={"obj": {"a": 1, "b": 2, "c": 3}}, + expected=[{"k": "c", "v": 3}, {"k": "b", "v": 2}, {"k": "a", "v": 1}], + msg="Reverse objectToArray", + ), + ExpressionTestCase( + id="reverseArray_concat_reversed", + expression={"$concatArrays": [{"$reverseArray": "$a"}, {"$reverseArray": "$b"}]}, + doc={"a": [1, 2], "b": [3, 4]}, + expected=[2, 1, 4, 3], + msg="Concat of two reversed arrays", + ), + ExpressionTestCase( + id="reverseArray_map_subarrays", + expression={"$map": {"input": "$arr", "in": {"$reverseArray": "$$this"}}}, + doc={"arr": [[1, 2], [3, 4]]}, + expected=[[2, 1], [4, 3]], + msg="$map reverses each subarray", + ), + ExpressionTestCase( + id="reverseArray_inside_reduce", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", {"$reverseArray": "$$this"}]}, + } + }, + doc={"arr": [[1, 2], [3, 4]]}, + expected=[2, 1, 4, 3], + msg="$reverseArray used inside $reduce reverses each subarray while accumulating", + ), + ExpressionTestCase( + id="reverseArray_slice_last_n_original_order", + expression={"$reverseArray": {"$slice": [{"$reverseArray": "$arr"}, 2]}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[4, 5], + msg="reverse + $slice + reverse yields last N elements in original order", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(REVERSE_ARRAY_COMBINATION_TESTS)) +def test_reverseArray_combination(collection, test): + """Test $reverseArray composed with other operators.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_sortArray.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_sortArray.py new file mode 100644 index 000000000..58d65ee45 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_sortArray.py @@ -0,0 +1,111 @@ +""" +Combination tests for $sortArray 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 ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +SORT_ARRAY_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="sortArray_on_filter", + expression={ + "$sortArray": { + "input": {"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 2]}}}, + "sortBy": 1, + } + }, + doc={"arr": [3, 1, 4, 1, 5]}, + expected=[3, 4, 5], + msg="Should sort $filter result", + ), + ExpressionTestCase( + id="sortArray_on_concatArrays", + expression={"$sortArray": {"input": {"$concatArrays": ["$a", "$b"]}, "sortBy": 1}}, + doc={"a": [3, 1], "b": [2, 4]}, + expected=[1, 2, 3, 4], + msg="Should sort $concatArrays result", + ), + ExpressionTestCase( + id="sortArray_on_reverseArray", + expression={"$sortArray": {"input": {"$reverseArray": "$arr"}, "sortBy": 1}}, + doc={"arr": [3, 1, 2]}, + expected=[1, 2, 3], + msg="Should sort $reverseArray result", + ), + ExpressionTestCase( + id="sortArray_reverseArray_sortArray", + expression={ + "$sortArray": { + "input": {"$reverseArray": {"$sortArray": {"input": "$arr", "sortBy": 1}}}, + "sortBy": 1, + } + }, + doc={"arr": [3, 1, 2]}, + expected=[1, 2, 3], + msg="Should sort $reverseArray of $sortArray result", + ), + ExpressionTestCase( + id="sortArray_on_range", + expression={"$sortArray": {"input": {"$range": ["$start", "$end", "$step"]}, "sortBy": 1}}, + doc={"start": 5, "end": 0, "step": -1}, + expected=[1, 2, 3, 4, 5], + msg="Should sort $range result", + ), + ExpressionTestCase( + id="sortArray_on_setUnion", + expression={"$sortArray": {"input": {"$setUnion": [[3, 1], [2, 1]]}, "sortBy": 1}}, + doc={"x": 0}, + expected=[1, 2, 3], + msg="Should sort $setUnion result (manual B24)", + ), + ExpressionTestCase( + id="sortArray_result_arrayElemAt", + expression={"$arrayElemAt": [{"$sortArray": {"input": "$arr", "sortBy": 1}}, 0]}, + doc={"arr": [3, 1, 2]}, + expected=1, + msg="$arrayElemAt on $sortArray result returns first sorted element", + ), + ExpressionTestCase( + id="sortArray_result_in_eq", + expression={"$eq": [{"$sortArray": {"input": "$arr", "sortBy": 1}}, [1, 2, 3]]}, + doc={"arr": [3, 1, 2]}, + expected=True, + msg="$eq on $sortArray result", + ), + ExpressionTestCase( + id="sortArray_objectToArray_by_k", + expression={"$sortArray": {"input": {"$objectToArray": "$obj"}, "sortBy": {"k": 1}}}, + doc={"obj": {"c": 3, "a": 1, "b": 2}}, + expected=[{"k": "a", "v": 1}, {"k": "b", "v": 2}, {"k": "c", "v": 3}], + msg="Sort $objectToArray output by key field 'k' (manual B22)", + ), + ExpressionTestCase( + id="sortArray_nested_in_sortArray", + expression={ + "$sortArray": { + "input": {"$sortArray": {"input": "$arr", "sortBy": -1}}, + "sortBy": 1, + } + }, + doc={"arr": [3, 1, 2]}, + expected=[1, 2, 3], + msg="Nested $sortArray inside $sortArray — inner desc then outer asc (manual B25)", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SORT_ARRAY_COMBINATION_TESTS)) +def test_sortArray_combination(collection, test): + """Test $sortArray composed with other operators.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) From 950c2daf7cdef9fbec8c57c9e9be7b6794ebb60f Mon Sep 17 00:00:00 2001 From: "Victor [C] Tsang" Date: Fri, 24 Jul 2026 20:32:24 +0000 Subject: [PATCH 2/4] address reviewer comments, remove if and else condition for test executor in error Signed-off-by: Victor [C] Tsang --- ...est_expression_objectToArray_bson_types.py | 50 ++++- ..._expression_objectToArray_core_behavior.py | 28 ++- .../test_expression_objectToArray_errors.py | 101 ++++++---- ...test_expression_reverseArray_bson_types.py | 31 ++- ...t_expression_reverseArray_core_behavior.py | 32 +++- .../test_expression_reverseArray_errors.py | 111 +++++++---- .../test_expression_sortArray_bson_types.py | 14 +- .../test_expression_sortArray_errors.py | 176 +++++++----------- 8 files changed, 341 insertions(+), 202 deletions(-) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_bson_types.py index 975728b61..46b362a29 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_bson_types.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_bson_types.py @@ -44,42 +44,61 @@ TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ ExpressionTestCase( id="value_int64", + expression={"$objectToArray": {"a": Int64(99)}}, doc={"obj": {"a": Int64(99)}}, expected=[{"k": "a", "v": Int64(99)}], msg="Should preserve Int64 value", ), ExpressionTestCase( id="value_binary", + expression={"$objectToArray": {"a": Binary(b"\x01\x02", 0)}}, doc={"obj": {"a": Binary(b"\x01\x02", 0)}}, expected=[{"k": "a", "v": b"\x01\x02"}], msg="Should preserve Binary value", ), ExpressionTestCase( id="value_uuid", + expression={ + "$objectToArray": {"a": Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210"))} + }, doc={"obj": {"a": Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210"))}}, expected=[{"k": "a", "v": Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210"))}], msg="Should preserve UUID binary value", ), ExpressionTestCase( id="value_infinity", + expression={"$objectToArray": {"a": FLOAT_INFINITY}}, doc={"obj": {"a": FLOAT_INFINITY}}, expected=[{"k": "a", "v": FLOAT_INFINITY}], msg="Should preserve Infinity value", ), ExpressionTestCase( id="value_int32_max", + expression={"$objectToArray": {"a": INT32_MAX}}, doc={"obj": {"a": INT32_MAX}}, expected=[{"k": "a", "v": INT32_MAX}], msg="Should preserve INT32_MAX value", ), ExpressionTestCase( id="nested_bson_in_object_value", + expression={"$objectToArray": {"a": {"x": Int64(1), "y": Decimal128("2.5")}}}, doc={"obj": {"a": {"x": Int64(1), "y": Decimal128("2.5")}}}, expected=[{"k": "a", "v": {"x": Int64(1), "y": Decimal128("2.5")}}], msg="Should preserve nested BSON types in object value", ), ExpressionTestCase( id="mixed_bson_types", + expression={ + "$objectToArray": { + "int64": Int64(1), + "dec": Decimal128("1.5"), + "dt": datetime(2024, 1, 1, tzinfo=timezone.utc), + "oid": ObjectId("000000000000000000000001"), + "bin": Binary(b"\x01", 0), + "ts": Timestamp(0, 0), + "min": MinKey(), + } + }, doc={ "obj": { "int64": Int64(1), @@ -108,7 +127,7 @@ @pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) def test_objectToArray_bson_literal(collection, test): """Test $objectToArray BSON types with literal values.""" - result = execute_expression(collection, {"$objectToArray": test.doc["obj"]}) + result = execute_expression(collection, test.expression) assert_expression_result( result, expected=test.expected, error_code=test.error_code, msg=test.msg ) @@ -120,60 +139,70 @@ def test_objectToArray_bson_literal(collection, test): BSON_VALUE_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( id="value_decimal128", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": Decimal128("3.14")}}, expected=[{"k": "a", "v": Decimal128("3.14")}], msg="Should preserve Decimal128 value", ), ExpressionTestCase( id="value_datetime", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": datetime(2024, 1, 1, tzinfo=timezone.utc)}}, expected=[{"k": "a", "v": datetime(2024, 1, 1, tzinfo=timezone.utc)}], msg="Should preserve datetime value", ), ExpressionTestCase( id="value_objectid", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": ObjectId("000000000000000000000001")}}, expected=[{"k": "a", "v": ObjectId("000000000000000000000001")}], msg="Should preserve ObjectId value", ), ExpressionTestCase( id="value_bool_false", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": False}}, expected=[{"k": "a", "v": False}], msg="Should preserve false value", ), ExpressionTestCase( id="value_bool_true", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": True}}, expected=[{"k": "a", "v": True}], msg="Should preserve true value", ), ExpressionTestCase( id="value_regex", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": Regex("^abc", "i")}}, expected=[{"k": "a", "v": Regex("^abc", "i")}], msg="Should preserve regex value", ), ExpressionTestCase( id="value_minkey", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": MinKey()}}, expected=[{"k": "a", "v": MinKey()}], msg="Should preserve MinKey value", ), ExpressionTestCase( id="value_maxkey", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": MaxKey()}}, expected=[{"k": "a", "v": MaxKey()}], msg="Should preserve MaxKey value", ), ExpressionTestCase( id="value_timestamp", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": Timestamp(1234567890, 1)}}, expected=[{"k": "a", "v": Timestamp(1234567890, 1)}], msg="Should preserve Timestamp value", ), ExpressionTestCase( id="value_code", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": Code("x")}}, expected=[{"k": "a", "v": Code("x")}], msg="Should preserve JavaScript Code value", @@ -186,60 +215,70 @@ def test_objectToArray_bson_literal(collection, test): SPECIAL_NUMERIC_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( id="value_neg_infinity", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": FLOAT_NEGATIVE_INFINITY}}, expected=[{"k": "a", "v": FLOAT_NEGATIVE_INFINITY}], msg="Should preserve -Infinity value", ), ExpressionTestCase( id="value_neg_zero", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": DOUBLE_NEGATIVE_ZERO}}, expected=[{"k": "a", "v": DOUBLE_NEGATIVE_ZERO}], msg="Should preserve negative zero value", ), ExpressionTestCase( id="value_decimal128_nan", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": DECIMAL128_NAN}}, expected=[{"k": "a", "v": DECIMAL128_NAN}], msg="Should preserve Decimal128 NaN value", ), ExpressionTestCase( id="value_decimal128_infinity", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": DECIMAL128_INFINITY}}, expected=[{"k": "a", "v": DECIMAL128_INFINITY}], msg="Should preserve Decimal128 Infinity value", ), ExpressionTestCase( id="value_decimal128_neg_infinity", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": DECIMAL128_NEGATIVE_INFINITY}}, expected=[{"k": "a", "v": DECIMAL128_NEGATIVE_INFINITY}], msg="Should preserve Decimal128 -Infinity value", ), ExpressionTestCase( id="value_decimal128_neg_zero", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": DECIMAL128_NEGATIVE_ZERO}}, expected=[{"k": "a", "v": DECIMAL128_NEGATIVE_ZERO}], msg="Should preserve Decimal128 -0 value", ), ExpressionTestCase( id="value_decimal128_high_precision", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": Decimal128("1.234567890123456789012345678901234")}}, expected=[{"k": "a", "v": Decimal128("1.234567890123456789012345678901234")}], msg="Should preserve full Decimal128 precision", ), ExpressionTestCase( id="value_decimal128_zero_exponent", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": Decimal128("0E+10")}}, expected=[{"k": "a", "v": Decimal128("0E+10")}], msg="Should preserve Decimal128 exponent notation", ), ExpressionTestCase( id="value_decimal128_trailing_zeros", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": Decimal128("1.00000")}}, expected=[{"k": "a", "v": Decimal128("1.00000")}], msg="Should preserve Decimal128 trailing zeros", ), ExpressionTestCase( id="value_decimal128_subnormal_zero", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": Decimal128("0E-6176")}}, expected=[{"k": "a", "v": Decimal128("0E-6176")}], msg="Should preserve Decimal128 subnormal zero", @@ -251,30 +290,35 @@ def test_objectToArray_bson_literal(collection, test): BOUNDARY_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( id="value_int32_min", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": INT32_MIN}}, expected=[{"k": "a", "v": INT32_MIN}], msg="Should preserve INT32_MIN value", ), ExpressionTestCase( id="value_int64_max", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": INT64_MAX}}, expected=[{"k": "a", "v": INT64_MAX}], msg="Should preserve INT64_MAX value", ), ExpressionTestCase( id="value_int64_min", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": INT64_MIN}}, expected=[{"k": "a", "v": INT64_MIN}], msg="Should preserve INT64_MIN value", ), ExpressionTestCase( id="value_decimal128_max", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": DECIMAL128_MAX}}, expected=[{"k": "a", "v": DECIMAL128_MAX}], msg="Should preserve DECIMAL128_MAX value", ), ExpressionTestCase( id="value_decimal128_min", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": DECIMAL128_MIN}}, expected=[{"k": "a", "v": DECIMAL128_MIN}], msg="Should preserve DECIMAL128_MIN value", @@ -287,6 +331,7 @@ def test_objectToArray_bson_literal(collection, test): NESTED_BSON_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( id="nested_bson_in_array_value", + expression={"$objectToArray": "$obj"}, doc={ "obj": { "a": [ @@ -310,6 +355,7 @@ def test_objectToArray_bson_literal(collection, test): ), ExpressionTestCase( id="deeply_nested_bson", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": {"x": [{"y": Decimal128("1.5")}, Timestamp(0, 0)]}}}, expected=[{"k": "a", "v": {"x": [{"y": Decimal128("1.5")}, Timestamp(0, 0)]}}], msg="Should preserve deeply nested BSON types", @@ -328,7 +374,7 @@ def test_objectToArray_bson_literal(collection, test): @pytest.mark.parametrize("test", pytest_params(ALL_BSON_TESTS)) def test_objectToArray_bson_insert(collection, test): """Test $objectToArray BSON types with values from inserted documents.""" - result = execute_expression_with_insert(collection, {"$objectToArray": "$obj"}, test.doc) + 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/objectToArray/test_expression_objectToArray_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_core_behavior.py index 84221bf77..b76135165 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_core_behavior.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_core_behavior.py @@ -29,12 +29,14 @@ TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ ExpressionTestCase( id="single_field", + expression={"$objectToArray": {"a": 1}}, doc={"obj": {"a": 1}}, expected=[{"k": "a", "v": 1}], msg="Should convert single-field object", ), ExpressionTestCase( id="mixed_value_types", + expression={"$objectToArray": {"int": 1, "str": "hello", "bool": True, "null": None}}, doc={"obj": {"int": 1, "str": "hello", "bool": True, "null": None}}, expected=[ {"k": "int", "v": 1}, @@ -46,24 +48,28 @@ ), ExpressionTestCase( id="empty_object", + expression={"$objectToArray": {}}, doc={"obj": {}}, expected=[], msg="Should return empty array for empty object", ), ExpressionTestCase( id="null_object", + expression={"$objectToArray": None}, doc={"obj": None}, expected=None, msg="Should return null for null object", ), ExpressionTestCase( id="deeply_nested_not_recursive", + expression={"$objectToArray": {"a": {"b": {"c": {"d": 1}}}}}, doc={"obj": {"a": {"b": {"c": {"d": 1}}}}}, expected=[{"k": "a", "v": {"b": {"c": {"d": 1}}}}], msg="Should NOT recursively decompose nested docs", ), ExpressionTestCase( id="case_sensitive_keys", + expression={"$objectToArray": {"a": 1, "A": 2}}, doc={"obj": {"a": 1, "A": 2}}, expected=[{"k": "a", "v": 1}, {"k": "A", "v": 2}], msg="Case-different keys should be distinct", @@ -74,7 +80,7 @@ @pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) def test_objectToArray_literal(collection, test): """Test $objectToArray with literal values.""" - result = execute_expression(collection, {"$objectToArray": test.doc["obj"]}) + result = execute_expression(collection, test.expression) assert_expression_result( result, expected=test.expected, error_code=test.error_code, msg=test.msg ) @@ -85,30 +91,35 @@ def test_objectToArray_literal(collection, test): BASIC_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( id="multiple_fields", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": 1, "b": 2, "c": 3}}, expected=[{"k": "a", "v": 1}, {"k": "b", "v": 2}, {"k": "c", "v": 3}], msg="Should convert multi-field object", ), ExpressionTestCase( id="string_values", + expression={"$objectToArray": "$obj"}, doc={"obj": {"name": "Alice", "city": "Mycity"}}, expected=[{"k": "name", "v": "Alice"}, {"k": "city", "v": "Mycity"}], msg="Should convert object with string values", ), ExpressionTestCase( id="nested_object_value", + expression={"$objectToArray": "$obj"}, doc={"obj": {"obj": {"x": 1, "y": 2}}}, expected=[{"k": "obj", "v": {"x": 1, "y": 2}}], msg="Should convert object with nested object value", ), ExpressionTestCase( id="array_value", + expression={"$objectToArray": "$obj"}, doc={"obj": {"arr": [1, 2, 3]}}, expected=[{"k": "arr", "v": [1, 2, 3]}], msg="Should convert object with array value", ), ExpressionTestCase( id="empty_array_value", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": []}}, expected=[{"k": "a", "v": []}], msg="Should convert object with an empty-array value", @@ -121,36 +132,42 @@ def test_objectToArray_literal(collection, test): SPECIAL_KEY_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( id="empty_string_key", + expression={"$objectToArray": "$obj"}, doc={"obj": {"": 1}}, expected=[{"k": "", "v": 1}], msg="Should handle empty string key", ), ExpressionTestCase( id="dotted_key", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a.b.c": 1}}, expected=[{"k": "a.b.c", "v": 1}], msg="Should handle dotted key", ), ExpressionTestCase( id="unicode_key", + expression={"$objectToArray": "$obj"}, doc={"obj": {"日本語": 1}}, expected=[{"k": "日本語", "v": 1}], msg="Should handle unicode key", ), ExpressionTestCase( id="dollar_sign_key", + expression={"$objectToArray": "$obj"}, doc={"obj": {"$field": 1}}, expected=[{"k": "$field", "v": 1}], msg="Should handle dollar-sign key", ), ExpressionTestCase( id="key_with_spaces", + expression={"$objectToArray": "$obj"}, doc={"obj": {"my key": 1}}, expected=[{"k": "my key", "v": 1}], msg="Should handle key with spaces", ), ExpressionTestCase( id="numeric_string_keys", + expression={"$objectToArray": "$obj"}, doc={"obj": {"0": "a", "1": "b"}}, expected=[{"k": "0", "v": "a"}, {"k": "1", "v": "b"}], msg="Should handle numeric-looking keys as strings", @@ -163,12 +180,14 @@ def test_objectToArray_literal(collection, test): NON_RECURSIVE_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( id="value_looks_like_kv_pair", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": {"k": "x", "v": 1}}}, expected=[{"k": "a", "v": {"k": "x", "v": 1}}], msg="Should NOT interpret value as pre-existing k/v pair", ), ExpressionTestCase( id="nested_array_with_object", + expression={"$objectToArray": "$obj"}, doc={"obj": {"k1": [1, {"a": {"b": 1}}]}}, expected=[{"k": "k1", "v": [1, {"a": {"b": 1}}]}], msg="Should preserve nested array containing objects", @@ -181,30 +200,35 @@ def test_objectToArray_literal(collection, test): EDGE_CASE_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( id="preserves_field_order", + expression={"$objectToArray": "$obj"}, doc={"obj": {"qty": 25, "item": "abc123"}}, expected=[{"k": "qty", "v": 25}, {"k": "item", "v": "abc123"}], msg="Should preserve field order", ), ExpressionTestCase( id="many_fields_order", + expression={"$objectToArray": "$obj"}, doc={"obj": {f"field_{i}": i for i in range(12)}}, expected=[{"k": f"field_{i}", "v": i} for i in range(12)], msg="Should preserve order for 10+ fields", ), ExpressionTestCase( id="null_value_in_object", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": None, "b": 1}}, expected=[{"k": "a", "v": None}, {"k": "b", "v": 1}], msg="Should preserve null values", ), ExpressionTestCase( id="all_null_values", + expression={"$objectToArray": "$obj"}, doc={"obj": {"a": None, "b": None}}, expected=[{"k": "a", "v": None}, {"k": "b", "v": None}], msg="Should preserve all null values", ), ExpressionTestCase( id="long_key_name", + expression={"$objectToArray": "$obj"}, doc={"obj": {"k" * 1000: 1}}, expected=[{"k": "k" * 1000, "v": 1}], msg="Should preserve 1000-char key name", @@ -223,7 +247,7 @@ def test_objectToArray_literal(collection, test): @pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) def test_objectToArray_insert(collection, test): """Test $objectToArray with values from inserted documents.""" - result = execute_expression_with_insert(collection, {"$objectToArray": "$obj"}, test.doc) + 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/objectToArray/test_expression_objectToArray_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_errors.py index 182342712..3b1048e2d 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_errors.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_errors.py @@ -33,23 +33,26 @@ # Property [Literal-path parity]: representative non-object rejections also # run through the literal-value path (not just via inserted documents). # Defined here directly (not by positional index into NOT_OBJECT_ERROR_TESTS -# below) so the mapping is name-stable, and appended to the insert list below +# below) so the mapping is name-stable, and appended to ALL_ERROR_TESTS below # so they also get insert coverage. TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ ExpressionTestCase( id="string_input", + expression={"$objectToArray": "hello"}, doc={"obj": "hello"}, error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, msg="Should reject string input", ), ExpressionTestCase( id="int_input", + expression={"$objectToArray": 42}, doc={"obj": 42}, error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, msg="Should reject int input", ), ExpressionTestCase( id="timestamp_input", + expression={"$objectToArray": Timestamp(0, 0)}, doc={"obj": Timestamp(0, 0)}, error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, msg="Should reject timestamp input", @@ -57,10 +60,32 @@ ] -@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +# Property [Arity/structure errors]: a wrong number of top-level arguments +# (two, or zero via a literal empty array) is rejected with +# EXPRESSION_TYPE_MISMATCH_ERROR — distinct from an empty array *value* +# resolved via a field path, which is OBJECT_TO_ARRAY_NOT_OBJECT_ERROR (see +# empty_array_input above). These have no doc, so they only run through the +# literal path below (no insert-path counterpart makes sense for them). +ARITY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="two_args", + expression={"$objectToArray": [{"a": 1}, {"b": 2}]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="Should reject two arguments", + ), + ExpressionTestCase( + id="zero_args", + expression={"$objectToArray": []}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="A literal empty argument array is treated as zero arguments", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL + ARITY_ERROR_TESTS)) def test_objectToArray_not_object_literal(collection, test): """Test $objectToArray error cases with literal values.""" - result = execute_expression(collection, {"$objectToArray": test.doc["obj"]}) + result = execute_expression(collection, test.expression) assert_expression_result( result, expected=test.expected, error_code=test.error_code, msg=test.msg ) @@ -68,154 +93,156 @@ def test_objectToArray_not_object_literal(collection, test): # Property [Non-object rejection]: every non-object BSON type (scalar, array, # and edge values like empty string/array, false, NaN) is rejected with -# OBJECT_TO_ARRAY_NOT_OBJECT_ERROR (40390) — no type is silently coerced. +# OBJECT_TO_ARRAY_NOT_OBJECT_ERROR — no type is silently coerced. NOT_OBJECT_ERROR_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( id="double_input", + expression={"$objectToArray": "$obj"}, doc={"obj": 3.14}, error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, msg="Should reject double input", ), ExpressionTestCase( id="bool_input", + expression={"$objectToArray": "$obj"}, doc={"obj": True}, error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, msg="Should reject bool input", ), ExpressionTestCase( id="array_input", + expression={"$objectToArray": "$obj"}, doc={"obj": [1, 2, 3]}, error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, msg="Should reject array input", ), ExpressionTestCase( id="decimal128_input", + expression={"$objectToArray": "$obj"}, doc={"obj": Decimal128("1")}, error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, msg="Should reject decimal128 input", ), ExpressionTestCase( id="int64_input", + expression={"$objectToArray": "$obj"}, doc={"obj": Int64(1)}, error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, msg="Should reject int64 input", ), ExpressionTestCase( id="objectid_input", + expression={"$objectToArray": "$obj"}, doc={"obj": ObjectId()}, error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, msg="Should reject objectid input", ), ExpressionTestCase( id="datetime_input", + expression={"$objectToArray": "$obj"}, doc={"obj": datetime(2024, 1, 1, tzinfo=timezone.utc)}, error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, msg="Should reject datetime input", ), ExpressionTestCase( id="binary_input", + expression={"$objectToArray": "$obj"}, doc={"obj": Binary(b"x", 0)}, error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, msg="Should reject binary input", ), ExpressionTestCase( id="regex_input", + expression={"$objectToArray": "$obj"}, doc={"obj": Regex("x")}, error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, msg="Should reject regex input", ), ExpressionTestCase( id="maxkey_input", + expression={"$objectToArray": "$obj"}, doc={"obj": MaxKey()}, error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, msg="Should reject maxkey input", ), ExpressionTestCase( id="minkey_input", + expression={"$objectToArray": "$obj"}, doc={"obj": MinKey()}, error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, msg="Should reject minkey input", ), ExpressionTestCase( id="nan_input", + expression={"$objectToArray": "$obj"}, doc={"obj": FLOAT_NAN}, error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, msg="Should reject NaN input", ), ExpressionTestCase( id="bool_false_input", + expression={"$objectToArray": "$obj"}, doc={"obj": False}, error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, msg="Should reject bool false input", ), ExpressionTestCase( id="empty_array_input", + expression={"$objectToArray": "$obj"}, doc={"obj": []}, error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, msg="Should reject empty array input", ), ExpressionTestCase( id="empty_string_input", + expression={"$objectToArray": "$obj"}, doc={"obj": ""}, error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, msg="Should reject empty string input", ), ExpressionTestCase( id="decimal128_nan_input", + expression={"$objectToArray": "$obj"}, doc={"obj": DECIMAL128_NAN}, error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, msg="Should reject Decimal128 NaN input", ), ExpressionTestCase( id="decimal128_infinity_input", + expression={"$objectToArray": "$obj"}, doc={"obj": DECIMAL128_INFINITY}, error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, msg="Should reject Decimal128 Infinity input", ), ExpressionTestCase( id="decimal128_neg_infinity_input", + expression={"$objectToArray": "$obj"}, doc={"obj": DECIMAL128_NEGATIVE_INFINITY}, error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, msg="Should reject Decimal128 -Infinity input", ), ] +# Property [Composite-path error]: a composite array path (field-path +# resolution through an array of objects) is rejected as non-object, just +# like a plain non-object value. +COMPOSITE_PATH_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="composite_array_path", + expression={"$objectToArray": "$a.b"}, + doc={"a": [{"b": {"x": 1}}, {"b": {"y": 2}}]}, + error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, + msg="Composite array path should resolve to non-object", + ), +] + +ALL_ERROR_TESTS = NOT_OBJECT_ERROR_TESTS + TEST_SUBSET_FOR_LITERAL + COMPOSITE_PATH_ERROR_TESTS -@pytest.mark.parametrize("test", pytest_params(NOT_OBJECT_ERROR_TESTS + TEST_SUBSET_FOR_LITERAL)) + +@pytest.mark.parametrize("test", pytest_params(ALL_ERROR_TESTS)) def test_objectToArray_not_object_insert(collection, test): """Test $objectToArray error cases with values from inserted documents.""" - result = execute_expression_with_insert(collection, {"$objectToArray": "$obj"}, test.doc) + 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 ) - - -def test_objectToArray_two_args_error(collection): - """Test $objectToArray errors with two arguments.""" - result = execute_expression(collection, {"$objectToArray": [{"a": 1}, {"b": 2}]}) - assert_expression_result(result, error_code=EXPRESSION_TYPE_MISMATCH_ERROR) - - -def test_objectToArray_zero_args_error(collection): - """Test $objectToArray errors with zero arguments. - - A literal empty argument array is treated as zero arguments (16020), which - is distinct from an empty array *value* resolved via a field path (40390, - OBJECT_TO_ARRAY_NOT_OBJECT_ERROR — see empty_array_input above). - """ - result = execute_expression(collection, {"$objectToArray": []}) - assert_expression_result(result, error_code=EXPRESSION_TYPE_MISMATCH_ERROR) - - -def test_objectToArray_composite_array_path_error(collection): - """Test $objectToArray error case from field-path/expression resolution.""" - result = execute_expression_with_insert( - collection, - {"$objectToArray": "$a.b"}, - {"a": [{"b": {"x": 1}}, {"b": {"y": 2}}]}, - ) - assert_expression_result( - result, - error_code=OBJECT_TO_ARRAY_NOT_OBJECT_ERROR, - msg="Composite array path should resolve to non-object", - ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/reverseArray/test_expression_reverseArray_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reverseArray/test_expression_reverseArray_bson_types.py index 0ba7a2ee1..de639c0bb 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/reverseArray/test_expression_reverseArray_bson_types.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reverseArray/test_expression_reverseArray_bson_types.py @@ -25,6 +25,7 @@ DECIMAL128_NAN, DECIMAL128_NEGATIVE_INFINITY, DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, DOUBLE_NEGATIVE_ZERO, FLOAT_INFINITY, FLOAT_NEGATIVE_INFINITY, @@ -42,24 +43,32 @@ TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ ExpressionTestCase( id="int64_values", + expression={"$reverseArray": {"$literal": [Int64(1), Int64(2), Int64(3)]}}, doc={"arr": [Int64(1), Int64(2), Int64(3)]}, expected=[Int64(3), Int64(2), Int64(1)], msg="Should preserve Int64 values", ), ExpressionTestCase( id="binary_values", + expression={"$reverseArray": {"$literal": [Binary(b"\x01", 0), Binary(b"\x02", 0)]}}, doc={"arr": [Binary(b"\x01", 0), Binary(b"\x02", 0)]}, expected=[b"\x02", b"\x01"], msg="Should preserve Binary values", ), ExpressionTestCase( id="mixed_bson_types", + expression={ + "$reverseArray": { + "$literal": [1, "two", Int64(3), Decimal128("4"), True, None, MinKey()] + } + }, doc={"arr": [1, "two", Int64(3), Decimal128("4"), True, None, MinKey()]}, expected=[MinKey(), None, True, Decimal128("4"), Int64(3), "two", 1], msg="Should reverse mixed BSON types preserving each", ), ExpressionTestCase( id="infinity_values", + expression={"$reverseArray": {"$literal": [FLOAT_NEGATIVE_INFINITY, 0, FLOAT_INFINITY]}}, doc={"arr": [FLOAT_NEGATIVE_INFINITY, 0, FLOAT_INFINITY]}, expected=[FLOAT_INFINITY, 0, FLOAT_NEGATIVE_INFINITY], msg="Should preserve infinity values", @@ -70,7 +79,7 @@ @pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) def test_reverseArray_bson_literal(collection, test): """Test $reverseArray BSON types with literal values.""" - result = execute_expression(collection, {"$reverseArray": {"$literal": test.doc["arr"]}}) + result = execute_expression(collection, test.expression) assert_expression_result( result, expected=test.expected, error_code=test.error_code, msg=test.msg ) @@ -82,12 +91,14 @@ def test_reverseArray_bson_literal(collection, test): BSON_TYPE_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( id="decimal128_values", + expression={"$reverseArray": "$arr"}, doc={"arr": [Decimal128("1.5"), Decimal128("2.5"), Decimal128("3.5")]}, expected=[Decimal128("3.5"), Decimal128("2.5"), Decimal128("1.5")], msg="Should preserve Decimal128 values", ), ExpressionTestCase( id="datetime_values", + expression={"$reverseArray": "$arr"}, doc={ "arr": [ datetime(2024, 1, 1, tzinfo=timezone.utc), @@ -104,6 +115,7 @@ def test_reverseArray_bson_literal(collection, test): ), ExpressionTestCase( id="objectid_values", + expression={"$reverseArray": "$arr"}, doc={ "arr": [ ObjectId("000000000000000000000001"), @@ -120,30 +132,35 @@ def test_reverseArray_bson_literal(collection, test): ), ExpressionTestCase( id="binary_subtype_preservation", + expression={"$reverseArray": "$arr"}, doc={"arr": [Binary(b"\x01", 128), Binary(b"\x02", 128)]}, expected=[Binary(b"\x02", 128), Binary(b"\x01", 128)], msg="Should preserve Binary subtype", ), ExpressionTestCase( id="regex_values", + expression={"$reverseArray": "$arr"}, doc={"arr": [Regex("^a", "i"), Regex("^b", "i")]}, expected=[Regex("^b", "i"), Regex("^a", "i")], msg="Should preserve Regex values", ), ExpressionTestCase( id="timestamp_values", + expression={"$reverseArray": "$arr"}, doc={"arr": [Timestamp(1, 0), Timestamp(2, 0), Timestamp(3, 0)]}, expected=[Timestamp(3, 0), Timestamp(2, 0), Timestamp(1, 0)], msg="Should preserve Timestamp values", ), ExpressionTestCase( id="minkey_maxkey", + expression={"$reverseArray": "$arr"}, doc={"arr": [MinKey(), MaxKey()]}, expected=[MaxKey(), MinKey()], msg="Should preserve MinKey/MaxKey values", ), ExpressionTestCase( id="uuid_values", + expression={"$reverseArray": "$arr"}, doc={ "arr": [ Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), @@ -164,12 +181,14 @@ def test_reverseArray_bson_literal(collection, test): SPECIAL_NUMERIC_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( id="decimal128_infinity", - doc={"arr": [DECIMAL128_NEGATIVE_INFINITY, Decimal128("0"), DECIMAL128_INFINITY]}, - expected=[DECIMAL128_INFINITY, Decimal128("0"), DECIMAL128_NEGATIVE_INFINITY], + expression={"$reverseArray": "$arr"}, + doc={"arr": [DECIMAL128_NEGATIVE_INFINITY, DECIMAL128_ZERO, DECIMAL128_INFINITY]}, + expected=[DECIMAL128_INFINITY, DECIMAL128_ZERO, DECIMAL128_NEGATIVE_INFINITY], msg="Should preserve Decimal128 infinity values", ), ExpressionTestCase( id="boundary_values", + expression={"$reverseArray": "$arr"}, doc={"arr": [INT32_MIN, INT32_MAX, INT64_MIN, INT64_MAX]}, expected=[INT64_MAX, INT64_MIN, INT32_MAX, INT32_MIN], msg="Should preserve numeric boundary values", @@ -183,24 +202,28 @@ def test_reverseArray_bson_literal(collection, test): ELEMENT_PRESERVATION_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( id="decimal128_trailing_zeros", + expression={"$reverseArray": "$arr"}, doc={"arr": [Decimal128("1.0"), Decimal128("1.00"), Decimal128("1.000")]}, expected=[Decimal128("1.000"), Decimal128("1.00"), Decimal128("1.0")], msg="Decimal128 trailing zeros preserved", ), ExpressionTestCase( id="double_negative_zero", + expression={"$reverseArray": "$arr"}, doc={"arr": [1, DOUBLE_NEGATIVE_ZERO, -1]}, expected=[-1, DOUBLE_NEGATIVE_ZERO, 1], msg="Double negative zero preserved", ), ExpressionTestCase( id="decimal128_negative_zero", + expression={"$reverseArray": "$arr"}, doc={"arr": [Decimal128("1"), DECIMAL128_NEGATIVE_ZERO]}, expected=[DECIMAL128_NEGATIVE_ZERO, Decimal128("1")], msg="Decimal128 negative zero preserved", ), ExpressionTestCase( id="decimal128_nan_element", + expression={"$reverseArray": "$arr"}, doc={"arr": [DECIMAL128_NAN, Decimal128("1")]}, expected=[Decimal128("1"), DECIMAL128_NAN], msg="Decimal128 NaN element preserved", @@ -215,7 +238,7 @@ def test_reverseArray_bson_literal(collection, test): @pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) def test_reverseArray_bson_insert(collection, test): """Test $reverseArray BSON types with values from inserted documents.""" - result = execute_expression_with_insert(collection, {"$reverseArray": "$arr"}, test.doc) + 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/reverseArray/test_expression_reverseArray_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reverseArray/test_expression_reverseArray_core_behavior.py index 501997687..33be57de9 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/reverseArray/test_expression_reverseArray_core_behavior.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reverseArray/test_expression_reverseArray_core_behavior.py @@ -28,24 +28,28 @@ TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ ExpressionTestCase( id="ints", + expression={"$reverseArray": {"$literal": [1, 2, 3]}}, doc={"arr": [1, 2, 3]}, expected=[3, 2, 1], msg="Should reverse int array", ), ExpressionTestCase( id="strings", + expression={"$reverseArray": {"$literal": ["a", "b", "c"]}}, doc={"arr": ["a", "b", "c"]}, expected=["c", "b", "a"], msg="Should reverse string array", ), ExpressionTestCase( id="empty_array", + expression={"$reverseArray": {"$literal": []}}, doc={"arr": []}, expected=[], msg="Should return empty array for empty input", ), ExpressionTestCase( id="nested_arrays", + expression={"$reverseArray": {"$literal": [[1, 2, 3], [4, 5, 6]]}}, doc={"arr": [[1, 2, 3], [4, 5, 6]]}, expected=[[4, 5, 6], [1, 2, 3]], msg="Should reverse top-level only, not subarrays", @@ -56,7 +60,7 @@ @pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) def test_reverseArray_literal(collection, test): """Test $reverseArray with literal values.""" - result = execute_expression(collection, {"$reverseArray": {"$literal": test.doc["arr"]}}) + result = execute_expression(collection, test.expression) assert_expression_result( result, expected=test.expected, error_code=test.error_code, msg=test.msg ) @@ -68,24 +72,28 @@ def test_reverseArray_literal(collection, test): BASIC_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( id="doubles", + expression={"$reverseArray": "$arr"}, doc={"arr": [1.1, 2.2, 3.3]}, expected=[3.3, 2.2, 1.1], msg="Should reverse double array", ), ExpressionTestCase( id="booleans", + expression={"$reverseArray": "$arr"}, doc={"arr": [True, True, False]}, expected=[False, True, True], msg="Should reverse boolean array", ), ExpressionTestCase( id="objects", + expression={"$reverseArray": "$arr"}, doc={"arr": [{"a": 1}, {"b": 2}, {"c": 3}]}, expected=[{"c": 3}, {"b": 2}, {"a": 1}], msg="Should reverse array of objects", ), ExpressionTestCase( id="numeric_cross_types", + expression={"$reverseArray": "$arr"}, doc={"arr": [1, Int64(2), 3.0, Decimal128("4")]}, expected=[Decimal128("4"), 3.0, Int64(2), 1], msg="Should reverse mixed numeric types", @@ -95,14 +103,23 @@ def test_reverseArray_literal(collection, test): # Property [Degenerate lengths]: arrays of length 0, 1, and 2 are handled # correctly by the reversal (no swap loop, no-op single element, single swap). DEGENERATE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="empty_array_field_path", + expression={"$reverseArray": "$arr"}, + doc={"arr": []}, + expected=[], + msg="Should return empty array when field path resolves to empty array", + ), ExpressionTestCase( id="single_element", + expression={"$reverseArray": "$arr"}, doc={"arr": [42]}, expected=[42], msg="Should return single element unchanged", ), ExpressionTestCase( id="two_elements", + expression={"$reverseArray": "$arr"}, doc={"arr": [1, 2]}, expected=[2, 1], msg="Should swap two elements", @@ -114,12 +131,14 @@ def test_reverseArray_literal(collection, test): NESTED_ARRAY_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( id="nested_mixed", + expression={"$reverseArray": "$arr"}, doc={"arr": [[1], "two", [3, 4]]}, expected=[[3, 4], "two", [1]], msg="Should reverse top-level with mixed nested", ), ExpressionTestCase( id="deeply_nested", + expression={"$reverseArray": "$arr"}, doc={"arr": [[[1, 2]], [[3, 4]]]}, expected=[[[3, 4]], [[1, 2]]], msg="Should reverse top-level of deeply nested arrays", @@ -132,12 +151,14 @@ def test_reverseArray_literal(collection, test): DUPLICATE_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( id="all_same", + expression={"$reverseArray": "$arr"}, doc={"arr": [5, 5, 5]}, expected=[5, 5, 5], msg="Should handle all identical values", ), ExpressionTestCase( id="palindrome", + expression={"$reverseArray": "$arr"}, doc={"arr": [1, 2, 3, 2, 1]}, expected=[1, 2, 3, 2, 1], msg="Palindrome array should be unchanged", @@ -151,6 +172,7 @@ def test_reverseArray_literal(collection, test): LARGE_ARRAY_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( id="large_array", + expression={"$reverseArray": "$arr"}, doc={"arr": _LARGE}, expected=list(reversed(_LARGE)), msg="Should reverse large array", @@ -163,18 +185,21 @@ def test_reverseArray_literal(collection, test): NULL_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( id="null_input", + expression={"$reverseArray": "$arr"}, doc={"arr": None}, expected=None, msg="Should return null for null input", ), ExpressionTestCase( id="array_with_nulls", + expression={"$reverseArray": "$arr"}, doc={"arr": [1, None, 3]}, expected=[3, None, 1], msg="Null elements preserved", ), ExpressionTestCase( id="array_all_nulls", + expression={"$reverseArray": "$arr"}, doc={"arr": [None, None]}, expected=[None, None], msg="All null array reversed", @@ -187,18 +212,21 @@ def test_reverseArray_literal(collection, test): ELEMENT_PRESERVATION_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( id="object_field_order", + expression={"$reverseArray": "$arr"}, doc={"arr": [{"a": 1, "b": 2}, {"c": 3, "d": 4}]}, expected=[{"c": 3, "d": 4}, {"a": 1, "b": 2}], msg="Object field order preserved", ), ExpressionTestCase( id="embedded_docs_with_arrays", + expression={"$reverseArray": "$arr"}, doc={"arr": [{"items": [1, 2]}, {"items": [3, 4]}]}, expected=[{"items": [3, 4]}, {"items": [1, 2]}], msg="Inner arrays in docs not reversed", ), ExpressionTestCase( id="array_of_objects", + expression={"$reverseArray": "$arr"}, doc={"arr": [{"name": "a", "val": 1}, {"name": "b", "val": 2}, {"name": "c", "val": 3}]}, expected=[{"name": "c", "val": 3}, {"name": "b", "val": 2}, {"name": "a", "val": 1}], msg="Object integrity preserved", @@ -220,7 +248,7 @@ def test_reverseArray_literal(collection, test): @pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) def test_reverseArray_insert(collection, test): """Test $reverseArray with values from inserted documents.""" - result = execute_expression_with_insert(collection, {"$reverseArray": "$arr"}, test.doc) + 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/reverseArray/test_expression_reverseArray_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reverseArray/test_expression_reverseArray_errors.py index 8a615edec..556e3433d 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/reverseArray/test_expression_reverseArray_errors.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reverseArray/test_expression_reverseArray_errors.py @@ -31,6 +31,7 @@ DECIMAL128_MIN, DECIMAL128_NAN, DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, DECIMAL128_NEGATIVE_ZERO, DOUBLE_NEGATIVE_ZERO, FLOAT_INFINITY, @@ -50,35 +51,63 @@ TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ ExpressionTestCase( id="string_input", + expression={"$reverseArray": "hello"}, doc={"arr": "hello"}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject string input", ), ExpressionTestCase( id="timestamp_input", + expression={"$reverseArray": Timestamp(0, 0)}, doc={"arr": Timestamp(0, 0)}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject timestamp input", ), ExpressionTestCase( id="nan_input", + expression={"$reverseArray": FLOAT_NAN}, doc={"arr": FLOAT_NAN}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject NaN input", ), ExpressionTestCase( id="int32_max_input", + expression={"$reverseArray": INT32_MAX}, doc={"arr": INT32_MAX}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject INT32_MAX input", ), ] +# Property [Arity errors]: $reverseArray requires exactly one argument; zero, +# two, or three arguments are rejected with EXPRESSION_TYPE_MISMATCH_ERROR. +# These have no doc, so they only run through the literal path below. +ARITY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="zero_args", + expression={"$reverseArray": []}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="A literal empty argument array is treated as zero arguments", + ), + ExpressionTestCase( + id="two_args", + expression={"$reverseArray": [[], []]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="Should reject two arguments", + ), + ExpressionTestCase( + id="three_args", + expression={"$reverseArray": ["$a", "$b", "$c"]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="Should reject three arguments", + ), +] + -@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL + ARITY_ERROR_TESTS)) def test_reverseArray_not_array_literal(collection, test): """Test $reverseArray error with non-array literal input.""" - result = execute_expression(collection, {"$reverseArray": test.doc["arr"]}) + result = execute_expression(collection, test.expression) assert_expression_result( result, expected=test.expected, error_code=test.error_code, msg=test.msg ) @@ -90,90 +119,105 @@ def test_reverseArray_not_array_literal(collection, test): NOT_ARRAY_ERROR_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( id="int_input", + expression={"$reverseArray": "$arr"}, doc={"arr": 42}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject int input", ), ExpressionTestCase( id="negative_int_input", + expression={"$reverseArray": "$arr"}, doc={"arr": -42}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject negative int input", ), ExpressionTestCase( id="bool_input", + expression={"$reverseArray": "$arr"}, doc={"arr": True}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject bool input", ), ExpressionTestCase( id="object_input", + expression={"$reverseArray": "$arr"}, doc={"arr": {"a": 1}}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject object input", ), ExpressionTestCase( id="empty_object_input", + expression={"$reverseArray": "$arr"}, doc={"arr": {}}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject empty object input (contrast with [] which succeeds)", ), ExpressionTestCase( id="double_input", + expression={"$reverseArray": "$arr"}, doc={"arr": 3.14}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject double input", ), ExpressionTestCase( id="negative_double_input", + expression={"$reverseArray": "$arr"}, doc={"arr": -3.14}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject negative double input", ), ExpressionTestCase( id="decimal128_input", + expression={"$reverseArray": "$arr"}, doc={"arr": Decimal128("1")}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject decimal128 input", ), ExpressionTestCase( id="int64_input", + expression={"$reverseArray": "$arr"}, doc={"arr": Int64(1)}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject int64 input", ), ExpressionTestCase( id="objectid_input", + expression={"$reverseArray": "$arr"}, doc={"arr": ObjectId()}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject objectid input", ), ExpressionTestCase( id="datetime_input", + expression={"$reverseArray": "$arr"}, doc={"arr": datetime(2024, 1, 1, tzinfo=timezone.utc)}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject datetime input", ), ExpressionTestCase( id="binary_input", + expression={"$reverseArray": "$arr"}, doc={"arr": Binary(b"x", 0)}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject binary input", ), ExpressionTestCase( id="regex_input", + expression={"$reverseArray": "$arr"}, doc={"arr": Regex("x")}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject regex input", ), ExpressionTestCase( id="maxkey_input", + expression={"$reverseArray": "$arr"}, doc={"arr": MaxKey()}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject maxkey input", ), ExpressionTestCase( id="minkey_input", + expression={"$reverseArray": "$arr"}, doc={"arr": MinKey()}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject minkey input", @@ -186,48 +230,56 @@ def test_reverseArray_not_array_literal(collection, test): SPECIAL_NUMERIC_ERROR_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( id="inf_input", + expression={"$reverseArray": "$arr"}, doc={"arr": FLOAT_INFINITY}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject Infinity input", ), ExpressionTestCase( id="neg_inf_input", + expression={"$reverseArray": "$arr"}, doc={"arr": FLOAT_NEGATIVE_INFINITY}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject -Infinity input", ), ExpressionTestCase( id="neg_zero_input", + expression={"$reverseArray": "$arr"}, doc={"arr": DOUBLE_NEGATIVE_ZERO}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject negative zero input", ), ExpressionTestCase( id="decimal128_nan_input", + expression={"$reverseArray": "$arr"}, doc={"arr": DECIMAL128_NAN}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject Decimal128 NaN input", ), ExpressionTestCase( id="decimal128_neg_nan_input", - doc={"arr": Decimal128("-NaN")}, + expression={"$reverseArray": "$arr"}, + doc={"arr": DECIMAL128_NEGATIVE_NAN}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject Decimal128 -NaN input", ), ExpressionTestCase( id="decimal128_inf_input", + expression={"$reverseArray": "$arr"}, doc={"arr": DECIMAL128_INFINITY}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject Decimal128 Infinity input", ), ExpressionTestCase( id="decimal128_neg_inf_input", + expression={"$reverseArray": "$arr"}, doc={"arr": DECIMAL128_NEGATIVE_INFINITY}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject Decimal128 -Infinity input", ), ExpressionTestCase( id="decimal128_neg_zero_input", + expression={"$reverseArray": "$arr"}, doc={"arr": DECIMAL128_NEGATIVE_ZERO}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject Decimal128 -0 input", @@ -241,30 +293,35 @@ def test_reverseArray_not_array_literal(collection, test): BOUNDARY_ERROR_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( id="int32_min_input", + expression={"$reverseArray": "$arr"}, doc={"arr": INT32_MIN}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject INT32_MIN input", ), ExpressionTestCase( id="int64_max_input", + expression={"$reverseArray": "$arr"}, doc={"arr": INT64_MAX}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject INT64_MAX input", ), ExpressionTestCase( id="int64_min_input", + expression={"$reverseArray": "$arr"}, doc={"arr": INT64_MIN}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject INT64_MIN input", ), ExpressionTestCase( id="decimal128_max_input", + expression={"$reverseArray": "$arr"}, doc={"arr": DECIMAL128_MAX}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject DECIMAL128_MAX input", ), ExpressionTestCase( id="decimal128_min_input", + expression={"$reverseArray": "$arr"}, doc={"arr": DECIMAL128_MIN}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject DECIMAL128_MIN input", @@ -277,49 +334,20 @@ def test_reverseArray_not_array_literal(collection, test): STRING_EDGE_ERROR_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( id="comma_separated_string_input", + expression={"$reverseArray": "$arr"}, doc={"arr": "1, 2, 3"}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject comma-separated string", ), ExpressionTestCase( id="json_like_string_input", + expression={"$reverseArray": "$arr"}, doc={"arr": "[1, 2, 3]"}, error_code=REVERSE_ARRAY_NOT_ARRAY_ERROR, msg="Should reject JSON-like string", ), ] -ALL_TESTS = ( - NOT_ARRAY_ERROR_TESTS - + SPECIAL_NUMERIC_ERROR_TESTS - + BOUNDARY_ERROR_TESTS - + STRING_EDGE_ERROR_TESTS - + TEST_SUBSET_FOR_LITERAL -) - - -@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) -def test_reverseArray_not_array_insert(collection, test): - """Test $reverseArray error with non-array input from inserted documents.""" - result = execute_expression_with_insert(collection, {"$reverseArray": "$arr"}, test.doc) - assert_expression_result( - result, expected=test.expected, error_code=test.error_code, msg=test.msg - ) - - -ARITY_ERROR_TESTS = [ - pytest.param({"$reverseArray": [[], []]}, id="two_args"), - pytest.param({"$reverseArray": ["$a", "$b", "$c"]}, id="three_args"), -] - - -@pytest.mark.parametrize("expr", ARITY_ERROR_TESTS) -def test_reverseArray_arity_error(collection, expr): - """Test $reverseArray errors with wrong number of arguments.""" - result = execute_expression(collection, expr) - assert_expression_result(result, error_code=EXPRESSION_TYPE_MISMATCH_ERROR) - - # Property [Expression-resolved non-array rejection]: an operand built by # wrapping a non-array field reference in an array literal (e.g. [$a] where # a=1) still resolves to a non-array value once evaluated, and is rejected @@ -334,10 +362,19 @@ def test_reverseArray_arity_error(collection, expr): ), ] +ALL_TESTS = ( + NOT_ARRAY_ERROR_TESTS + + SPECIAL_NUMERIC_ERROR_TESTS + + BOUNDARY_ERROR_TESTS + + STRING_EDGE_ERROR_TESTS + + TEST_SUBSET_FOR_LITERAL + + EXPRESSION_ERROR_TESTS +) + -@pytest.mark.parametrize("test", pytest_params(EXPRESSION_ERROR_TESTS)) -def test_reverseArray_expression_error(collection, test): - """Test $reverseArray error cases from field-path/expression resolution.""" +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_reverseArray_not_array_insert(collection, test): + """Test $reverseArray error with non-array input from inserted documents.""" result = execute_expression_with_insert(collection, test.expression, test.doc) assert_expression_result( result, expected=test.expected, error_code=test.error_code, msg=test.msg diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_bson_types.py index c87cd01e0..fe0754bfd 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_bson_types.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_bson_types.py @@ -28,6 +28,7 @@ DECIMAL128_INFINITY, DECIMAL128_NAN, DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_ZERO, DOUBLE_NEGATIVE_ZERO, FLOAT_INFINITY, FLOAT_NEGATIVE_INFINITY, @@ -35,6 +36,7 @@ INT32_MIN, INT64_MAX, INT64_MIN, + INT64_ZERO, ) # Property [Literal-path parity]: representative cases from each group below @@ -295,8 +297,8 @@ def test_sortArray_bson_literal(collection, test): ExpressionTestCase( id="int64_boundaries", expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, - doc={"arr": [Int64(0), INT64_MAX, INT64_MIN]}, - expected=[INT64_MIN, Int64(0), INT64_MAX], + doc={"arr": [INT64_ZERO, INT64_MAX, INT64_MIN]}, + expected=[INT64_MIN, INT64_ZERO, INT64_MAX], msg="Should sort INT64 boundary values correctly", ), ExpressionTestCase( @@ -309,8 +311,8 @@ def test_sortArray_bson_literal(collection, test): ExpressionTestCase( id="decimal128_infinity", expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, - doc={"arr": [DECIMAL128_INFINITY, Decimal128("0"), DECIMAL128_NEGATIVE_INFINITY]}, - expected=[DECIMAL128_NEGATIVE_INFINITY, Decimal128("0"), DECIMAL128_INFINITY], + doc={"arr": [DECIMAL128_INFINITY, DECIMAL128_ZERO, DECIMAL128_NEGATIVE_INFINITY]}, + expected=[DECIMAL128_NEGATIVE_INFINITY, DECIMAL128_ZERO, DECIMAL128_INFINITY], msg="Should sort Decimal128 infinity values correctly", ), ] @@ -458,8 +460,8 @@ def test_sortArray_bson_literal(collection, test): ExpressionTestCase( id="cross_type_zero_equivalence", expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, - doc={"arr": [Decimal128("0"), DOUBLE_NEGATIVE_ZERO, Int64(0)]}, - expected=[Decimal128("0"), DOUBLE_NEGATIVE_ZERO, Int64(0)], + doc={"arr": [DECIMAL128_ZERO, DOUBLE_NEGATIVE_ZERO, INT64_ZERO]}, + expected=[DECIMAL128_ZERO, DOUBLE_NEGATIVE_ZERO, INT64_ZERO], msg="All-zero values across Decimal128/double/Int64 compare equal and" " keep stable (input) order", ), diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_errors.py index 45e084534..33e2cc4fd 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_errors.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_errors.py @@ -37,6 +37,7 @@ DECIMAL128_MIN, DECIMAL128_NAN, DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, DECIMAL128_NEGATIVE_ZERO, DOUBLE_NEGATIVE_ZERO, FLOAT_INFINITY, @@ -78,8 +79,69 @@ ), ] +# Property [Argument structure validation]: the `$sortArray` argument must be +# a well-formed object with exactly `input` and `sortBy` and no others; a +# missing/unknown field or non-object argument is rejected with its own +# named error. These have no doc, so they only run through the literal path. +STRUCTURE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="empty_object", + expression={"$sortArray": {}}, + error_code=SORT_ARRAY_MISSING_INPUT_ERROR, + msg="Empty object should error", + ), + ExpressionTestCase( + id="missing_input", + expression={"$sortArray": {"sortBy": 1}}, + error_code=SORT_ARRAY_MISSING_INPUT_ERROR, + msg="Missing input should error", + ), + ExpressionTestCase( + id="missing_sortby", + expression={"$sortArray": {"input": [1, 2, 3]}}, + error_code=SORT_ARRAY_MISSING_SORTBY_ERROR, + msg="Missing sortBy should error", + ), + ExpressionTestCase( + id="unknown_field", + expression={"$sortArray": {"input": [1], "sortBy": 1, "extra": 1}}, + error_code=SORT_ARRAY_UNKNOWN_FIELD_ERROR, + msg="Unknown field should error", + ), + ExpressionTestCase( + id="non_object_arg_array", + expression={"$sortArray": [[1, 2, 3], 1]}, + error_code=SORT_ARRAY_NON_OBJECT_ARG_ERROR, + msg="Array argument should error", + ), + ExpressionTestCase( + id="non_object_arg_empty_array", + expression={"$sortArray": []}, + error_code=SORT_ARRAY_NON_OBJECT_ARG_ERROR, + msg="Empty array argument should error the same as any other array argument", + ), + ExpressionTestCase( + id="non_object_arg_scalar", + expression={"$sortArray": 1}, + error_code=SORT_ARRAY_NON_OBJECT_ARG_ERROR, + msg="Scalar argument should error", + ), + ExpressionTestCase( + id="non_object_arg_string", + expression={"$sortArray": "hello"}, + error_code=SORT_ARRAY_NON_OBJECT_ARG_ERROR, + msg="String argument should error", + ), + ExpressionTestCase( + id="non_object_arg_null", + expression={"$sortArray": None}, + error_code=SORT_ARRAY_NON_OBJECT_ARG_ERROR, + msg="Null argument should error", + ), +] -@pytest.mark.parametrize("test", pytest_params(LITERAL_ERROR_TESTS)) + +@pytest.mark.parametrize("test", pytest_params(LITERAL_ERROR_TESTS + STRUCTURE_ERROR_TESTS)) def test_sortArray_not_array_literal(collection, test): """Test $sortArray error with non-array literal input.""" result = execute_expression(collection, test.expression) @@ -248,7 +310,7 @@ def test_sortArray_not_array_literal(collection, test): ExpressionTestCase( id="decimal128_neg_nan_input", expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, - doc={"arr": Decimal128("-NaN")}, + doc={"arr": DECIMAL128_NEGATIVE_NAN}, error_code=SORT_ARRAY_INPUT_NOT_ARRAY_ERROR, msg="Should reject Decimal128 -NaN input", ), @@ -481,113 +543,3 @@ def test_sortArray_not_array_insert(collection, test): assert_expression_result( result, expected=test.expected, error_code=test.error_code, msg=test.msg ) - - -# Property [Argument structure validation]: the `$sortArray` argument must be -# a well-formed object with exactly `input` and `sortBy` and no others; a -# missing/unknown field or non-object argument is rejected with its own -# named error. -STRUCTURE_ERROR_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - id="empty_object", - expression={"$sortArray": {}}, - error_code=SORT_ARRAY_MISSING_INPUT_ERROR, - msg="Empty object should error", - ), - ExpressionTestCase( - id="missing_input", - expression={"$sortArray": {"sortBy": 1}}, - error_code=SORT_ARRAY_MISSING_INPUT_ERROR, - msg="Missing input should error", - ), - ExpressionTestCase( - id="missing_sortby", - expression={"$sortArray": {"input": [1, 2, 3]}}, - error_code=SORT_ARRAY_MISSING_SORTBY_ERROR, - msg="Missing sortBy should error", - ), - ExpressionTestCase( - id="unknown_field", - expression={"$sortArray": {"input": [1], "sortBy": 1, "extra": 1}}, - error_code=SORT_ARRAY_UNKNOWN_FIELD_ERROR, - msg="Unknown field should error", - ), - ExpressionTestCase( - id="non_object_arg_array", - expression={"$sortArray": [[1, 2, 3], 1]}, - error_code=SORT_ARRAY_NON_OBJECT_ARG_ERROR, - msg="Array argument should error", - ), - ExpressionTestCase( - id="non_object_arg_scalar", - expression={"$sortArray": 1}, - error_code=SORT_ARRAY_NON_OBJECT_ARG_ERROR, - msg="Scalar argument should error", - ), - ExpressionTestCase( - id="non_object_arg_string", - expression={"$sortArray": "hello"}, - error_code=SORT_ARRAY_NON_OBJECT_ARG_ERROR, - msg="String argument should error", - ), - ExpressionTestCase( - id="non_object_arg_null", - expression={"$sortArray": None}, - error_code=SORT_ARRAY_NON_OBJECT_ARG_ERROR, - msg="Null argument should error", - ), -] - - -@pytest.mark.parametrize("test", pytest_params(STRUCTURE_ERROR_TESTS)) -def test_sortArray_argument_handling(collection, test): - """Test $sortArray argument structure validation.""" - result = execute_expression(collection, test.expression) - assert_expression_result( - result, expected=test.expected, error_code=test.error_code, msg=test.msg - ) - - -# Property [Error precedence] (Rule 18): when multiple argument problems -# co-occur, document which error wins, determined empirically — invalid -# sortBy beats non-array/null input, and missing-input beats missing-sortBy -# beats non-array input. -PRECEDENCE_ERROR_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - id="invalid_sortby_beats_non_array_input", - expression={"$sortArray": {"input": "hello", "sortBy": 0}}, - error_code=SORT_ARRAY_INVALID_SORT_SCALAR_ERROR, - msg="Invalid sortBy (0) is reported over non-array input", - ), - ExpressionTestCase( - id="invalid_sortby_beats_null_input", - expression={"$sortArray": {"input": "$arr", "sortBy": 0}}, - doc={"arr": None}, - error_code=SORT_ARRAY_INVALID_SORT_SCALAR_ERROR, - msg="Invalid sortBy (0) is reported instead of null propagation", - ), - ExpressionTestCase( - id="missing_input_beats_missing_sortby", - expression={"$sortArray": {}}, - error_code=SORT_ARRAY_MISSING_INPUT_ERROR, - msg="Missing input is reported before missing sortBy", - ), - ExpressionTestCase( - id="missing_sortby_beats_non_array_input", - expression={"$sortArray": {"input": "hello"}}, - error_code=SORT_ARRAY_MISSING_SORTBY_ERROR, - msg="Missing sortBy is reported before a non-array input type error", - ), -] - - -@pytest.mark.parametrize("test", pytest_params(PRECEDENCE_ERROR_TESTS)) -def test_sortArray_error_precedence(collection, test): - """Document which error wins when multiple argument problems co-occur (Rule 18).""" - if test.doc is not None: - result = execute_expression_with_insert(collection, test.expression, test.doc) - else: - result = execute_expression(collection, test.expression) - assert_expression_result( - result, expected=test.expected, error_code=test.error_code, msg=test.msg - ) From 5e854f7c3251ce3eced1bd57e79bccebf40ed2e4 Mon Sep 17 00:00:00 2001 From: "Victor [C] Tsang" Date: Fri, 24 Jul 2026 20:39:07 +0000 Subject: [PATCH 3/4] add single arg test case for objectToArray Signed-off-by: Victor [C] Tsang --- .../test_expression_objectToArray_expressions.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_expressions.py index 7f0415128..28420f8ee 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_expressions.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_expressions.py @@ -177,6 +177,14 @@ expected=[{"k": "a", "v": 3}, {"k": "b", "v": 9}], msg="Should resolve computed values inside an object operand", ), + ExpressionTestCase( + id="operand_single_element_array_unwraps", + expression={"$objectToArray": [{"a": 1}]}, + doc={"x": 0}, + expected=[{"k": "a", "v": 1}], + msg="Single-element array argument is treated as one argument and unwraps" + " to the object (contrast with [] zero-args and two-element arity errors)", + ), ] # Property [BSON type distinction] (Rule 11): k is always a string; v From 84aef04616327d4eda59a0c80b0ff82a84bbca03 Mon Sep 17 00:00:00 2001 From: "Victor [C] Tsang" Date: Fri, 24 Jul 2026 20:50:13 +0000 Subject: [PATCH 4/4] replace input values with constants Signed-off-by: Victor [C] Tsang --- ...est_expression_objectToArray_bson_types.py | 18 ++++++++------- ...test_expression_reverseArray_bson_types.py | 11 +++++---- .../test_expression_sortArray_bson_types.py | 23 +++++++++++++++---- ...test_expression_sortArray_core_behavior.py | 14 +++++++---- .../test_expression_sortArray_errors.py | 6 +++-- 5 files changed, 49 insertions(+), 23 deletions(-) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_bson_types.py index 46b362a29..c86d5919e 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_bson_types.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/objectToArray/test_expression_objectToArray_bson_types.py @@ -28,6 +28,8 @@ DECIMAL128_NAN, DECIMAL128_NEGATIVE_INFINITY, DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_TWO_AND_HALF, DOUBLE_NEGATIVE_ZERO, FLOAT_INFINITY, FLOAT_NEGATIVE_INFINITY, @@ -81,9 +83,9 @@ ), ExpressionTestCase( id="nested_bson_in_object_value", - expression={"$objectToArray": {"a": {"x": Int64(1), "y": Decimal128("2.5")}}}, - doc={"obj": {"a": {"x": Int64(1), "y": Decimal128("2.5")}}}, - expected=[{"k": "a", "v": {"x": Int64(1), "y": Decimal128("2.5")}}], + expression={"$objectToArray": {"a": {"x": Int64(1), "y": DECIMAL128_TWO_AND_HALF}}}, + doc={"obj": {"a": {"x": Int64(1), "y": DECIMAL128_TWO_AND_HALF}}}, + expected=[{"k": "a", "v": {"x": Int64(1), "y": DECIMAL128_TWO_AND_HALF}}], msg="Should preserve nested BSON types in object value", ), ExpressionTestCase( @@ -91,7 +93,7 @@ expression={ "$objectToArray": { "int64": Int64(1), - "dec": Decimal128("1.5"), + "dec": DECIMAL128_ONE_AND_HALF, "dt": datetime(2024, 1, 1, tzinfo=timezone.utc), "oid": ObjectId("000000000000000000000001"), "bin": Binary(b"\x01", 0), @@ -102,7 +104,7 @@ doc={ "obj": { "int64": Int64(1), - "dec": Decimal128("1.5"), + "dec": DECIMAL128_ONE_AND_HALF, "dt": datetime(2024, 1, 1, tzinfo=timezone.utc), "oid": ObjectId("000000000000000000000001"), "bin": Binary(b"\x01", 0), @@ -112,7 +114,7 @@ }, expected=[ {"k": "int64", "v": Int64(1)}, - {"k": "dec", "v": Decimal128("1.5")}, + {"k": "dec", "v": DECIMAL128_ONE_AND_HALF}, {"k": "dt", "v": datetime(2024, 1, 1, tzinfo=timezone.utc)}, {"k": "oid", "v": ObjectId("000000000000000000000001")}, {"k": "bin", "v": b"\x01"}, @@ -356,8 +358,8 @@ def test_objectToArray_bson_literal(collection, test): ExpressionTestCase( id="deeply_nested_bson", expression={"$objectToArray": "$obj"}, - doc={"obj": {"a": {"x": [{"y": Decimal128("1.5")}, Timestamp(0, 0)]}}}, - expected=[{"k": "a", "v": {"x": [{"y": Decimal128("1.5")}, Timestamp(0, 0)]}}], + doc={"obj": {"a": {"x": [{"y": DECIMAL128_ONE_AND_HALF}, Timestamp(0, 0)]}}}, + expected=[{"k": "a", "v": {"x": [{"y": DECIMAL128_ONE_AND_HALF}, Timestamp(0, 0)]}}], msg="Should preserve deeply nested BSON types", ), ] diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/reverseArray/test_expression_reverseArray_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reverseArray/test_expression_reverseArray_bson_types.py index de639c0bb..d21bd589a 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/reverseArray/test_expression_reverseArray_bson_types.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reverseArray/test_expression_reverseArray_bson_types.py @@ -25,6 +25,9 @@ DECIMAL128_NAN, DECIMAL128_NEGATIVE_INFINITY, DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_TRAILING_ZERO, + DECIMAL128_TWO_AND_HALF, DECIMAL128_ZERO, DOUBLE_NEGATIVE_ZERO, FLOAT_INFINITY, @@ -92,8 +95,8 @@ def test_reverseArray_bson_literal(collection, test): ExpressionTestCase( id="decimal128_values", expression={"$reverseArray": "$arr"}, - doc={"arr": [Decimal128("1.5"), Decimal128("2.5"), Decimal128("3.5")]}, - expected=[Decimal128("3.5"), Decimal128("2.5"), Decimal128("1.5")], + doc={"arr": [DECIMAL128_ONE_AND_HALF, DECIMAL128_TWO_AND_HALF, Decimal128("3.5")]}, + expected=[Decimal128("3.5"), DECIMAL128_TWO_AND_HALF, DECIMAL128_ONE_AND_HALF], msg="Should preserve Decimal128 values", ), ExpressionTestCase( @@ -203,8 +206,8 @@ def test_reverseArray_bson_literal(collection, test): ExpressionTestCase( id="decimal128_trailing_zeros", expression={"$reverseArray": "$arr"}, - doc={"arr": [Decimal128("1.0"), Decimal128("1.00"), Decimal128("1.000")]}, - expected=[Decimal128("1.000"), Decimal128("1.00"), Decimal128("1.0")], + doc={"arr": [DECIMAL128_TRAILING_ZERO, Decimal128("1.00"), Decimal128("1.000")]}, + expected=[Decimal128("1.000"), Decimal128("1.00"), DECIMAL128_TRAILING_ZERO], msg="Decimal128 trailing zeros preserved", ), ExpressionTestCase( diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_bson_types.py index fe0754bfd..bfe565c8f 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_bson_types.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_bson_types.py @@ -28,8 +28,11 @@ DECIMAL128_INFINITY, DECIMAL128_NAN, DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_TWO_AND_HALF, DECIMAL128_ZERO, DOUBLE_NEGATIVE_ZERO, + DOUBLE_ONE_AND_HALF, FLOAT_INFINITY, FLOAT_NEGATIVE_INFINITY, INT32_MAX, @@ -114,8 +117,8 @@ def test_sortArray_bson_literal(collection, test): ExpressionTestCase( id="sort_decimal128_asc", expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, - doc={"arr": [Decimal128("3.14"), Decimal128("1.5"), Decimal128("2.7")]}, - expected=[Decimal128("1.5"), Decimal128("2.7"), Decimal128("3.14")], + doc={"arr": [Decimal128("3.14"), DECIMAL128_ONE_AND_HALF, Decimal128("2.7")]}, + expected=[DECIMAL128_ONE_AND_HALF, Decimal128("2.7"), Decimal128("3.14")], msg="Should sort Decimal128 values ascending", ), ExpressionTestCase( @@ -355,8 +358,20 @@ def test_sortArray_bson_literal(collection, test): ExpressionTestCase( id="sort_docs_by_cross_numeric_types", expression={"$sortArray": {"input": "$arr", "sortBy": {"v": 1}}}, - doc={"arr": [{"v": Decimal128("2.5")}, {"v": 1}, {"v": Int64(3)}, {"v": 1.5}]}, - expected=[{"v": 1}, {"v": 1.5}, {"v": Decimal128("2.5")}, {"v": Int64(3)}], + doc={ + "arr": [ + {"v": DECIMAL128_TWO_AND_HALF}, + {"v": 1}, + {"v": Int64(3)}, + {"v": DOUBLE_ONE_AND_HALF}, + ] + }, + expected=[ + {"v": 1}, + {"v": DOUBLE_ONE_AND_HALF}, + {"v": DECIMAL128_TWO_AND_HALF}, + {"v": Int64(3)}, + ], msg="Should sort documents with mixed numeric field types", ), ExpressionTestCase( diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_core_behavior.py index 628d62583..d562d90c4 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_core_behavior.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_core_behavior.py @@ -9,7 +9,7 @@ """ import pytest -from bson import Decimal128, Int64 +from bson import Int64 from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 ExpressionTestCase, @@ -20,6 +20,10 @@ execute_expression_with_insert, ) from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + DOUBLE_ONE_AND_HALF, +) # Property [Literal-path parity]: representative cases from each group below # also run through the literal-value path (not just via inserted documents), @@ -345,8 +349,8 @@ def test_sortArray_literal(collection, test): ExpressionTestCase( id="int_and_double", expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, - doc={"arr": [3, 1.5, 2]}, - expected=[1.5, 2, 3], + doc={"arr": [3, DOUBLE_ONE_AND_HALF, 2]}, + expected=[DOUBLE_ONE_AND_HALF, 2, 3], msg="Should sort ints and doubles together", ), ExpressionTestCase( @@ -359,8 +363,8 @@ def test_sortArray_literal(collection, test): ExpressionTestCase( id="int_and_decimal128", expression={"$sortArray": {"input": "$arr", "sortBy": 1}}, - doc={"arr": [3, Decimal128("1.5"), 2]}, - expected=[Decimal128("1.5"), 2, 3], + doc={"arr": [3, DECIMAL128_ONE_AND_HALF, 2]}, + expected=[DECIMAL128_ONE_AND_HALF, 2, 3], msg="Should sort ints and decimal128 together", ), ] diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_errors.py index 33e2cc4fd..4147f86a7 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_errors.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/sortArray/test_expression_sortArray_errors.py @@ -39,7 +39,9 @@ DECIMAL128_NEGATIVE_INFINITY, DECIMAL128_NEGATIVE_NAN, DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, DOUBLE_NEGATIVE_ZERO, + DOUBLE_ONE_AND_HALF, FLOAT_INFINITY, FLOAT_NAN, FLOAT_NEGATIVE_INFINITY, @@ -434,14 +436,14 @@ def test_sortArray_not_array_literal(collection, test): ), ExpressionTestCase( id="sortby_fractional", - expression={"$sortArray": {"input": "$arr", "sortBy": 1.5}}, + expression={"$sortArray": {"input": "$arr", "sortBy": DOUBLE_ONE_AND_HALF}}, doc={"arr": [1, 2]}, error_code=SORT_ARRAY_INVALID_SORT_SCALAR_ERROR, msg="sortBy 1.5 should error", ), ExpressionTestCase( id="sortby_decimal128_fractional", - expression={"$sortArray": {"input": "$arr", "sortBy": Decimal128("1.5")}}, + expression={"$sortArray": {"input": "$arr", "sortBy": DECIMAL128_ONE_AND_HALF}}, doc={"arr": [1, 2]}, error_code=SORT_ARRAY_INVALID_SORT_SCALAR_ERROR, msg="sortBy Decimal128('1.5') should error like the double 1.5 case",