From 926242ea186d4cb140f1eb5028084dee2e3b5295 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" Date: Mon, 13 Jul 2026 15:11:36 -0700 Subject: [PATCH 1/2] migrate reduce, size, and slice Co-authored-by: Leszek Kurzyna Co-authored-by: Sean Oczkowski Signed-off-by: Alina (Xi) Li --- .../expressions/array/reduce/__init__.py | 0 .../test_expression_reduce_bson_types.py | 383 ++++++++++++ .../test_expression_reduce_core_behavior.py | 536 ++++++++++++++++ .../reduce/test_expression_reduce_errors.py | 300 +++++++++ .../test_expression_reduce_expressions.py | 252 ++++++++ .../reduce/test_smoke_expression_reduce.py | 2 +- .../expressions/array/size/__init__.py | 0 .../size/test_expression_size_bson_types.py | 267 ++++++++ .../test_expression_size_core_behavior.py | 200 ++++++ .../array/size/test_expression_size_errors.py | 222 +++++++ .../size/test_expression_size_expressions.py | 67 ++ .../array/size/test_smoke_expression_size.py | 2 +- .../expressions/array/slice/__init__.py | 0 .../test_expression_slice_core_behavior.py | 439 +++++++++++++ .../test_expression_slice_element_types.py | 313 ++++++++++ .../slice/test_expression_slice_errors.py | 581 ++++++++++++++++++ .../test_expression_slice_expressions.py | 76 +++ .../test_expression_slice_null_missing.py | 133 ++++ .../slice/test_smoke_expression_slice.py | 2 +- .../test_expressions_combination_reduce.py | 107 ++++ .../test_expressions_combination_size.py | 68 ++ .../test_expressions_combination_slice.py | 75 +++ 22 files changed, 4022 insertions(+), 3 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_bson_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/size/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_bson_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_element_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_null_missing.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_reduce.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_size.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_slice.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_bson_types.py new file mode 100644 index 000000000..f959ccf82 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_bson_types.py @@ -0,0 +1,383 @@ +""" +BSON type element tests for $reduce expression. + +Most cases use identity reduction (concatArrays into an accumulator array) to verify +BSON element-type preservation, including special numeric and boundary values; the +typed-sum cases instead verify that $add preserves the correct output BSON type. +""" + +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_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_TRAILING_ZERO, + DECIMAL128_TWO_AND_HALF, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT64_MAX, + INT64_MIN, + INT64_ZERO, +) + +# Property [Type Preservation]: $reduce preserves each element's BSON type via identity reduction. +BSON_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int64_values", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [Int64(1), Int64(2), Int64(3)]}, + expected=[Int64(1), Int64(2), Int64(3)], + msg="$reduce should preserve Int64 element values", + ), + ExpressionTestCase( + "decimal128_values", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [DECIMAL128_ONE_AND_HALF, DECIMAL128_TWO_AND_HALF]}, + expected=[DECIMAL128_ONE_AND_HALF, DECIMAL128_TWO_AND_HALF], + msg="$reduce should preserve Decimal128 element values", + ), + ExpressionTestCase( + "datetime_values", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={ + "arr": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 6, 1, tzinfo=timezone.utc), + ] + }, + expected=[ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 6, 1, tzinfo=timezone.utc), + ], + msg="$reduce should preserve datetime element values", + ), + ExpressionTestCase( + "objectid_values", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [ObjectId("000000000000000000000001"), ObjectId("000000000000000000000002")]}, + expected=[ObjectId("000000000000000000000001"), ObjectId("000000000000000000000002")], + msg="$reduce should preserve ObjectId element values", + ), + ExpressionTestCase( + "binary_values", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [Binary(b"\x01", 0), Binary(b"\x02", 0)]}, + expected=[b"\x01", b"\x02"], + msg="$reduce should preserve Binary element values", + ), + ExpressionTestCase( + "binary_subtype_preservation", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [Binary(b"\x01", 128), Binary(b"\x02", 128)]}, + expected=[Binary(b"\x01", 128), Binary(b"\x02", 128)], + msg="$reduce should preserve the Binary subtype", + ), + ExpressionTestCase( + "regex_values", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [Regex("^a", "i"), Regex("^b", "i")]}, + expected=[Regex("^a", "i"), Regex("^b", "i")], + msg="$reduce should preserve Regex element values", + ), + ExpressionTestCase( + "javascript_values", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [Code("a"), Code("b")]}, + expected=[Code("a"), Code("b")], + msg="$reduce should preserve JavaScript code element values", + ), + ExpressionTestCase( + "timestamp_values", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [Timestamp(1, 0), Timestamp(2, 0)]}, + expected=[Timestamp(1, 0), Timestamp(2, 0)], + msg="$reduce should preserve Timestamp element values", + ), + ExpressionTestCase( + "minkey_maxkey", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [MinKey(), MaxKey()]}, + expected=[MinKey(), MaxKey()], + msg="$reduce should preserve MinKey and MaxKey element values", + ), + ExpressionTestCase( + "uuid_values", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={ + "arr": [ + Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), + Binary.from_uuid(UUID("fedcba98-7654-3210-0123-456789abcdef")), + ] + }, + expected=[ + Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), + Binary.from_uuid(UUID("fedcba98-7654-3210-0123-456789abcdef")), + ], + msg="$reduce should preserve UUID Binary element values", + ), +] + +# Property [Mixed Types]: $reduce preserves elements of mixed BSON types. +MIXED_BSON_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "mixed_bson_types", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [1, "two", Int64(3), Decimal128("4"), True, None, MinKey()]}, + expected=[1, "two", Int64(3), Decimal128("4"), True, None, MinKey()], + msg="$reduce should preserve mixed BSON types", + ), +] + +# Property [Special Numeric Elements]: $reduce preserves Infinity, NaN, INT32/INT64 +# boundary values, and negative-zero elements. Python float NaN != NaN under plain +# equality, so float_nan_values compares via pytest.approx(..., nan_ok=True) instead +# (the same NaN-aware comparison technique used elsewhere in this test suite, e.g. +# elem_float_nan in test_expression_slice_element_types.py). +SPECIAL_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "float_nan_values", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [FLOAT_NAN, 1]}, + expected=[pytest.approx(FLOAT_NAN, nan_ok=True), 1], + msg="$reduce should preserve float NaN values", + ), + ExpressionTestCase( + "infinity_values", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [FLOAT_INFINITY, FLOAT_NEGATIVE_INFINITY]}, + expected=[FLOAT_INFINITY, FLOAT_NEGATIVE_INFINITY], + msg="$reduce should preserve infinity values", + ), + ExpressionTestCase( + "decimal128_infinity", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [DECIMAL128_INFINITY, DECIMAL128_NEGATIVE_INFINITY]}, + expected=[DECIMAL128_INFINITY, DECIMAL128_NEGATIVE_INFINITY], + msg="$reduce should preserve Decimal128 infinity values", + ), + ExpressionTestCase( + "boundary_values", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [INT32_MIN, INT32_MAX, INT64_MIN, INT64_MAX]}, + expected=[INT32_MIN, INT32_MAX, INT64_MIN, INT64_MAX], + msg="$reduce should preserve numeric boundary values", + ), + ExpressionTestCase( + "negative_zero", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [DOUBLE_NEGATIVE_ZERO, DECIMAL128_NEGATIVE_ZERO]}, + expected=[DOUBLE_NEGATIVE_ZERO, DECIMAL128_NEGATIVE_ZERO], + msg="$reduce should preserve negative zero values", + ), +] + +# Property [Decimal128 Precision]: $reduce preserves Decimal128 precision and special values. +DECIMAL128_PRECISION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal128_trailing_zeros", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [DECIMAL128_TRAILING_ZERO, Decimal128("1.00"), Decimal128("1.000")]}, + expected=[DECIMAL128_TRAILING_ZERO, Decimal128("1.00"), Decimal128("1.000")], + msg="$reduce should preserve Decimal128 trailing zeros", + ), + ExpressionTestCase( + "decimal128_nan", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [DECIMAL128_NAN, Decimal128("1")]}, + expected=[DECIMAL128_NAN, Decimal128("1")], + msg="$reduce should preserve Decimal128 NaN", + ), +] + +# Property [Typed Sum]: $reduce sums numeric elements preserving the result BSON type. +BSON_SUM_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "sum_int64", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": INT64_ZERO, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"arr": [Int64(10), Int64(20), Int64(30)]}, + expected=Int64(60), + msg="$reduce should sum Int64 values", + ), + ExpressionTestCase( + "sum_decimal128", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": DECIMAL128_ZERO, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"arr": [DECIMAL128_ONE_AND_HALF, DECIMAL128_TWO_AND_HALF, Decimal128("3.0")]}, + expected=Decimal128("7.0"), + msg="$reduce should sum Decimal128 values preserving precision", + ), + ExpressionTestCase( + "sum_double", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": DOUBLE_ZERO, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"arr": [1.5, 2.5, 3.0]}, + expected=7.0, + msg="$reduce should sum double values", + ), +] + +ALL_BSON_TESTS = ( + BSON_TYPE_TESTS + + MIXED_BSON_TESTS + + SPECIAL_NUMERIC_TESTS + + DECIMAL128_PRECISION_TESTS + + BSON_SUM_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_BSON_TESTS)) +def test_reduce_bson_insert(collection, test): + """Test $reduce BSON types with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result(result, expected=test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_core_behavior.py new file mode 100644 index 000000000..db8ae0537 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_core_behavior.py @@ -0,0 +1,536 @@ +""" +Core behavior tests for $reduce expression. + +Tests basic reduction (sum, product, concat), empty arrays, null propagation, +various initialValue types, nested arrays, objects as elements, and large arrays. +""" + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Basic Reduction]: $reduce folds the array left to right applying 'in'. +BASIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "basic_sum", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=15, + msg="$reduce should sum all elements", + ), + ExpressionTestCase( + "basic_product", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": 1, + "in": {"$multiply": ["$$value", "$$this"]}, + } + }, + doc={"arr": [1, 2, 3, 4]}, + expected=24, + msg="$reduce should multiply all elements", + ), + ExpressionTestCase( + "basic_string_concat", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": "", + "in": {"$concat": ["$$value", "$$this"]}, + } + }, + doc={"arr": ["a", "b", "c"]}, + expected="abc", + msg="$reduce should concatenate strings", + ), + ExpressionTestCase( + "basic_count_elements", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", 1]}} + }, + doc={"arr": ["a", "b", "c", "d"]}, + expected=4, + msg="$reduce should count elements", + ), +] + +# Property [Empty Input]: an empty input array returns initialValue unchanged. +EMPTY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "empty_array_int_init", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": []}, + expected=0, + msg="$reduce should return initialValue for an empty array", + ), + ExpressionTestCase( + "empty_array_string_init", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": "start", + "in": {"$concat": ["$$value", "$$this"]}, + } + }, + doc={"arr": []}, + expected="start", + msg="$reduce should return the string initialValue for an empty array", + ), + ExpressionTestCase( + "empty_array_array_init", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": []}, + expected=[], + msg="$reduce should return the array initialValue for an empty array", + ), + ExpressionTestCase( + "empty_array_object_init", + expression={"$reduce": {"input": "$arr", "initialValue": {"count": 0}, "in": "$$value"}}, + doc={"arr": []}, + expected={"count": 0}, + msg="$reduce should return the object initialValue for an empty array", + ), + ExpressionTestCase( + "empty_array_null_init", + expression={"$reduce": {"input": "$arr", "initialValue": None, "in": "$$value"}}, + doc={"arr": []}, + expected=None, + msg="$reduce should return the null initialValue for an empty array", + ), + ExpressionTestCase( + "empty_array_bool_init", + expression={"$reduce": {"input": "$arr", "initialValue": True, "in": "$$value"}}, + doc={"arr": []}, + expected=True, + msg="$reduce should return the true initialValue for an empty array", + ), + ExpressionTestCase( + "empty_array_bool_false_init", + expression={"$reduce": {"input": "$arr", "initialValue": False, "in": "$$value"}}, + doc={"arr": []}, + expected=False, + msg="$reduce should return the false initialValue for an empty array", + ), +] + +# Property [Null Input]: a null input array returns null. +NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": None}, + expected=None, + msg="$reduce should return null when input is null", + ), +] + +# Property [Single Element]: a one-element array applies 'in' exactly once. +SINGLE_ELEMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "single_element_sum", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": [42]}, + expected=42, + msg="$reduce should reduce a single element", + ), + ExpressionTestCase( + "single_element_identity", + expression={"$reduce": {"input": "$arr", "initialValue": 0, "in": "$$this"}}, + doc={"arr": [99]}, + expected=99, + msg="$reduce should return the element for a single-element identity reduction", + ), +] + +# Property [Array Accumulator]: $concatArrays in 'in' flattens nested arrays. +NESTED_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "flatten_one_level", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", "$$this"]}, + } + }, + doc={"arr": [[1, 2], [3, 4], [5]]}, + expected=[1, 2, 3, 4, 5], + msg="$reduce should flatten one level of nesting", + ), + ExpressionTestCase( + "flatten_with_empty", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", "$$this"]}, + } + }, + doc={"arr": [[1], [], [2, 3], []]}, + expected=[1, 2, 3], + msg="$reduce should flatten with empty subarrays", + ), +] + +# Property [Object Elements]: 'in' can read fields of object elements. +OBJECT_ELEMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "sum_object_field", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": 0, + "in": {"$add": ["$$value", "$$this.val"]}, + } + }, + doc={"arr": [{"val": 10}, {"val": 20}, {"val": 30}]}, + expected=60, + msg="$reduce should sum object field values", + ), + ExpressionTestCase( + "merge_objects", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": {}, + "in": {"$mergeObjects": ["$$value", "$$this"]}, + } + }, + doc={"arr": [{"a": 1}, {"b": 2}, {"c": 3}]}, + expected={"a": 1, "b": 2, "c": 3}, + msg="$reduce should merge objects", + ), +] + +# Property [Array Building]: 'in' can grow the accumulator array. +ACCUMULATE_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "collect_into_array", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [1, 2, 3]}, + expected=[1, 2, 3], + msg="$reduce should collect elements into an array", + ), + ExpressionTestCase( + "collect_doubled", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", [{"$multiply": ["$$this", 2]}]]}, + } + }, + doc={"arr": [1, 2, 3]}, + expected=[2, 4, 6], + msg="$reduce should collect doubled elements", + ), +] + +# Property [Boolean Reduction]: boolean 'in' operators fold to a single boolean. +BOOLEAN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "all_true", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": True, + "in": {"$and": ["$$value", "$$this"]}, + } + }, + doc={"arr": [True, True, True]}, + expected=True, + msg="$reduce should reduce all-true to true", + ), + ExpressionTestCase( + "and_short_circuits_on_false", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": True, + "in": {"$and": ["$$value", "$$this"]}, + } + }, + doc={"arr": [True, False, True]}, + expected=False, + msg="$reduce should reduce to false when any element is false", + ), + ExpressionTestCase( + "any_true", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": False, + "in": {"$or": ["$$value", "$$this"]}, + } + }, + doc={"arr": [False, False, True]}, + expected=True, + msg="$reduce should reduce to true when any element is true via $or", + ), +] + +# Property [Large Input]: reduction handles large arrays. +LARGE_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "large_array_sum", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": list(range(10_000))}, + expected=sum(range(10_000)), + msg="$reduce should sum a large array", + ), +] + +# Property [Evaluation Order]: elements are processed strictly left to right. +LEFT_TO_RIGHT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "concat_order", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": "", + "in": {"$concat": ["$$value", "$$this"]}, + } + }, + doc={"arr": ["a", "b", "c"]}, + expected="abc", + msg="$reduce should apply 'in' left to right (right-to-left would yield 'cba')", + ), + ExpressionTestCase( + "sequential_subtract", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": 100, + "in": {"$subtract": ["$$value", "$$this"]}, + } + }, + doc={"arr": [10, 5]}, + expected=85, + msg="$reduce should apply $subtract once per element, accumulating the result", + ), + ExpressionTestCase( + "sequential_divide", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": 100, + "in": {"$divide": ["$$value", "$$this"]}, + } + }, + doc={"arr": [2, 5]}, + expected=10.0, + msg="$reduce should apply $divide once per element, accumulating the result", + ), +] + +# Property [Object Accumulator]: $$value can be an object carrying multiple sub-accumulators. +OBJECT_ACCUMULATOR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "sum_and_product", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": {"sum": 5, "product": 2}, + "in": { + "sum": {"$add": ["$$value.sum", "$$this"]}, + "product": {"$multiply": ["$$value.product", "$$this"]}, + }, + } + }, + doc={"arr": [1, 2, 3, 4]}, + expected={"sum": 15, "product": 48}, + msg="$reduce should support an object accumulator", + ), + ExpressionTestCase( + "items_and_sum", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": {"items": [], "sum": 0}, + "in": { + "items": {"$concatArrays": ["$$value.items", ["$$this"]]}, + "sum": {"$add": ["$$value.sum", "$$this"]}, + }, + } + }, + doc={"arr": [1, 2, 3]}, + expected={"items": [1, 2, 3], "sum": 6}, + msg="$reduce should support an object accumulator with an array field", + ), +] + +# Property [In Expression]: 'in' accepts literals and $$value/$$this references. +IN_EXPR_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_in", + expression={"$reduce": {"input": "$arr", "initialValue": 0, "in": 42}}, + doc={"arr": [1, 2, 3]}, + expected=42, + msg="$reduce should return the literal 'in' value each iteration", + ), + ExpressionTestCase( + "value_ref_only", + expression={"$reduce": {"input": "$arr", "initialValue": 0, "in": "$$value"}}, + doc={"arr": [1, 2, 3]}, + expected=0, + msg="$reduce should return initialValue unchanged for a $$value-only 'in'", + ), + ExpressionTestCase( + "this_ref_only", + expression={"$reduce": {"input": "$arr", "initialValue": 0, "in": "$$this"}}, + doc={"arr": [10, 20, 30]}, + expected=30, + msg="$reduce should return the last element for a $$this-only 'in'", + ), + ExpressionTestCase( + "without_value", + expression={"$reduce": {"input": "$arr", "initialValue": 5, "in": {"$add": [5, "$$this"]}}}, + doc={"arr": [1, 2, 3, 4]}, + expected=9, + msg="$reduce should return the last evaluation when 'in' omits $$value", + ), + ExpressionTestCase( + "without_this", + expression={ + "$reduce": {"input": "$arr", "initialValue": 5, "in": {"$add": ["$$value", 5]}} + }, + doc={"arr": [1, 2, 3, 4]}, + expected=25, + msg="$reduce should apply 'in' each iteration when it omits $$this", + ), +] + +# Property [Null Propagation]: a null element or null accumulator propagates through arithmetic. +NULL_PROPAGATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_propagates_add", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": [1, None, 3]}, + expected=None, + msg="$reduce should propagate a null element through $add", + ), + ExpressionTestCase( + "null_with_ifNull", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": 0, + "in": {"$add": ["$$value", {"$ifNull": ["$$this", 0]}]}, + } + }, + doc={"arr": [1, None, 3]}, + expected=4, + msg="$reduce should replace null with $ifNull before summing", + ), + ExpressionTestCase( + "null_init_add", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": None, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"arr": [1, 2]}, + expected=None, + msg="$reduce should return null for a null initialValue with $add", + ), +] + +# Property [Heterogeneous Elements]: mixed element types are handled per the 'in' expression. +HETEROGENEOUS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "collect_heterogeneous", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", ["$$this"]]}, + } + }, + doc={"arr": [1, "two", True, None, [3], {"four": 4}]}, + expected=[1, "two", True, None, [3], {"four": 4}], + msg="$reduce should collect all element types into an array", + ), + ExpressionTestCase( + "type_bridge_tostring", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": "", + "in": {"$concat": ["$$value", {"$toString": "$$this"}]}, + } + }, + doc={"arr": [1, 2]}, + expected="12", + msg="$reduce should bridge types via $toString", + ), + ExpressionTestCase( + "int_init_decimal_elements", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": [Decimal128("1"), Decimal128("2")]}, + expected=Decimal128("3"), + msg="$reduce should promote int initialValue with Decimal128 elements", + ), +] + +ALL_TESTS = ( + BASIC_TESTS + + EMPTY_TESTS + + NULL_TESTS + + SINGLE_ELEMENT_TESTS + + NESTED_ARRAY_TESTS + + OBJECT_ELEMENT_TESTS + + ACCUMULATE_ARRAY_TESTS + + BOOLEAN_TESTS + + LARGE_ARRAY_TESTS + + LEFT_TO_RIGHT_TESTS + + OBJECT_ACCUMULATOR_TESTS + + IN_EXPR_TYPE_TESTS + + NULL_PROPAGATION_TESTS + + HETEROGENEOUS_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_reduce_insert(collection, test): + """Test $reduce 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/reduce/test_expression_reduce_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_errors.py new file mode 100644 index 000000000..2232c4c1a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_errors.py @@ -0,0 +1,300 @@ +""" +Error tests for $reduce expression. + +Consolidates all $reduce error cases: non-array input (one case per non-deprecated +BSON type), type mismatches between initialValue and elements, non-object argument, +unknown fields, and missing required fields. +Note: $reduce propagates null — null input returns null (tested in core_behavior). +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + CONCAT_TYPE_ERROR, + REDUCE_INPUT_NOT_ARRAY_ERROR, + REDUCE_MISSING_IN_ERROR, + REDUCE_MISSING_INIT_ERROR, + REDUCE_MISSING_INPUT_ERROR, + REDUCE_NON_OBJECT_ARG_ERROR, + REDUCE_UNKNOWN_FIELD_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Non-Array Input]: $reduce rejects a non-array input for every non-deprecated BSON type. +NOT_ARRAY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": "hello"}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject string input", + ), + ExpressionTestCase( + "int_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": 42}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject int input", + ), + ExpressionTestCase( + "double_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": 3.14}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject double input", + ), + ExpressionTestCase( + "decimal128_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": Decimal128("1")}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject decimal128 input", + ), + ExpressionTestCase( + "int64_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": Int64(1)}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject int64 input", + ), + ExpressionTestCase( + "bool_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": True}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject bool input", + ), + ExpressionTestCase( + "object_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": {"a": 1}}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject object input", + ), + ExpressionTestCase( + "objectid_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": ObjectId()}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject objectid input", + ), + ExpressionTestCase( + "datetime_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject datetime input", + ), + ExpressionTestCase( + "binary_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": Binary(b"x", 0)}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject binary input", + ), + ExpressionTestCase( + "regex_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": Regex("x")}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject regex input", + ), + ExpressionTestCase( + "javascript_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": Code("x")}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject JavaScript code input", + ), + ExpressionTestCase( + "timestamp_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": Timestamp(0, 0)}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject timestamp input", + ), + ExpressionTestCase( + "minkey_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": MinKey()}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject minkey input", + ), + ExpressionTestCase( + "maxkey_input", + expression={ + "$reduce": {"input": "$arr", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"arr": MaxKey()}, + error_code=REDUCE_INPUT_NOT_ARRAY_ERROR, + msg="$reduce should reject maxkey input", + ), +] + +# Property [Type Mismatch]: an accumulator/element type mismatch propagates the error. +TYPE_MISMATCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_init_int_add", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": "hello", + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"arr": [1]}, + error_code=TYPE_MISMATCH_ERROR, + msg="$reduce should reject a string accumulator passed to $add", + ), + ExpressionTestCase( + "int_init_string_concat", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": 0, + "in": {"$concat": ["$$value", "$$this"]}, + } + }, + doc={"arr": ["a"]}, + error_code=CONCAT_TYPE_ERROR, + msg="$reduce should reject an int accumulator passed to $concat", + ), + ExpressionTestCase( + "mid_iteration_type_mismatch", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": 0, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"arr": [1, 2, "three", 4]}, + error_code=TYPE_MISMATCH_ERROR, + msg="$reduce should reject a type mismatch mid-iteration", + ), +] + +# Property [Non-Object Argument]: a non-object $reduce argument is rejected. +NON_OBJECT_ARG_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int_arg", + expression={"$reduce": 0}, + error_code=REDUCE_NON_OBJECT_ARG_ERROR, + msg="$reduce should reject an int argument", + ), + ExpressionTestCase( + "string_arg", + expression={"$reduce": "hello"}, + error_code=REDUCE_NON_OBJECT_ARG_ERROR, + msg="$reduce should reject a string argument", + ), + ExpressionTestCase( + "array_arg", + expression={"$reduce": [1, 2, 3]}, + error_code=REDUCE_NON_OBJECT_ARG_ERROR, + msg="$reduce should reject an array argument", + ), + ExpressionTestCase( + "null_arg", + expression={"$reduce": None}, + error_code=REDUCE_NON_OBJECT_ARG_ERROR, + msg="$reduce should reject a null argument", + ), + ExpressionTestCase( + "bool_arg", + expression={"$reduce": True}, + error_code=REDUCE_NON_OBJECT_ARG_ERROR, + msg="$reduce should reject a bool argument", + ), +] + +# Property [Unknown Field]: an unrecognized field in the $reduce argument is rejected. +UNKNOWN_FIELD_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "extra_unknown", + expression={"$reduce": {"input": [1], "initialValue": 0, "in": "$$value", "extra": 1}}, + error_code=REDUCE_UNKNOWN_FIELD_ERROR, + msg="$reduce should reject an unknown field", + ), +] + +# Property [Missing Required Field]: omitting exactly one required field is rejected. +MISSING_REQUIRED_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_input", + expression={"$reduce": {"initialValue": 0, "in": "$$value"}}, + error_code=REDUCE_MISSING_INPUT_ERROR, + msg="$reduce should reject a missing input field", + ), + ExpressionTestCase( + "missing_initialValue", + expression={"$reduce": {"input": [1], "in": "$$value"}}, + error_code=REDUCE_MISSING_INIT_ERROR, + msg="$reduce should reject a missing initialValue field", + ), + ExpressionTestCase( + "missing_in", + expression={"$reduce": {"input": [1], "initialValue": 0}}, + error_code=REDUCE_MISSING_IN_ERROR, + msg="$reduce should reject a missing in field", + ), +] + +INPUT_ERROR_TESTS = NOT_ARRAY_ERROR_TESTS + TYPE_MISMATCH_TESTS +STRUCTURE_ERROR_TESTS = NON_OBJECT_ARG_TESTS + UNKNOWN_FIELD_TESTS + MISSING_REQUIRED_TESTS + + +@pytest.mark.parametrize("test", pytest_params(INPUT_ERROR_TESTS)) +def test_reduce_error_insert(collection, test): + """Test $reduce input errors with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(STRUCTURE_ERROR_TESTS)) +def test_reduce_structure_error(collection, test): + """Test $reduce argument structure validation.""" + result = execute_expression(collection, test.expression) + assert_expression_result(result, error_code=test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_expressions.py new file mode 100644 index 000000000..e04eed5ae --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_expressions.py @@ -0,0 +1,252 @@ +""" +Expression and field path tests for $reduce expression. + +Tests field path lookups, composite paths, system variables, +null/missing propagation via expressions, nested $reduce, and +access to outer document fields in the 'in' expression. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Field Path Input]: $reduce resolves a field path to the input array. +FIELD_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_path", + expression={ + "$reduce": {"input": "$a.b", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"a": {"b": [1, 2, 3]}}, + expected=6, + msg="$reduce should resolve a nested field path for input", + ), + ExpressionTestCase( + "composite_array_path", + expression={ + "$reduce": {"input": "$a.b", "initialValue": 0, "in": {"$add": ["$$value", "$$this"]}} + }, + doc={"a": [{"b": 1}, {"b": 2}, {"b": 3}]}, + expected=6, + msg="$reduce should resolve a composite array path for input", + ), +] + +# Property [Variable Input]: $reduce reads its input from bound and system variables. +LET_AND_VARIABLE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "let_variable", + expression={ + "$let": { + "vars": {"arr": "$values"}, + "in": { + "$reduce": { + "input": "$$arr", + "initialValue": 0, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + } + }, + doc={"values": [1, 2, 3]}, + expected=6, + msg="$reduce should read input from a $let variable", + ), + ExpressionTestCase( + "root_variable", + expression={ + "$reduce": { + "input": "$$ROOT.values", + "initialValue": 0, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"_id": 1, "values": [10, 20]}, + expected=30, + msg="$reduce should read input via $$ROOT", + ), +] + +# Property [Null/Missing Input]: a missing or removed input field propagates null. +NULL_MISSING_EXPR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_field", + expression={ + "$reduce": { + "input": "$nonexistent", + "initialValue": 0, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"other": 1}, + expected=None, + msg="$reduce should return null for a missing input field", + ), + ExpressionTestCase( + "missing_input_type_is_null", + expression={ + "$type": { + "$reduce": { + "input": "$nonexistent", + "initialValue": 0, + "in": {"$add": ["$$value", "$$this"]}, + } + } + }, + doc={"x": 1}, + expected="null", + msg="$reduce should produce null type for a missing input field", + ), + ExpressionTestCase( + "remove_variable", + expression={ + "$reduce": { + "input": "$$REMOVE", + "initialValue": 0, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"x": 1}, + expected=None, + msg="$reduce should propagate null for $$REMOVE input", + ), +] + +# Property [In Expression Context]: 'in' can nest $reduce and reference outer fields. +MISC_EXPR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_reduce", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": 0, + "in": { + "$add": [ + "$$value", + { + "$reduce": { + "input": "$$this", + "initialValue": 0, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + ] + }, + } + }, + doc={"arr": [[1, 2], [3, 4]]}, + expected=10, + msg="$reduce should support a nested $reduce", + ), + ExpressionTestCase( + "access_outer_field", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": 0, + "in": {"$add": ["$$value", {"$multiply": ["$$this", "$factor"]}]}, + } + }, + doc={"arr": [1, 2, 3], "factor": 10}, + expected=60, + msg="$reduce should access an outer document field in 'in'", + ), + ExpressionTestCase( + "field_ref_in_in", + expression={ + "$reduce": { + "input": [1, 2, 3, 4], + "initialValue": 0, + "in": {"$add": ["$$value", "$$this", "$val"]}, + } + }, + doc={"val": 1}, + expected=14, + msg="$reduce should add an outer field ref each iteration", + ), +] + +# Property [InitialValue Expression]: initialValue accepts field references and expressions. +INITIAL_VALUE_EXPR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "initialValue_from_field", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": "$init", + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"arr": [1, 2, 3], "init": 4}, + expected=10, + msg="$reduce should accept initialValue from a field reference", + ), + ExpressionTestCase( + "initialValue_from_expression", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": {"$multiply": [2, 2]}, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"arr": [1, 2, 3]}, + expected=10, + msg="$reduce should accept initialValue from an expression", + ), +] + +# Property [Expression Input]: input accepts array expressions and $literal arrays. +EXPRESSION_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "array_expression_input", + expression={ + "$reduce": { + "input": ["$x", "$y", "$z"], + "initialValue": 0, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"x": 1, "y": 2, "z": 3}, + expected=6, + msg="$reduce should resolve an array expression input", + ), + ExpressionTestCase( + "literal_input", + expression={ + "$reduce": { + "input": {"$literal": [5, 10, 15]}, + "initialValue": 0, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={}, + expected=30, + msg="$reduce should accept a $literal array input", + ), +] + +ALL_EXPR_TESTS = ( + FIELD_LOOKUP_TESTS + + LET_AND_VARIABLE_TESTS + + NULL_MISSING_EXPR_TESTS + + MISC_EXPR_TESTS + + INITIAL_VALUE_EXPR_TESTS + + EXPRESSION_INPUT_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_EXPR_TESTS)) +def test_reduce_expression(collection, test): + """Test $reduce 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/reduce/test_smoke_expression_reduce.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_smoke_expression_reduce.py index 68ba38ea0..85e26912f 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_smoke_expression_reduce.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_smoke_expression_reduce.py @@ -38,4 +38,4 @@ def test_smoke_expression_reduce(collection): ) expected = [{"_id": 1, "sum": 6}, {"_id": 2, "sum": 15}] - assertSuccess(result, expected, msg="Should support $reduce expression") + assertSuccess(result, expected, "$reduce should sum array elements in $project") diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_bson_types.py new file mode 100644 index 000000000..06b0a9479 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_bson_types.py @@ -0,0 +1,267 @@ +""" +BSON type tests for $size expression. + +Tests $size correctly counts array elements containing specific BSON types, +special numeric values, Decimal128 special values, and null byte strings. +""" + +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.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Property [BSON Element Counting]: $size counts each array element regardless of its BSON type. +BSON_ELEMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "bson_types_mixed", + expression={"$size": "$arr"}, + doc={ + "arr": [ + Int64(1), + Decimal128("2"), + ObjectId("000000000000000000000001"), + datetime(2024, 1, 1, tzinfo=timezone.utc), + ] + }, + expected=4, + msg="$size should count mixed BSON-typed elements", + ), + ExpressionTestCase( + "minkey_maxkey", + expression={"$size": "$arr"}, + doc={"arr": [MinKey(), MaxKey()]}, + expected=2, + msg="$size should count MinKey and MaxKey elements", + ), + ExpressionTestCase( + "timestamp_element", + expression={"$size": "$arr"}, + doc={"arr": [Timestamp(0, 0)]}, + expected=1, + msg="$size should count a Timestamp element", + ), + ExpressionTestCase( + "binary_element", + expression={"$size": "$arr"}, + doc={"arr": [Binary(b"\x00", 0)]}, + expected=1, + msg="$size should count a Binary element", + ), + ExpressionTestCase( + "regex_element", + expression={"$size": "$arr"}, + doc={"arr": [Regex(".*")]}, + expected=1, + msg="$size should count a Regex element", + ), + ExpressionTestCase( + "int64_element", + expression={"$size": "$arr"}, + doc={"arr": [Int64(1)]}, + expected=1, + msg="$size should count an Int64 element", + ), + ExpressionTestCase( + "decimal128_element", + expression={"$size": "$arr"}, + doc={"arr": [Decimal128("1")]}, + expected=1, + msg="$size should count a Decimal128 element", + ), + ExpressionTestCase( + "objectid_element", + expression={"$size": "$arr"}, + doc={"arr": [ObjectId()]}, + expected=1, + msg="$size should count an ObjectId element", + ), + ExpressionTestCase( + "datetime_element", + expression={"$size": "$arr"}, + doc={"arr": [datetime(2024, 1, 1, tzinfo=timezone.utc)]}, + expected=1, + msg="$size should count a datetime element", + ), +] + +# Property [Special Numeric Elements]: $size counts NaN, Infinity, and negative-zero elements. +SPECIAL_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "special_numerics_mixed", + expression={"$size": "$arr"}, + doc={"arr": [DECIMAL128_NAN, FLOAT_NAN, FLOAT_INFINITY]}, + expected=3, + msg="$size should count special numeric elements", + ), + ExpressionTestCase( + "nan_element", + expression={"$size": "$arr"}, + doc={"arr": [FLOAT_NAN]}, + expected=1, + msg="$size should count a NaN element", + ), + ExpressionTestCase( + "inf_element", + expression={"$size": "$arr"}, + doc={"arr": [FLOAT_INFINITY]}, + expected=1, + msg="$size should count an Infinity element", + ), + ExpressionTestCase( + "neg_inf_element", + expression={"$size": "$arr"}, + doc={"arr": [FLOAT_NEGATIVE_INFINITY]}, + expected=1, + msg="$size should count a -Infinity element", + ), + ExpressionTestCase( + "neg_zero_element", + expression={"$size": "$arr"}, + doc={"arr": [DOUBLE_NEGATIVE_ZERO]}, + expected=1, + msg="$size should count a negative zero element", + ), +] + +# Property [Decimal128 Special Elements]: $size counts Decimal128 NaN and Infinity elements. +DECIMAL128_SPECIAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal128_nan", + expression={"$size": "$arr"}, + doc={"arr": [DECIMAL128_NAN]}, + expected=1, + msg="$size should count a Decimal128 NaN element", + ), + ExpressionTestCase( + "decimal128_neg_nan", + expression={"$size": "$arr"}, + doc={"arr": [DECIMAL128_NEGATIVE_NAN]}, + expected=1, + msg="$size should count a Decimal128 -NaN element", + ), + ExpressionTestCase( + "decimal128_inf", + expression={"$size": "$arr"}, + doc={"arr": [DECIMAL128_INFINITY]}, + expected=1, + msg="$size should count a Decimal128 Infinity element", + ), + ExpressionTestCase( + "decimal128_neg_inf", + expression={"$size": "$arr"}, + doc={"arr": [DECIMAL128_NEGATIVE_INFINITY]}, + expected=1, + msg="$size should count a Decimal128 -Infinity element", + ), + ExpressionTestCase( + "decimal128_neg_zero", + expression={"$size": "$arr"}, + doc={"arr": [DECIMAL128_NEGATIVE_ZERO]}, + expected=1, + msg="$size should count a Decimal128 -0 element", + ), +] + +# Property [Top-Level Counting]: $size counts only top-level elements, not nested values. +NESTED_MIXED_BSON_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_mixed_bson", + expression={"$size": "$arr"}, + doc={ + "arr": [ + MinKey(), + {"a": [DECIMAL128_ONE_AND_HALF]}, + Int64(1), + datetime(2024, 1, 1, tzinfo=timezone.utc), + Binary(b"\x01", 0), + ] + }, + expected=5, + msg="$size should count nested mixed BSON elements at the top level", + ), +] + +# Property [Null Byte Strings]: $size counts elements whose string content contains null bytes. +NULL_BYTE_STRING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_byte_strings", + expression={"$size": "$arr"}, + doc={ + "arr": [ + "\u0000", + "\u0000abcd", + "abcd\u0000", + "1\u00002\u00003\u00004", + ["\u0000", "\u0000abc", "abc\u0000", "1\u00002\u00003\u0000"], + {"test_doc": "1\u00002\u00003\u0000"}, + {"test_doc": ["\u0000"]}, + ] + }, + expected=7, + msg="$size should count elements whose strings contain null bytes", + ), +] + +ALL_BSON_TESTS = ( + BSON_ELEMENT_TESTS + + SPECIAL_NUMERIC_TESTS + + DECIMAL128_SPECIAL_TESTS + + NESTED_MIXED_BSON_TESTS + + NULL_BYTE_STRING_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_BSON_TESTS)) +def test_size_bson_insert(collection, test): + """Test $size BSON types with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result(result, expected=test.expected, msg=test.msg) + + +# Property [Literal BSON Input]: $size counts a literal array of BSON-typed elements. +BSON_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_bson_mixed", + expression={ + "$size": { + "$literal": [ + Int64(1), + Decimal128("2"), + ObjectId("000000000000000000000001"), + datetime(2024, 1, 1, tzinfo=timezone.utc), + ] + } + }, + expected=4, + msg="$size should count a literal array of BSON-typed elements", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(BSON_LITERAL_TESTS)) +def test_size_bson_literal(collection, test): + """Test $size BSON types with literal values.""" + result = execute_expression(collection, test.expression) + assert_expression_result(result, expected=test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_core_behavior.py new file mode 100644 index 000000000..dcaceafb3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_core_behavior.py @@ -0,0 +1,200 @@ +""" +Core behavior tests for $size expression. + +Tests array length counting for various array types and sizes. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.lazy_payload import lazy +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Element Count]: $size returns the number of top-level elements in an array. +BASIC_SIZE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "empty_array", + expression={"$size": "$arr"}, + doc={"arr": []}, + expected=0, + msg="$size should return 0 for an empty array", + ), + ExpressionTestCase( + "single_element", + expression={"$size": "$arr"}, + doc={"arr": [1]}, + expected=1, + msg="$size should return 1 for a single-element array", + ), + ExpressionTestCase( + "single_null_element", + expression={"$size": "$arr"}, + doc={"arr": [None]}, + expected=1, + msg="$size should return 1 for a single null element", + ), + ExpressionTestCase( + "three_elements", + expression={"$size": "$arr"}, + doc={"arr": [1, 2, 3]}, + expected=3, + msg="$size should return 3 for a three-element array", + ), + ExpressionTestCase( + "five_elements", + expression={"$size": "$arr"}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=5, + msg="$size should return 5 for a five-element array", + ), + ExpressionTestCase( + "all_same", + expression={"$size": "$arr"}, + doc={"arr": [1, 1, 1, 1]}, + expected=4, + msg="$size should count duplicate elements", + ), + ExpressionTestCase( + "string_array", + expression={"$size": "$arr"}, + doc={"arr": ["a", "b", "c"]}, + expected=3, + msg="$size should count string elements", + ), +] + +# Property [Element Type Independence]: $size counts every element regardless of type. +ELEMENT_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "mixed_types", + expression={"$size": "$arr"}, + doc={"arr": [1, "two", True, None, {"a": 1}, [1, 2]]}, + expected=6, + msg="$size should count mixed-type elements", + ), + ExpressionTestCase( + "nested_arrays", + expression={"$size": "$arr"}, + doc={"arr": [[1, 2], [3, 4], [5, 6]]}, + expected=3, + msg="$size should count top-level elements without flattening", + ), + ExpressionTestCase( + "array_of_objects", + expression={"$size": "$arr"}, + doc={"arr": [{"a": 1}, {"b": 2}]}, + expected=2, + msg="$size should count object elements", + ), + ExpressionTestCase( + "array_of_nulls", + expression={"$size": "$arr"}, + doc={"arr": [None, None, None]}, + expected=3, + msg="$size should count null elements", + ), + ExpressionTestCase( + "array_with_empty_subarrays", + expression={"$size": "$arr"}, + doc={"arr": [[], [], []]}, + expected=3, + msg="$size should count empty subarray elements", + ), + ExpressionTestCase( + "empty_object_element", + expression={"$size": "$arr"}, + doc={"arr": [{}]}, + expected=1, + msg="$size should count an empty object element", + ), + ExpressionTestCase( + "empty_string_element", + expression={"$size": "$arr"}, + doc={"arr": [""]}, + expected=1, + msg="$size should count an empty string element", + ), + ExpressionTestCase( + "zero_element", + expression={"$size": "$arr"}, + doc={"arr": [0]}, + expected=1, + msg="$size should count a zero element", + ), + ExpressionTestCase( + "false_element", + expression={"$size": "$arr"}, + doc={"arr": [False]}, + expected=1, + msg="$size should count a false element", + ), + ExpressionTestCase( + "single_nested_array", + expression={"$size": "$arr"}, + doc={"arr": [[1, 2, 3]]}, + expected=1, + msg="$size should count a single nested array as one element", + ), +] + +# Property [Large Arrays]: $size returns the correct count for large arrays. +LARGE_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "large_array", + expression={"$size": "$arr"}, + doc=lazy(lambda: {"arr": list(range(10_000))}), + expected=10_000, + msg="$size should count a large array's elements", + ), + ExpressionTestCase( + "large_string_element", + expression={"$size": "$arr"}, + doc=lazy(lambda: {"arr": ["x" * 1_000_000]}), + expected=1, + msg="$size should count a single large string element as one element", + ), +] + +ALL_TESTS = BASIC_SIZE_TESTS + ELEMENT_TYPE_TESTS + LARGE_ARRAY_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_size_insert(collection, test): + """Test $size with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +# Property [Literal Input]: $size counts an array passed as a literal. +SIZE_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_empty", + expression={"$size": {"$literal": []}}, + expected=0, + msg="$size should count a literal empty array", + ), + ExpressionTestCase( + "literal_three_elements", + expression={"$size": {"$literal": [1, 2, 3]}}, + expected=3, + msg="$size should count a literal array's elements", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SIZE_LITERAL_TESTS)) +def test_size_literal(collection, test): + """Test $size with literal values.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_errors.py new file mode 100644 index 000000000..426940fc7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_errors.py @@ -0,0 +1,222 @@ +""" +Error tests for $size expression. + +$size errors on non-array input (including null and missing) and wrong arity. +Unlike most operators, $size does NOT propagate null — it errors. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + EXPRESSION_TYPE_MISMATCH_ERROR, + SIZE_NOT_ARRAY_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Non-Array Rejection]: $size rejects any non-array input, including null. +NOT_ARRAY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_input", + expression={"$size": "$arr"}, + doc={"arr": "hello"}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject string input", + ), + ExpressionTestCase( + "int_input", + expression={"$size": "$arr"}, + doc={"arr": 42}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject int input", + ), + ExpressionTestCase( + "double_input", + expression={"$size": "$arr"}, + doc={"arr": 3.14}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject double input", + ), + ExpressionTestCase( + "bool_true_input", + expression={"$size": "$arr"}, + doc={"arr": True}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject boolean true input", + ), + ExpressionTestCase( + "bool_false_input", + expression={"$size": "$arr"}, + doc={"arr": False}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject boolean false input", + ), + ExpressionTestCase( + "null_input", + expression={"$size": "$arr"}, + doc={"arr": None}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject null input without null propagation", + ), + ExpressionTestCase( + "object_input", + expression={"$size": "$arr"}, + doc={"arr": {"a": 1}}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject object input", + ), + ExpressionTestCase( + "decimal128_input", + expression={"$size": "$arr"}, + doc={"arr": Decimal128("1")}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject decimal128 input", + ), + ExpressionTestCase( + "int64_input", + expression={"$size": "$arr"}, + doc={"arr": Int64(1)}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject int64 input", + ), + ExpressionTestCase( + "objectid_input", + expression={"$size": "$arr"}, + doc={"arr": ObjectId()}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject objectid input", + ), + ExpressionTestCase( + "datetime_input", + expression={"$size": "$arr"}, + doc={"arr": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject datetime input", + ), + ExpressionTestCase( + "binary_input", + expression={"$size": "$arr"}, + doc={"arr": Binary(b"x", 0)}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject binary input", + ), + ExpressionTestCase( + "regex_input", + expression={"$size": "$arr"}, + doc={"arr": Regex("x")}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject regex input", + ), + ExpressionTestCase( + "javascript_input", + expression={"$size": "$arr"}, + doc={"arr": Code("x")}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject JavaScript code input", + ), + ExpressionTestCase( + "timestamp_input", + expression={"$size": "$arr"}, + doc={"arr": Timestamp(0, 0)}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject timestamp input", + ), + ExpressionTestCase( + "minkey_input", + expression={"$size": "$arr"}, + doc={"arr": MinKey()}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject minkey input", + ), + ExpressionTestCase( + "maxkey_input", + expression={"$size": "$arr"}, + doc={"arr": MaxKey()}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject maxkey input", + ), +] + +# Property [Non-Array Field Rejection]: $size errors on a non-array or missing field path. +FIELD_EXPR_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "field_ref_single_element_array_wrapped", + expression={"$size": ["$a"]}, + doc={"a": 1}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg=( + "$size should reject a non-array field ref even when wrapped in a " + "single-element argument array (array-unwrap syntax)" + ), + ), + ExpressionTestCase( + "missing_field", + expression={"$size": "$nonexistent"}, + doc={"other": 1}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject a missing field", + ), + ExpressionTestCase( + "nested_missing_field", + expression={"$size": "$a.b"}, + doc={"a": {}}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject a missing nested field", + ), +] + +# Property [Literal Input]: $size rejects a non-array literal argument. +LITERAL_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_string", + expression={"$size": "hello"}, + error_code=SIZE_NOT_ARRAY_ERROR, + msg="$size should reject a non-array literal", + ), +] + +# Property [Arity]: $size requires exactly one argument. +ARITY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_args", + expression={"$size": []}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$size should reject zero arguments", + ), + ExpressionTestCase( + "two_args", + expression={"$size": [1, 2]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$size should reject two arguments", + ), +] + +INSERT_ERROR_TESTS = NOT_ARRAY_ERROR_TESTS + FIELD_EXPR_ERROR_TESTS + + +@pytest.mark.parametrize("test", pytest_params(INSERT_ERROR_TESTS)) +def test_size_insert(collection, test): + """Test $size error cases with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(LITERAL_ERROR_TESTS + ARITY_ERROR_TESTS)) +def test_size_literal(collection, test): + """Test $size error cases with literal values, including wrong arity.""" + 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/size/test_expression_size_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_expressions.py new file mode 100644 index 000000000..e763c3507 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_expression_size_expressions.py @@ -0,0 +1,67 @@ +""" +Expression and field path tests for $size expression. + +Tests nested expressions, field path lookups, and composite paths. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Variable Input]: $size counts an array bound to a $let variable. +LET_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "size_let_variable", + expression={"$let": {"vars": {"arr": "$arr"}, "in": {"$size": "$$arr"}}}, + doc={"arr": [1, 2, 3]}, + expected=3, + msg="$size should count an array bound to a $let variable", + ), +] + +# Property [Field Path Input]: $size resolves a field path to an array before counting. +FIELD_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_path", + expression={"$size": "$a.b"}, + doc={"a": {"b": [10, 20, 30]}}, + expected=3, + msg="$size should count an array at a nested field path", + ), + ExpressionTestCase( + "composite_array_path", + expression={"$size": "$a.b"}, + doc={"a": [{"b": 1}, {"b": 2}, {"b": 3}]}, + expected=3, + msg="$size should count an array resolved from a composite path", + ), +] + +# Property [Nested Operator Input]: $size counts an array produced by a nested expression. +NESTED_OPERATOR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_operator", + expression={"$size": [[1, 2, 3, {"$size": "$arr"}]]}, + doc={"arr": [1, 2, 3]}, + expected=4, + msg="$size should count an array containing a nested $size result", + ), +] + +ALL_EXPR_TESTS = LET_TESTS + FIELD_LOOKUP_TESTS + NESTED_OPERATOR_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_EXPR_TESTS)) +def test_size_expression(collection, test): + """Test $size 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/size/test_smoke_expression_size.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_smoke_expression_size.py index b033165a9..da0dea578 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_smoke_expression_size.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/size/test_smoke_expression_size.py @@ -28,4 +28,4 @@ def test_smoke_expression_size(collection): ) expected = [{"_id": 1, "arraySize": 3}, {"_id": 2, "arraySize": 5}] - assertSuccess(result, expected, msg="Should support $size expression") + assertSuccess(result, expected, "$size should return element counts in $project") diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_core_behavior.py new file mode 100644 index 000000000..e78ab5dec --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_core_behavior.py @@ -0,0 +1,439 @@ +""" +Core behavior tests for $slice expression. + +Tests 2-arg form (positive/negative n, n exceeds length, zero n, empty array), +3-arg form (position, negative position, n exceeds remaining, empty/single-element), +nested mixed arrays, large arrays, and literal array/index arguments. +""" + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import INT32_MAX + +# Property [Positive n]: a positive n returns the first n elements, capped at array length. +POSITIVE_N_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "first_1", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "n": 1}, + expected=[1], + msg="$slice should return the first element", + ), + ExpressionTestCase( + "first_2", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "n": 2}, + expected=[1, 2], + msg="$slice should return the first 2 elements", + ), + ExpressionTestCase( + "first_3", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "n": 3}, + expected=[1, 2, 3], + msg="$slice should return the first 3 elements", + ), + ExpressionTestCase( + "first_all", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": 3}, + expected=[1, 2, 3], + msg="$slice should return all elements", + ), + ExpressionTestCase( + "first_single", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [42], "n": 1}, + expected=[42], + msg="$slice should return a single element", + ), + ExpressionTestCase( + "first_strings", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": ["a", "b", "c"], "n": 2}, + expected=["a", "b"], + msg="$slice should return the first 2 strings", + ), + ExpressionTestCase( + "first_mixed", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, "two", True, None], "n": 3}, + expected=[1, "two", True], + msg="$slice should return the first 3 mixed elements", + ), +] + +# Property [Negative n]: a negative n returns the last |n| elements, capped at array length. +NEGATIVE_N_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "last_1", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "n": -1}, + expected=[5], + msg="$slice should return the last element", + ), + ExpressionTestCase( + "last_2", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "n": -2}, + expected=[4, 5], + msg="$slice should return the last 2 elements", + ), + ExpressionTestCase( + "last_3", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "n": -3}, + expected=[3, 4, 5], + msg="$slice should return the last 3 elements", + ), + ExpressionTestCase( + "last_all", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": -3}, + expected=[1, 2, 3], + msg="$slice should return all elements via negative n", + ), + ExpressionTestCase( + "last_single", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [42], "n": -1}, + expected=[42], + msg="$slice should return a single element via -1", + ), +] + +# Property [n Exceeds Length]: an n larger than the array returns the whole array. +N_EXCEEDS_LENGTH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "pos_n_exceeds", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": 10}, + expected=[1, 2, 3], + msg="$slice should return all when n exceeds length", + ), + ExpressionTestCase( + "neg_n_exceeds", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": -10}, + expected=[1, 2, 3], + msg="$slice should return all when negative n exceeds length", + ), + ExpressionTestCase( + "pos_n_int32_max", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": INT32_MAX}, + expected=[1, 2, 3], + msg="$slice should return all when n is INT32_MAX", + ), +] + +# Property [Zero n]: n of zero returns an empty array. +ZERO_N_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_n", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": 0}, + expected=[], + msg="$slice should return empty for n=0", + ), + ExpressionTestCase( + "zero_n_empty", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [], "n": 0}, + expected=[], + msg="$slice should return empty for n=0 on an empty array", + ), +] + +# Property [Empty Array]: slicing an empty array returns an empty array. +EMPTY_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "empty_pos_n", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [], "n": 5}, + expected=[], + msg="$slice should return empty for positive n on an empty array", + ), + ExpressionTestCase( + "empty_neg_n", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [], "n": -5}, + expected=[], + msg="$slice should return empty for negative n on an empty array", + ), +] + +# Property [Position]: the 3-arg form starts slicing at the given position. +POSITION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "pos_0_n_2", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "pos": 0, "n": 2}, + expected=[1, 2], + msg="$slice should slice from position 0", + ), + ExpressionTestCase( + "pos_1_n_2", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "pos": 1, "n": 2}, + expected=[2, 3], + msg="$slice should slice from position 1", + ), + ExpressionTestCase( + "pos_2_n_3", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "pos": 2, "n": 3}, + expected=[3, 4, 5], + msg="$slice should slice from position 2", + ), + ExpressionTestCase( + "pos_0_n_all", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": 0, "n": 3}, + expected=[1, 2, 3], + msg="$slice should slice all from position 0", + ), + ExpressionTestCase( + "pos_last_n_1", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": 2, "n": 1}, + expected=[3], + msg="$slice should slice the last element via position", + ), +] + +# Property [Negative Position]: a negative position counts from the end and clamps to the start. +NEGATIVE_POSITION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "neg_pos_1_n_1", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "pos": -1, "n": 1}, + expected=[5], + msg="$slice should slice from position -1", + ), + ExpressionTestCase( + "neg_pos_2_n_2", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "pos": -2, "n": 2}, + expected=[4, 5], + msg="$slice should slice from position -2", + ), + ExpressionTestCase( + "neg_pos_3_n_2", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "pos": -3, "n": 2}, + expected=[3, 4], + msg="$slice should slice from position -3", + ), + ExpressionTestCase( + "neg_pos_exceeds_n_2", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": -10, "n": 2}, + expected=[1, 2], + msg="$slice should clamp a negative position to the start", + ), + ExpressionTestCase( + "neg_pos_all", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": -3, "n": 3}, + expected=[1, 2, 3], + msg="$slice should slice all from a negative position", + ), +] + +# Property [Position n Exceeds]: n beyond the remaining elements returns what remains. +POSITION_N_EXCEEDS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "pos_n_exceeds_remaining", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "pos": 3, "n": 10}, + expected=[4, 5], + msg="$slice should return the remaining elements when n exceeds", + ), + ExpressionTestCase( + "pos_beyond_array", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": 10, "n": 2}, + expected=[], + msg="$slice should return empty when position is beyond the array", + ), + ExpressionTestCase( + "pos_int32_max", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": INT32_MAX, "n": 2}, + expected=[], + msg="$slice should return empty when position is INT32_MAX", + ), + ExpressionTestCase( + "pos_eq_length", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "pos": 5, "n": 3}, + expected=[], + msg="$slice should return empty when position equals array length", + ), +] + +# Property [Empty Array With Position]: the 3-arg form on an empty array returns an empty array. +EMPTY_ARRAY_POSITION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "empty_pos_0", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [], "pos": 0, "n": 3}, + expected=[], + msg="$slice should return empty for an empty array with position 0", + ), + ExpressionTestCase( + "empty_neg_pos", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [], "pos": -1, "n": 3}, + expected=[], + msg="$slice should return empty for an empty array with a negative position", + ), + ExpressionTestCase( + "empty_pos_beyond", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [], "pos": 5, "n": 3}, + expected=[], + msg="$slice should return empty for an empty array with position beyond", + ), +] + +# Property [Single Element With Position]: position selects or misses the single element. +SINGLE_ELEMENT_POSITION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "single_pos_0_n_1", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": ["only"], "pos": 0, "n": 1}, + expected=["only"], + msg="$slice should return the element at position 0", + ), + ExpressionTestCase( + "single_neg_1_n_1", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": ["only"], "pos": -1, "n": 1}, + expected=["only"], + msg="$slice should return the element via a negative position", + ), + ExpressionTestCase( + "single_pos_past_end", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": ["only"], "pos": 1, "n": 1}, + expected=[], + msg="$slice should return empty when position is past the single element", + ), +] + +# Property [Element Preservation]: sliced elements retain their type and value. +NESTED_MIXED_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "mixed_bson_slice", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, "two", {"a": 1}, [3, 4], True, None, Decimal128("5.5")], "n": 4}, + expected=[1, "two", {"a": 1}, [3, 4]], + msg="$slice should slice mixed BSON types", + ), + ExpressionTestCase( + "mixed_bson_slice_neg", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, "two", {"a": 1}, [3, 4], True, None, Decimal128("5.5")], "n": -3}, + expected=[True, None, Decimal128("5.5")], + msg="$slice should slice the last 3 mixed BSON types", + ), + ExpressionTestCase( + "deeply_nested_slice", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [[[1, 2], [3, 4]], [[5, 6]], "end"], "n": 2}, + expected=[[[1, 2], [3, 4]], [[5, 6]]], + msg="$slice should slice deeply nested arrays", + ), +] + +# Property [Large Array]: slicing works on large arrays. +LARGE_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "large_first_5", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": list(range(10_000)), "n": 5}, + expected=[0, 1, 2, 3, 4], + msg="$slice should slice the first 5 from a large array", + ), + ExpressionTestCase( + "large_last_5", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": list(range(10_000)), "n": -5}, + expected=[9_995, 9_996, 9_997, 9_998, 9_999], + msg="$slice should slice the last 5 from a large array", + ), + ExpressionTestCase( + "large_pos_middle", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": list(range(10_000)), "pos": 5_000, "n": 3}, + expected=[5_000, 5_001, 5_002], + msg="$slice should slice from the middle of a large array", + ), +] + +ALL_TESTS = ( + POSITIVE_N_TESTS + + NEGATIVE_N_TESTS + + N_EXCEEDS_LENGTH_TESTS + + ZERO_N_TESTS + + EMPTY_ARRAY_TESTS + + POSITION_TESTS + + NEGATIVE_POSITION_TESTS + + POSITION_N_EXCEEDS_TESTS + + EMPTY_ARRAY_POSITION_TESTS + + SINGLE_ELEMENT_POSITION_TESTS + + NESTED_MIXED_TESTS + + LARGE_ARRAY_TESTS +) + +# Property [Literal Arguments]: $slice accepts literal array and index arguments. +LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_first_1", + expression={"$slice": [[1, 2, 3, 4, 5], 1]}, + expected=[1], + msg="$slice should return the first element from literal arguments", + ), + ExpressionTestCase( + "literal_last_1", + expression={"$slice": [[1, 2, 3, 4, 5], -1]}, + expected=[5], + msg="$slice should return the last element from literal arguments", + ), + ExpressionTestCase( + "literal_pos_0_n_2", + expression={"$slice": [[1, 2, 3, 4, 5], 0, 2]}, + expected=[1, 2], + msg="$slice should slice from a literal position", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(LITERAL_TESTS)) +def test_slice_literal(collection, test): + """Test $slice with literal values.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_slice_insert(collection, test): + """Test $slice 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/slice/test_expression_slice_element_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_element_types.py new file mode 100644 index 000000000..06feafc73 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_element_types.py @@ -0,0 +1,313 @@ +""" +Numeric type and element type preservation tests for $slice expression. + +Tests that n/position accept any integral numeric type (normalizing non-canonical +forms like -0.0) and that sliced elements, including special numeric values, retain +their original BSON type when passed as field references or literal arguments. +""" + +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, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_TWO_AND_HALF, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Property [Numeric Argument Types]: n and position accept any integral numeric type, +# and non-canonical representations of an integer (negative zero, alternate Decimal128 +# exponent forms) normalize to their integer value. +NUMERIC_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "n_int64", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": Int64(2)}, + expected=[1, 2], + msg="$slice should accept Int64 n", + ), + ExpressionTestCase( + "n_double_integral", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": 2.0}, + expected=[1, 2], + msg="$slice should accept an integral double n", + ), + ExpressionTestCase( + "n_decimal128_integral", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": Decimal128("2")}, + expected=[1, 2], + msg="$slice should accept Decimal128 n", + ), + ExpressionTestCase( + "n_neg_zero_double", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": DOUBLE_NEGATIVE_ZERO}, + expected=[], + msg="$slice should treat -0.0 n as 0", + ), + ExpressionTestCase( + "n_neg_zero_decimal128", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": DECIMAL128_NEGATIVE_ZERO}, + expected=[], + msg="$slice should treat decimal128 -0 n as 0", + ), + ExpressionTestCase( + "pos_int64", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "pos": Int64(1), "n": 2}, + expected=[2, 3], + msg="$slice should accept Int64 position", + ), + ExpressionTestCase( + "pos_double_integral", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "pos": 1.0, "n": 2}, + expected=[2, 3], + msg="$slice should accept an integral double position", + ), + ExpressionTestCase( + "pos_decimal128_integral", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3, 4, 5], "pos": Decimal128("1"), "n": 2}, + expected=[2, 3], + msg="$slice should accept Decimal128 position", + ), + ExpressionTestCase( + "pos_neg_zero_double", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": DOUBLE_NEGATIVE_ZERO, "n": 2}, + expected=[1, 2], + msg="$slice should treat -0.0 position as 0", + ), + ExpressionTestCase( + "pos_neg_zero_decimal128", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": DECIMAL128_NEGATIVE_ZERO, "n": 2}, + expected=[1, 2], + msg="$slice should treat Decimal128 -0 position as 0", + ), + ExpressionTestCase( + "pos_decimal128_10E_neg1", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": Decimal128("10E-1"), "n": 2}, + expected=[2, 3], + msg="$slice should treat Decimal128 10E-1 position as 1", + ), +] + +# Property [Element Preservation]: sliced elements retain their original BSON type and value. +ELEMENT_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "elem_int64", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [Int64(99), Int64(100)], "n": 1}, + expected=[Int64(99)], + msg="$slice should preserve Int64 elements", + ), + ExpressionTestCase( + "elem_decimal128", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [DECIMAL128_ONE_AND_HALF, DECIMAL128_TWO_AND_HALF], "n": 1}, + expected=[DECIMAL128_ONE_AND_HALF], + msg="$slice should preserve Decimal128 elements", + ), + ExpressionTestCase( + "elem_datetime", + expression={"$slice": ["$arr", "$n"]}, + doc={ + "arr": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 2, 1, tzinfo=timezone.utc), + ], + "n": 1, + }, + expected=[datetime(2024, 1, 1, tzinfo=timezone.utc)], + msg="$slice should preserve datetime elements", + ), + ExpressionTestCase( + "elem_objectid", + expression={"$slice": ["$arr", "$n"]}, + doc={ + "arr": [ObjectId("000000000000000000000001"), ObjectId("000000000000000000000002")], + "n": 1, + }, + expected=[ObjectId("000000000000000000000001")], + msg="$slice should preserve ObjectId elements", + ), + ExpressionTestCase( + "elem_binary", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [Binary(b"\x01", 0), Binary(b"\x02", 0)], "n": 1}, + expected=[b"\x01"], + msg="$slice should preserve binary elements", + ), + ExpressionTestCase( + "elem_regex", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [Regex("^a", "i"), Regex("^b", "i")], "n": 1}, + expected=[Regex("^a", "i")], + msg="$slice should preserve regex elements", + ), + ExpressionTestCase( + "elem_timestamp", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [Timestamp(1, 1), Timestamp(2, 2)], "n": 1}, + expected=[Timestamp(1, 1)], + msg="$slice should preserve timestamp elements", + ), + ExpressionTestCase( + "elem_minkey", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [MinKey(), 1], "n": 1}, + expected=[MinKey()], + msg="$slice should preserve MinKey elements", + ), + ExpressionTestCase( + "elem_maxkey", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, MaxKey()], "n": -1}, + expected=[MaxKey()], + msg="$slice should preserve MaxKey elements", + ), + ExpressionTestCase( + "elem_bool", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [True, False], "n": 1}, + expected=[True], + msg="$slice should preserve bool elements", + ), + ExpressionTestCase( + "elem_null", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [None, 1], "n": 1}, + expected=[None], + msg="$slice should preserve null elements", + ), + ExpressionTestCase( + "elem_nested_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [[1, 2], [3, 4]], "n": 1}, + expected=[[1, 2]], + msg="$slice should preserve nested array elements", + ), + ExpressionTestCase( + "elem_object", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [{"a": 1}, {"b": 2}], "n": 1}, + expected=[{"a": 1}], + msg="$slice should preserve object elements", + ), + ExpressionTestCase( + "elem_uuid", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), 1], "n": 1}, + expected=[Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210"))], + msg="$slice should preserve UUID binary elements", + ), + ExpressionTestCase( + "elem_float_nan", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [FLOAT_NAN, 1, 2], "n": 1}, + expected=[pytest.approx(FLOAT_NAN, nan_ok=True)], + msg="$slice should preserve NaN elements", + ), + ExpressionTestCase( + "elem_float_infinity", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [FLOAT_INFINITY, 1], "n": 1}, + expected=[FLOAT_INFINITY], + msg="$slice should preserve Infinity elements", + ), + ExpressionTestCase( + "elem_float_neg_infinity", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [FLOAT_NEGATIVE_INFINITY, 1], "n": 1}, + expected=[FLOAT_NEGATIVE_INFINITY], + msg="$slice should preserve -Infinity elements", + ), + ExpressionTestCase( + "elem_decimal128_nan", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [DECIMAL128_NAN, 1], "n": 1}, + expected=[DECIMAL128_NAN], + msg="$slice should preserve Decimal128 NaN elements", + ), + ExpressionTestCase( + "elem_decimal128_infinity", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [DECIMAL128_INFINITY, 1], "n": 1}, + expected=[DECIMAL128_INFINITY], + msg="$slice should preserve Decimal128 Infinity elements", + ), + ExpressionTestCase( + "elem_decimal128_neg_infinity", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [DECIMAL128_NEGATIVE_INFINITY, 1], "n": 1}, + expected=[DECIMAL128_NEGATIVE_INFINITY], + msg="$slice should preserve Decimal128 -Infinity elements", + ), +] + +ALL_TESTS = NUMERIC_TYPE_TESTS + ELEMENT_TYPE_TESTS + +# Property [Literal Arguments]: $slice accepts literal arrays and numeric arguments. +LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_n_int64", + expression={"$slice": [[1, 2, 3], Int64(2)]}, + expected=[1, 2], + msg="$slice should accept a literal Int64 n", + ), + ExpressionTestCase( + "literal_elem_int64", + expression={"$slice": [[Int64(99), Int64(100)], 1]}, + expected=[Int64(99)], + msg="$slice should preserve Int64 elements from a literal array", + ), + ExpressionTestCase( + "literal_elem_decimal128_neg_infinity", + expression={"$slice": [[DECIMAL128_NEGATIVE_INFINITY, 1], 1]}, + expected=[DECIMAL128_NEGATIVE_INFINITY], + msg="$slice should preserve a Decimal128 -Infinity element from a literal array", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(LITERAL_TESTS)) +def test_slice_literal(collection, test): + """Test $slice element/numeric 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 + ) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_slice_insert(collection, test): + """Test $slice element/numeric 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/slice/test_expression_slice_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_errors.py new file mode 100644 index 000000000..4c68ee2e5 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_errors.py @@ -0,0 +1,581 @@ +""" +Error tests for $slice expression. + +Tests non-array first argument, non-numeric/non-integral n and position, +non-positive n in 3-arg form, wrong arity errors, and a field path resolving +to an invalid-type value used as an argument. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + EXPRESSION_ARITY_ERROR, + EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + EXPRESSION_SLICE_N_NOT_POSITIVE_ERROR, + SLICE_INVALID_ARGUMENT_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_HALF, + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_ZERO, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT64_MAX, +) + +# Property [Positive n Required]: in the 3-arg form, a non-positive n is rejected. +N_NOT_POSITIVE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "pos_0_n_0", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": 0, "n": 0}, + error_code=EXPRESSION_SLICE_N_NOT_POSITIVE_ERROR, + msg="$slice should reject n=0 in the 3-arg form", + ), + ExpressionTestCase( + "pos_1_n_0", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": 1, "n": 0}, + error_code=EXPRESSION_SLICE_N_NOT_POSITIVE_ERROR, + msg="$slice should reject n=0 with position 1", + ), + ExpressionTestCase( + "pos_0_n_neg", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": 0, "n": -1}, + error_code=EXPRESSION_SLICE_N_NOT_POSITIVE_ERROR, + msg="$slice should reject negative n in the 3-arg form", + ), + ExpressionTestCase( + "pos_1_n_neg", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": 1, "n": -2}, + error_code=EXPRESSION_SLICE_N_NOT_POSITIVE_ERROR, + msg="$slice should reject n=-2 in the 3-arg form", + ), + ExpressionTestCase( + "neg_pos_n_neg", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": -1, "n": -1}, + error_code=EXPRESSION_SLICE_N_NOT_POSITIVE_ERROR, + msg="$slice should reject negative n with a negative position", + ), + ExpressionTestCase( + "n_neg_zero_double_3arg", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": 0, "n": DOUBLE_NEGATIVE_ZERO}, + error_code=EXPRESSION_SLICE_N_NOT_POSITIVE_ERROR, + msg="$slice should reject -0.0 n in the 3-arg form", + ), + ExpressionTestCase( + "n_neg_zero_decimal128_3arg", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": 0, "n": DECIMAL128_NEGATIVE_ZERO}, + error_code=EXPRESSION_SLICE_N_NOT_POSITIVE_ERROR, + msg="$slice should reject Decimal128 -0 n in the 3-arg form", + ), +] + +# Property [Array Required]: a non-array, non-null first argument is rejected for every BSON type. +NOT_ARRAY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": "hello", "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject a string as the array argument", + ), + ExpressionTestCase( + "int_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": 42, "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject an int as the array argument", + ), + ExpressionTestCase( + "double_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": 3.14, "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject a double as the array argument", + ), + ExpressionTestCase( + "bool_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": True, "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject a bool as the array argument", + ), + ExpressionTestCase( + "object_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": {"a": 1}, "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject an object as the array argument", + ), + ExpressionTestCase( + "decimal128_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": Decimal128("1"), "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject a decimal128 as the array argument", + ), + ExpressionTestCase( + "int64_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": Int64(1), "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject an int64 as the array argument", + ), + ExpressionTestCase( + "binary_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": Binary(b"x", 0), "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject a binary as the array argument", + ), + ExpressionTestCase( + "datetime_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": datetime(2024, 1, 1, tzinfo=timezone.utc), "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject a datetime as the array argument", + ), + ExpressionTestCase( + "objectid_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": ObjectId(), "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject an objectid as the array argument", + ), + ExpressionTestCase( + "regex_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": Regex("x"), "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject a regex as the array argument", + ), + ExpressionTestCase( + "javascript_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": Code("x"), "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject JavaScript code as the array argument", + ), + ExpressionTestCase( + "timestamp_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": Timestamp(0, 0), "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject a timestamp as the array argument", + ), + ExpressionTestCase( + "minkey_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": MinKey(), "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject minkey as the array argument", + ), + ExpressionTestCase( + "maxkey_as_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": MaxKey(), "n": 1}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject maxkey as the array argument", + ), +] + +# Property [Numeric n]: a non-numeric n is rejected for every non-deprecated BSON type. +N_NOT_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "n_string", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": "2"}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject string n", + ), + ExpressionTestCase( + "n_bool", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": True}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject bool n", + ), + ExpressionTestCase( + "n_array", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": [2]}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject array n", + ), + ExpressionTestCase( + "n_object", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": {"a": 2}}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject object n", + ), + ExpressionTestCase( + "n_datetime", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject datetime n", + ), + ExpressionTestCase( + "n_objectid", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": ObjectId()}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject objectid n", + ), + ExpressionTestCase( + "n_binary", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": Binary(b"x", 0)}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject binary n", + ), + ExpressionTestCase( + "n_regex", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": Regex("x")}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject regex n", + ), + ExpressionTestCase( + "n_javascript", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": Code("x")}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject JavaScript code n", + ), + ExpressionTestCase( + "n_timestamp", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": Timestamp(0, 0)}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject timestamp n", + ), + ExpressionTestCase( + "n_minkey", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": MinKey()}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject minkey n", + ), + ExpressionTestCase( + "n_maxkey", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": MaxKey()}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject maxkey n", + ), +] + +# Property [32-bit Representability]: n must be a whole number representable as a +# signed 32-bit integer; fractional values, NaN/Infinity, and integral values outside +# the int32 range are all rejected with the same error. +N_NOT_INTEGRAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "n_fractional_double", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": 1.5}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject a fractional double n", + ), + ExpressionTestCase( + "n_fractional_decimal128", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": DECIMAL128_HALF}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject a fractional decimal128 n", + ), + ExpressionTestCase( + "n_nan", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": FLOAT_NAN}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject NaN n", + ), + ExpressionTestCase( + "n_inf", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": FLOAT_INFINITY}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject infinity n", + ), + ExpressionTestCase( + "n_neg_inf", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": FLOAT_NEGATIVE_INFINITY}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject -infinity n", + ), + ExpressionTestCase( + "n_decimal128_nan", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": DECIMAL128_NAN}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject decimal128 NaN n", + ), + ExpressionTestCase( + "n_decimal128_inf", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": DECIMAL128_INFINITY}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject decimal128 infinity n", + ), + ExpressionTestCase( + "n_int64_max", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": INT64_MAX}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject n that is a whole number outside the 32-bit integer range", + ), +] + +# Property [Numeric Position]: a non-numeric position is rejected for every BSON type. +POS_NOT_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "pos_string", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": "1", "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject string position", + ), + ExpressionTestCase( + "pos_bool", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": True, "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject bool position", + ), + ExpressionTestCase( + "pos_array", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": [1], "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject array position", + ), + ExpressionTestCase( + "pos_object", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": {"a": 1}, "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject object position", + ), + ExpressionTestCase( + "pos_datetime", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": datetime(2024, 1, 1, tzinfo=timezone.utc), "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject datetime position", + ), + ExpressionTestCase( + "pos_objectid", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": ObjectId(), "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject objectid position", + ), + ExpressionTestCase( + "pos_binary", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": Binary(b"x", 0), "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject binary position", + ), + ExpressionTestCase( + "pos_regex", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": Regex("x"), "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject regex position", + ), + ExpressionTestCase( + "pos_javascript", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": Code("x"), "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject JavaScript code position", + ), + ExpressionTestCase( + "pos_timestamp", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": Timestamp(0, 0), "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject timestamp position", + ), + ExpressionTestCase( + "pos_minkey", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": MinKey(), "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject minkey position", + ), + ExpressionTestCase( + "pos_maxkey", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": MaxKey(), "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject maxkey position", + ), +] + +# Property [32-bit Representability]: position must be a whole number representable as +# a signed 32-bit integer; fractional values, NaN/Infinity, and integral values outside +# the int32 range are all rejected with the same error. +POS_NOT_INTEGRAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "pos_fractional", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": 1.5, "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject a fractional position", + ), + ExpressionTestCase( + "pos_nan", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": FLOAT_NAN, "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject NaN position", + ), + ExpressionTestCase( + "pos_inf", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": FLOAT_INFINITY, "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject infinity position", + ), + ExpressionTestCase( + "pos_neg_inf", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": FLOAT_NEGATIVE_INFINITY, "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject -infinity position", + ), + ExpressionTestCase( + "pos_decimal128_nan", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": DECIMAL128_NAN, "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject decimal128 NaN position", + ), + ExpressionTestCase( + "pos_decimal128_inf", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": DECIMAL128_INFINITY, "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject decimal128 infinity position", + ), + ExpressionTestCase( + "pos_int64_max", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": INT64_MAX, "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg=( + "$slice should reject a position that is a whole number outside the " + "32-bit integer range" + ), + ), +] + +# Property [Field Expression n]: a field path that resolves to a non-numeric n is rejected. +FIELD_EXPR_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "composite_array_as_n", + expression={"$slice": [[1, 2, 3], "$x.y"]}, + doc={"x": [{"y": 1}, {"y": 2}]}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject a composite array resolved as the n argument", + ), +] + +ALL_TESTS = ( + N_NOT_POSITIVE_ERROR_TESTS + + NOT_ARRAY_ERROR_TESTS + + N_NOT_NUMERIC_TESTS + + N_NOT_INTEGRAL_TESTS + + POS_NOT_NUMERIC_TESTS + + POS_NOT_INTEGRAL_TESTS + + FIELD_EXPR_ERROR_TESTS +) + +# Property [Literal Arguments]: errors are raised the same way with literal arguments. +LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_string_as_array", + expression={"$slice": ["hello", 1]}, + error_code=SLICE_INVALID_ARGUMENT_ERROR, + msg="$slice should reject a literal string as the array argument", + ), + ExpressionTestCase( + "literal_pos_0_n_0", + expression={"$slice": [[1, 2, 3], 0, 0]}, + error_code=EXPRESSION_SLICE_N_NOT_POSITIVE_ERROR, + msg="$slice should reject literal n=0 in the 3-arg form", + ), + ExpressionTestCase( + "literal_n_fractional_double", + expression={"$slice": [[1, 2, 3], 1.5]}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject a literal fractional double n", + ), +] + +# Property [Arity]: $slice requires exactly two or three arguments. +ARITY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_args", + expression={"$slice": []}, + error_code=EXPRESSION_ARITY_ERROR, + msg="$slice should reject zero arguments", + ), + ExpressionTestCase( + "one_arg", + expression={"$slice": [[1, 2, 3]]}, + error_code=EXPRESSION_ARITY_ERROR, + msg="$slice should reject a single argument", + ), + ExpressionTestCase( + "four_args", + expression={"$slice": [[1, 2, 3], 0, 2, 1]}, + error_code=EXPRESSION_ARITY_ERROR, + msg="$slice should reject four arguments", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(LITERAL_TESTS + ARITY_ERROR_TESTS)) +def test_slice_literal(collection, test): + """Test $slice error cases with literal values, including wrong arity.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_slice_insert(collection, test): + """Test $slice error cases with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_expressions.py new file mode 100644 index 000000000..5ba46134a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_expressions.py @@ -0,0 +1,76 @@ +""" +Expression and field path tests for $slice expression. + +Tests nested expressions, field path lookups, and composite paths. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Nested Expression]: $slice can consume the output of a nested $slice. +NESTED_EXPR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_slice_2_level", + expression={"$slice": [{"$slice": [[1, 2, 3, 4, 5], 4]}, -2]}, + expected=[3, 4], + msg="$slice should slice the output of a nested $slice", + ), + ExpressionTestCase( + "nested_slice_3_level", + expression={"$slice": [{"$slice": [{"$slice": [[1, 2, 3, 4, 5, 6, 7], 5]}, -4]}, 2]}, + expected=[2, 3], + msg="$slice should slice through three nested $slice levels", + ), +] + +# Property [Field Path Input]: $slice resolves a field path to the array argument. +FIELD_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_path", + expression={"$slice": ["$a.b", 2]}, + doc={"a": {"b": [10, 20, 30]}}, + expected=[10, 20], + msg="$slice should resolve a nested field path", + ), + ExpressionTestCase( + "deeply_nested_field", + expression={"$slice": ["$a.b.c", -2]}, + doc={"a": {"b": {"c": [5, 6, 7, 8]}}}, + expected=[7, 8], + msg="$slice should resolve a deeply nested field path", + ), + ExpressionTestCase( + "composite_array_path", + expression={"$slice": ["$a.b", 2]}, + doc={"a": [{"b": 10}, {"b": 20}, {"b": 30}]}, + expected=[10, 20], + msg="$slice should resolve a composite array path", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(NESTED_EXPR_TESTS)) +def test_slice_nested_expression(collection, test): + """Test $slice composed with other expressions.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(FIELD_LOOKUP_TESTS)) +def test_slice_field_lookup(collection, test): + """Test $slice with field path lookups 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/slice/test_expression_slice_null_missing.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_null_missing.py new file mode 100644 index 000000000..77d164744 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_null_missing.py @@ -0,0 +1,133 @@ +""" +Null and missing field handling tests for $slice expression. + +Tests that a null or missing array, n, or position (2-arg and 3-arg forms) propagates +to null, via both field references and literal arguments. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import MISSING + +# Property [Null Propagation]: a null array, null n, or null position (3-arg) yields null. +NULL_INSERT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_array_2arg", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": None, "n": 2}, + expected=None, + msg="$slice should return null for a null array in the 2-arg form", + ), + ExpressionTestCase( + "null_array_3arg", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": None, "pos": 0, "n": 2}, + expected=None, + msg="$slice should return null for a null array in the 3-arg form", + ), + ExpressionTestCase( + "null_n_2arg", + expression={"$slice": ["$arr", "$n"]}, + doc={"arr": [1, 2, 3], "n": None}, + expected=None, + msg="$slice should return null for a null n", + ), + ExpressionTestCase( + "null_pos_3arg", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": None, "n": 2}, + expected=None, + msg="$slice should return null for a null position in the 3-arg form", + ), +] + +# Property [Null Literal Propagation]: null literal arguments yield null. +NULL_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_array_2arg_literal", + expression={"$slice": [None, 2]}, + expected=None, + msg="$slice should return null for a literal null array in the 2-arg form", + ), + ExpressionTestCase( + "null_array_3arg_literal", + expression={"$slice": [None, 0, 2]}, + expected=None, + msg="$slice should return null for a literal null array in the 3-arg form", + ), + ExpressionTestCase( + "null_n_2arg_literal", + expression={"$slice": [[1, 2, 3], None]}, + expected=None, + msg="$slice should return null for a literal null n", + ), + ExpressionTestCase( + "null_pos_3arg_literal", + expression={"$slice": [[1, 2, 3], None, 2]}, + expected=None, + msg="$slice should return null for a literal null position in the 3-arg form", + ), + ExpressionTestCase( + "null_n_3arg_literal", + expression={"$slice": [[1, 2, 3], 0, None]}, + expected=None, + msg="$slice should return null for a literal null n in the 3-arg form", + ), +] + +# Property [Missing Field Propagation]: a missing field reference in any argument yields null. +MISSING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_array_2arg", + expression={"$slice": [MISSING, 2]}, + expected=None, + msg="$slice should return null for a missing array", + ), + ExpressionTestCase( + "missing_n_2arg", + expression={"$slice": [[1, 2, 3], MISSING]}, + expected=None, + msg="$slice should return null for a missing n in the 2-arg form", + ), + ExpressionTestCase( + "missing_pos_3arg", + expression={"$slice": [[1, 2, 3], MISSING, 2]}, + expected=None, + msg="$slice should return null for a missing position in the 3-arg form", + ), + ExpressionTestCase( + "missing_n_3arg", + expression={"$slice": [[1, 2, 3], 0, MISSING]}, + expected=None, + msg="$slice should return null for a missing n in the 3-arg form", + ), +] + +LITERAL_TESTS = NULL_LITERAL_TESTS + MISSING_TESTS + + +@pytest.mark.parametrize("test", pytest_params(LITERAL_TESTS)) +def test_slice_literal(collection, test): + """Test $slice null/missing with literal values.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(NULL_INSERT_TESTS)) +def test_slice_insert(collection, test): + """Test $slice null 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/slice/test_smoke_expression_slice.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_smoke_expression_slice.py index a4971bf99..59b1f6ae3 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_smoke_expression_slice.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_smoke_expression_slice.py @@ -28,4 +28,4 @@ def test_smoke_expression_slice(collection): ) expected = [{"_id": 1, "sliced": [1, 2]}, {"_id": 2, "sliced": [10, 20]}] - assertSuccess(result, expected, msg="Should support $slice expression") + assertSuccess(result, expected, "$slice should return the first n elements in $project") diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_reduce.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_reduce.py new file mode 100644 index 000000000..bf61dc285 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_reduce.py @@ -0,0 +1,107 @@ +""" +Combination tests for $reduce 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 + +# Property [Operator Composition]: $reduce composes correctly with $range, $map, $size, +# $cond, and comparison operators. +REDUCE_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="reduce_sum_on_range", + expression={ + "$reduce": { + "input": {"$range": [1, 6]}, + "initialValue": 0, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"_placeholder": 1}, + expected=15, + msg="Should sum $range(1,6)", + ), + ExpressionTestCase( + id="reduce_on_map", + expression={ + "$reduce": { + "input": {"$map": {"input": "$arr", "in": {"$multiply": ["$$this", 2]}}}, + "initialValue": 0, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"arr": [1, 2, 3]}, + expected=12, + msg="Should sum mapped array", + ), + ExpressionTestCase( + id="reduce_flatten_then_size", + expression={ + "$size": { + "$reduce": { + "input": "$arr", + "initialValue": [], + "in": {"$concatArrays": ["$$value", "$$this"]}, + } + } + }, + doc={"arr": [[1, 2], [3], [4, 5, 6]]}, + expected=6, + msg="Size of flattened array", + ), + ExpressionTestCase( + id="max_value", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": 0, + "in": { + "$cond": { + "if": {"$gt": ["$$this", "$$value"]}, + "then": "$$this", + "else": "$$value", + } + }, + } + }, + doc={"arr": [3, 1, 4, 1, 5, 9, 2, 6]}, + expected=9, + msg="Should find max value", + ), + ExpressionTestCase( + id="null_elements_count_non_null", + expression={ + "$reduce": { + "input": "$arr", + "initialValue": 0, + "in": { + "$cond": { + "if": {"$ne": ["$$this", None]}, + "then": {"$add": ["$$value", 1]}, + "else": "$$value", + } + }, + } + }, + doc={"arr": [1, None, 2, None, 3]}, + expected=3, + msg="Should count non-null elements", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(REDUCE_COMBINATION_TESTS)) +def test_reduce_combination(collection, test): + """Test $reduce 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_size.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_size.py new file mode 100644 index 000000000..b3021ad17 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_size.py @@ -0,0 +1,68 @@ +""" +Combination tests for $size 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 + +SIZE_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="size_on_range", + expression={"$size": {"$range": ["$start", "$end"]}}, + doc={"start": 0, "end": 10}, + expected=10, + msg="Should return size of $range result", + ), + ExpressionTestCase( + id="size_on_split", + expression={"$size": {"$split": ["$str", ","]}}, + doc={"str": "a,b,c,d"}, + expected=4, + msg="Should return size of $split result", + ), + ExpressionTestCase( + id="size_on_slice", + expression={"$size": {"$slice": ["$arr", 3]}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=3, + msg="Should return size of $slice result", + ), + ExpressionTestCase( + id="size_on_objectToArray", + expression={"$size": {"$objectToArray": "$obj"}}, + doc={"obj": {"a": 1, "b": 2}}, + expected=2, + msg="Should return size of $objectToArray result", + ), + ExpressionTestCase( + id="size_on_reverseArray", + expression={"$size": {"$reverseArray": "$arr"}}, + doc={"arr": [1, 2, 3]}, + expected=3, + msg="Should return size of $reverseArray result", + ), + ExpressionTestCase( + id="size_subtract", + expression={"$subtract": [{"$size": "$a"}, {"$size": "$b"}]}, + doc={"a": [1, 2, 3, 4, 5], "b": [1, 2]}, + expected=3, + msg="Should subtract two $size results", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SIZE_COMBINATION_TESTS)) +def test_size_combination(collection, test): + """Test $size 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_slice.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_slice.py new file mode 100644 index 000000000..529efd554 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_slice.py @@ -0,0 +1,75 @@ +""" +Combination tests for $slice 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 + +# Property [Composition]: $slice composes with array-producing and array-consuming operators. +SLICE_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "slice_on_concatArrays", + expression={"$slice": [{"$concatArrays": ["$a", "$b"]}, 3]}, + doc={"a": [1, 2], "b": [3, 4, 5]}, + expected=[1, 2, 3], + msg="$slice should slice the result of $concatArrays", + ), + ExpressionTestCase( + "slice_on_map", + expression={"$slice": [{"$map": {"input": "$a", "in": {"$multiply": ["$$this", 2]}}}, 2]}, + doc={"a": [1, 2, 3]}, + expected=[2, 4], + msg="$slice should slice the result of $map", + ), + ExpressionTestCase( + "slice_on_range", + expression={"$slice": [{"$range": ["$start", "$end"]}, -3]}, + doc={"start": 0, "end": 10}, + expected=[7, 8, 9], + msg="$slice should slice the result of $range", + ), + ExpressionTestCase( + "reduce_on_slice", + expression={ + "$reduce": { + "input": {"$slice": ["$a", 3]}, + "initialValue": 0, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"a": [1, 2, 3, 4, 5]}, + expected=6, + msg="$reduce should consume the result of $slice", + ), + ExpressionTestCase( + "concatArrays_of_slices", + expression={"$concatArrays": [{"$slice": ["$a", 2]}, {"$slice": ["$b", -1]}]}, + doc={"a": [1, 2, 3], "b": [4, 5, 6]}, + expected=[1, 2, 6], + msg="$slice results should compose under $concatArrays", + ), + ExpressionTestCase( + "slice_on_filter", + expression={"$slice": [{"$filter": {"input": "$a", "cond": {"$gt": ["$$this", 2]}}}, 2]}, + doc={"a": [1, 2, 3, 4, 5]}, + expected=[3, 4], + msg="$slice should slice the result of $filter", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SLICE_COMBINATION_TESTS)) +def test_slice_combination(collection, test): + """Test $slice 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 af947464eaa45b3881062ad39d21203410c16eb2 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" Date: Mon, 13 Jul 2026 15:34:16 -0700 Subject: [PATCH 2/2] self-review changes Signed-off-by: Alina (Xi) Li --- .../test_expression_reduce_bson_types.py | 5 +- .../slice/test_expression_slice_errors.py | 154 +------------- .../test_expression_slice_position_errors.py | 189 ++++++++++++++++++ 3 files changed, 193 insertions(+), 155 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_position_errors.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_bson_types.py index f959ccf82..fbd79cfc3 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_bson_types.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/reduce/test_expression_reduce_bson_types.py @@ -222,10 +222,7 @@ ] # Property [Special Numeric Elements]: $reduce preserves Infinity, NaN, INT32/INT64 -# boundary values, and negative-zero elements. Python float NaN != NaN under plain -# equality, so float_nan_values compares via pytest.approx(..., nan_ok=True) instead -# (the same NaN-aware comparison technique used elsewhere in this test suite, e.g. -# elem_float_nan in test_expression_slice_element_types.py). +# boundary values, and negative-zero elements. SPECIAL_NUMERIC_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( "float_nan_values", diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_errors.py index 4c68ee2e5..04530a321 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_errors.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_errors.py @@ -1,9 +1,9 @@ """ Error tests for $slice expression. -Tests non-array first argument, non-numeric/non-integral n and position, -non-positive n in 3-arg form, wrong arity errors, and a field path resolving -to an invalid-type value used as an argument. +Tests non-array first argument, non-numeric/non-integral n, non-positive n +in 3-arg form, wrong arity errors, and a field path resolving to an +invalid-type n. Position-argument errors are in test_expression_slice_position_errors.py. """ from datetime import datetime, timezone @@ -351,152 +351,6 @@ ), ] -# Property [Numeric Position]: a non-numeric position is rejected for every BSON type. -POS_NOT_NUMERIC_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "pos_string", - expression={"$slice": ["$arr", "$pos", "$n"]}, - doc={"arr": [1, 2, 3], "pos": "1", "n": 2}, - error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, - msg="$slice should reject string position", - ), - ExpressionTestCase( - "pos_bool", - expression={"$slice": ["$arr", "$pos", "$n"]}, - doc={"arr": [1, 2, 3], "pos": True, "n": 2}, - error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, - msg="$slice should reject bool position", - ), - ExpressionTestCase( - "pos_array", - expression={"$slice": ["$arr", "$pos", "$n"]}, - doc={"arr": [1, 2, 3], "pos": [1], "n": 2}, - error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, - msg="$slice should reject array position", - ), - ExpressionTestCase( - "pos_object", - expression={"$slice": ["$arr", "$pos", "$n"]}, - doc={"arr": [1, 2, 3], "pos": {"a": 1}, "n": 2}, - error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, - msg="$slice should reject object position", - ), - ExpressionTestCase( - "pos_datetime", - expression={"$slice": ["$arr", "$pos", "$n"]}, - doc={"arr": [1, 2, 3], "pos": datetime(2024, 1, 1, tzinfo=timezone.utc), "n": 2}, - error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, - msg="$slice should reject datetime position", - ), - ExpressionTestCase( - "pos_objectid", - expression={"$slice": ["$arr", "$pos", "$n"]}, - doc={"arr": [1, 2, 3], "pos": ObjectId(), "n": 2}, - error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, - msg="$slice should reject objectid position", - ), - ExpressionTestCase( - "pos_binary", - expression={"$slice": ["$arr", "$pos", "$n"]}, - doc={"arr": [1, 2, 3], "pos": Binary(b"x", 0), "n": 2}, - error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, - msg="$slice should reject binary position", - ), - ExpressionTestCase( - "pos_regex", - expression={"$slice": ["$arr", "$pos", "$n"]}, - doc={"arr": [1, 2, 3], "pos": Regex("x"), "n": 2}, - error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, - msg="$slice should reject regex position", - ), - ExpressionTestCase( - "pos_javascript", - expression={"$slice": ["$arr", "$pos", "$n"]}, - doc={"arr": [1, 2, 3], "pos": Code("x"), "n": 2}, - error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, - msg="$slice should reject JavaScript code position", - ), - ExpressionTestCase( - "pos_timestamp", - expression={"$slice": ["$arr", "$pos", "$n"]}, - doc={"arr": [1, 2, 3], "pos": Timestamp(0, 0), "n": 2}, - error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, - msg="$slice should reject timestamp position", - ), - ExpressionTestCase( - "pos_minkey", - expression={"$slice": ["$arr", "$pos", "$n"]}, - doc={"arr": [1, 2, 3], "pos": MinKey(), "n": 2}, - error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, - msg="$slice should reject minkey position", - ), - ExpressionTestCase( - "pos_maxkey", - expression={"$slice": ["$arr", "$pos", "$n"]}, - doc={"arr": [1, 2, 3], "pos": MaxKey(), "n": 2}, - error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, - msg="$slice should reject maxkey position", - ), -] - -# Property [32-bit Representability]: position must be a whole number representable as -# a signed 32-bit integer; fractional values, NaN/Infinity, and integral values outside -# the int32 range are all rejected with the same error. -POS_NOT_INTEGRAL_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "pos_fractional", - expression={"$slice": ["$arr", "$pos", "$n"]}, - doc={"arr": [1, 2, 3], "pos": 1.5, "n": 2}, - error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, - msg="$slice should reject a fractional position", - ), - ExpressionTestCase( - "pos_nan", - expression={"$slice": ["$arr", "$pos", "$n"]}, - doc={"arr": [1, 2, 3], "pos": FLOAT_NAN, "n": 2}, - error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, - msg="$slice should reject NaN position", - ), - ExpressionTestCase( - "pos_inf", - expression={"$slice": ["$arr", "$pos", "$n"]}, - doc={"arr": [1, 2, 3], "pos": FLOAT_INFINITY, "n": 2}, - error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, - msg="$slice should reject infinity position", - ), - ExpressionTestCase( - "pos_neg_inf", - expression={"$slice": ["$arr", "$pos", "$n"]}, - doc={"arr": [1, 2, 3], "pos": FLOAT_NEGATIVE_INFINITY, "n": 2}, - error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, - msg="$slice should reject -infinity position", - ), - ExpressionTestCase( - "pos_decimal128_nan", - expression={"$slice": ["$arr", "$pos", "$n"]}, - doc={"arr": [1, 2, 3], "pos": DECIMAL128_NAN, "n": 2}, - error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, - msg="$slice should reject decimal128 NaN position", - ), - ExpressionTestCase( - "pos_decimal128_inf", - expression={"$slice": ["$arr", "$pos", "$n"]}, - doc={"arr": [1, 2, 3], "pos": DECIMAL128_INFINITY, "n": 2}, - error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, - msg="$slice should reject decimal128 infinity position", - ), - ExpressionTestCase( - "pos_int64_max", - expression={"$slice": ["$arr", "$pos", "$n"]}, - doc={"arr": [1, 2, 3], "pos": INT64_MAX, "n": 2}, - error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, - msg=( - "$slice should reject a position that is a whole number outside the " - "32-bit integer range" - ), - ), -] - # Property [Field Expression n]: a field path that resolves to a non-numeric n is rejected. FIELD_EXPR_ERROR_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( @@ -513,8 +367,6 @@ + NOT_ARRAY_ERROR_TESTS + N_NOT_NUMERIC_TESTS + N_NOT_INTEGRAL_TESTS - + POS_NOT_NUMERIC_TESTS - + POS_NOT_INTEGRAL_TESTS + FIELD_EXPR_ERROR_TESTS ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_position_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_position_errors.py new file mode 100644 index 000000000..ca77afa9a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/slice/test_expression_slice_position_errors.py @@ -0,0 +1,189 @@ +""" +Position-argument error tests for $slice expression. + +Tests non-numeric and non-integral position rejection in the 3-arg form. +Array and n-argument errors are in test_expression_slice_errors.py. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT64_MAX, +) + +# Property [Numeric Position]: a non-numeric position is rejected for every BSON type. +POS_NOT_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "pos_string", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": "1", "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject string position", + ), + ExpressionTestCase( + "pos_bool", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": True, "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject bool position", + ), + ExpressionTestCase( + "pos_array", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": [1], "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject array position", + ), + ExpressionTestCase( + "pos_object", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": {"a": 1}, "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject object position", + ), + ExpressionTestCase( + "pos_datetime", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": datetime(2024, 1, 1, tzinfo=timezone.utc), "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject datetime position", + ), + ExpressionTestCase( + "pos_objectid", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": ObjectId(), "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject objectid position", + ), + ExpressionTestCase( + "pos_binary", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": Binary(b"x", 0), "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject binary position", + ), + ExpressionTestCase( + "pos_regex", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": Regex("x"), "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject regex position", + ), + ExpressionTestCase( + "pos_javascript", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": Code("x"), "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject JavaScript code position", + ), + ExpressionTestCase( + "pos_timestamp", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": Timestamp(0, 0), "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject timestamp position", + ), + ExpressionTestCase( + "pos_minkey", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": MinKey(), "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject minkey position", + ), + ExpressionTestCase( + "pos_maxkey", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": MaxKey(), "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_NUMERIC_ERROR, + msg="$slice should reject maxkey position", + ), +] + +# Property [32-bit Representability]: position must be a whole number representable as +# a signed 32-bit integer; fractional values, NaN/Infinity, and integral values outside +# the int32 range are all rejected with the same error. +POS_NOT_INTEGRAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "pos_fractional", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": 1.5, "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject a fractional position", + ), + ExpressionTestCase( + "pos_nan", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": FLOAT_NAN, "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject NaN position", + ), + ExpressionTestCase( + "pos_inf", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": FLOAT_INFINITY, "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject infinity position", + ), + ExpressionTestCase( + "pos_neg_inf", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": FLOAT_NEGATIVE_INFINITY, "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject -infinity position", + ), + ExpressionTestCase( + "pos_decimal128_nan", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": DECIMAL128_NAN, "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject decimal128 NaN position", + ), + ExpressionTestCase( + "pos_decimal128_inf", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": DECIMAL128_INFINITY, "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg="$slice should reject decimal128 infinity position", + ), + ExpressionTestCase( + "pos_int64_max", + expression={"$slice": ["$arr", "$pos", "$n"]}, + doc={"arr": [1, 2, 3], "pos": INT64_MAX, "n": 2}, + error_code=EXPRESSION_SLICE_ARG_NOT_INTEGRAL_ERROR, + msg=( + "$slice should reject a position that is a whole number outside the " + "32-bit integer range" + ), + ), +] + +ALL_TESTS = POS_NOT_NUMERIC_TESTS + POS_NOT_INTEGRAL_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_slice_position_insert(collection, test): + """Test $slice position-argument error cases with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + )