From 51a482ab4db4faa743125c3ac8699d126a8fbf33 Mon Sep 17 00:00:00 2001 From: PatersonProjects Date: Tue, 30 Jun 2026 13:32:35 -0700 Subject: [PATCH 1/6] Added tests Signed-off-by: PatersonProjects --- .../arithmetic/add/test_add_date.py | 162 +++++++++++ .../arithmetic/add/test_add_errors.py | 203 ++++++++++++++ .../arithmetic/add/test_add_input_forms.py | 47 ++++ .../arithmetic/add/test_add_non_finite.py | 154 +++++++++++ .../arithmetic/add/test_add_null.py | 86 ++++++ .../arithmetic/add/test_add_numeric.py | 259 ++++++++++++++++++ .../arithmetic/add/test_add_overflow.py | 95 +++++++ .../arithmetic/add/test_add_precision.py | 103 +++++++ .../arithmetic/add/test_add_return_type.py | 114 ++++++++ 9 files changed, 1223 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_date.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_input_forms.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_non_finite.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_null.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_numeric.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_overflow.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_precision.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_return_type.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_date.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_date.py new file mode 100644 index 000000000..7cd0f09f2 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_date.py @@ -0,0 +1,162 @@ +from datetime import datetime, timedelta, timezone + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Date Arithmetic]: $add accepts exactly one date operand and one or more numeric +# operands (in milliseconds). The date may appear in any position. +ADD_DATE_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_int32", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 86400000}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 2, tzinfo=timezone.utc), + msg="$add should add int32 milliseconds to a date", + ), + ExpressionTestCase( + "date_int64", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": Int64(86400000)}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 2, tzinfo=timezone.utc), + msg="$add should add int64 milliseconds to a date", + ), + ExpressionTestCase( + "date_decimal", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": Decimal128("1.5")}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, 2000, tzinfo=timezone.utc), + msg="$add should round a decimal128 fractional millisecond value when adding to a date", + ), + ExpressionTestCase( + "date_double_round_up", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 2.5}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, 3000, tzinfo=timezone.utc), + msg="$add should round up a double fractional millisecond value (.5) when adding to a date", + ), + ExpressionTestCase( + "date_double_truncates", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 4.4}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, 4000, tzinfo=timezone.utc), + msg="$add should truncate a double fractional millisecond value (<.5) when adding to a date", # noqa: E501 + ), +] + +# Property [Date Rounding Boundaries]: $add rounds fractional millisecond offsets using +# round-half-away-from-zero. Values with |frac| < 0.5 truncate toward zero; values with +# |frac| >= 0.5 round away from zero. +ADD_DATE_ROUNDING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_double_0_1", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 0.1}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, tzinfo=timezone.utc), + msg="$add should truncate 0.1ms and leave the date unchanged", + ), + ExpressionTestCase( + "date_double_0_49", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 0.49}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, tzinfo=timezone.utc), + msg="$add should truncate 0.49ms and leave the date unchanged", + ), + ExpressionTestCase( + "date_double_0_51", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 0.51}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, 1000, tzinfo=timezone.utc), + msg="$add should round up 0.51ms to 1ms when adding to a date", + ), + ExpressionTestCase( + "date_double_0_6", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 0.6}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, 1000, tzinfo=timezone.utc), + msg="$add should round up 0.6ms to 1ms when adding to a date", + ), + ExpressionTestCase( + "date_double_neg_0_5", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": -0.5}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, tzinfo=timezone.utc) - timedelta(milliseconds=1), + msg="$add should round -0.5ms away from zero to -1ms when adding to a date", + ), + ExpressionTestCase( + "date_double_neg_0_51", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": -0.51}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, tzinfo=timezone.utc) - timedelta(milliseconds=1), + msg="$add should round -0.51ms away from zero to -1ms when adding to a date", + ), +] + +# Property [Date Operand Position]: the date operand may appear in any position among the +# operands; only one date is permitted. +ADD_DATE_POSITION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "number_then_date", + doc={"a": 86400000, "b": datetime(2026, 1, 1, tzinfo=timezone.utc)}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 2, tzinfo=timezone.utc), + msg="$add should add a date when the numeric operand appears before the date", + ), + ExpressionTestCase( + "date_in_middle", + doc={"a": 1, "b": datetime(2026, 1, 1, tzinfo=timezone.utc), "c": 1000}, + expression={"$add": ["$a", "$b", "$c"]}, + expected=datetime(2026, 1, 1, 0, 0, 1, 1000, tzinfo=timezone.utc), + msg="$add should add a date when it appears in the middle of the operand list", + ), +] + +# Property [Date Sign Handling]: adding zero or a negative number of milliseconds to a date +# returns the date unchanged or subtracted. +ADD_DATE_SIGN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_negative", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": -86400000}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2025, 12, 31, tzinfo=timezone.utc), + msg="$add should subtract milliseconds from a date when adding a negative number", + ), + ExpressionTestCase( + "date_zero", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 0}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, tzinfo=timezone.utc), + msg="$add should return the same date when adding zero milliseconds", + ), + ExpressionTestCase( + "date_negative_zero", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": -0.0}, + expression={"$add": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, tzinfo=timezone.utc), + msg="$add should return the same date when adding negative zero", + ), +] + +ADD_DATE_ALL_TESTS = ( + ADD_DATE_NUMERIC_TESTS + ADD_DATE_POSITION_TESTS + ADD_DATE_SIGN_TESTS + ADD_DATE_ROUNDING_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(ADD_DATE_ALL_TESTS)) +def test_add_date(collection, test_case: ExpressionTestCase): + """Test $add date arithmetic: numeric types, operand position, and sign handling.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_errors.py new file mode 100644 index 000000000..f5c817b3d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_errors.py @@ -0,0 +1,203 @@ +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 ( + MORE_THAN_ONE_DATE_ERROR, + OVERFLOW_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + FLOAT_INFINITY, + FLOAT_NAN, + INT64_MAX, +) + +# Property [Type Strictness]: $add rejects non-numeric, non-date operand types. +ADD_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"type_{tid}", + doc={"a": 1, "b": val}, + expression={"$add": ["$a", "$b"]}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"$add should reject a {tid} operand", + ) + for tid, val in [ + ("string", "string"), + ("bool", True), + ("array", [2, 3]), + ("object", {"a": 2}), + ("regex", Regex("abc")), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("binary", Binary(b"data")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("timestamp", Timestamp(1, 1)), + ("code", Code("function(){}")), + ] +] + +# Property [Mixed Valid and Invalid]: $add rejects an invalid operand when it appears among +# valid numeric operands. +ADD_MIXED_VALID_INVALID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "mixed_valid_invalid", + doc={"a": 1, "b": 2, "c": "string"}, + expression={"$add": ["$a", "$b", "$c"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="$add should error when a string appears among numeric operands", + ), +] + +# Property [Single Invalid Operand]: $add rejects a single operand of an invalid type. +ADD_SINGLE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "single_string", + doc={"a": "string"}, + expression={"$add": ["$a"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="$add should reject a single string operand", + ), + ExpressionTestCase( + "single_boolean", + doc={"a": True}, + expression={"$add": ["$a"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="$add should reject a single boolean operand", + ), + ExpressionTestCase( + "single_array", + doc={"a": [1, 2]}, + expression={"$add": ["$a"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="$add should reject a single array operand", + ), + ExpressionTestCase( + "single_object", + doc={"a": {"x": 1}}, + expression={"$add": ["$a"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="$add should reject a single object operand", + ), +] + +# Property [Multiple Dates]: $add rejects expressions with more than one date operand. +ADD_MULTIPLE_DATE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "add_two_identical_dates", + doc={ + "a": datetime(2026, 1, 1, tzinfo=timezone.utc), + "b": datetime(2026, 1, 1, tzinfo=timezone.utc), + }, + expression={"$add": ["$a", "$b"]}, + error_code=MORE_THAN_ONE_DATE_ERROR, + msg="$add should error when adding two identical date operands", + ), + ExpressionTestCase( + "two_different_dates", + doc={ + "a": datetime(2026, 1, 1, tzinfo=timezone.utc), + "b": datetime(2026, 1, 2, tzinfo=timezone.utc), + }, + expression={"$add": ["$a", "$b"]}, + error_code=MORE_THAN_ONE_DATE_ERROR, + msg="$add should error when adding two different date operands", + ), + ExpressionTestCase( + "two_dates_with_numbers", + doc={ + "a": datetime(2026, 1, 1, tzinfo=timezone.utc), + "b": datetime(2026, 1, 2, tzinfo=timezone.utc), + }, + expression={"$add": [1, 2, 3, "$a", "$b"]}, + error_code=MORE_THAN_ONE_DATE_ERROR, + msg="$add should error when two dates appear among numeric operands", + ), + ExpressionTestCase( + "dates_separated_by_number", + doc={ + "a": datetime(2026, 1, 1, tzinfo=timezone.utc), + "b": datetime(2026, 1, 2, tzinfo=timezone.utc), + }, + expression={"$add": ["$a", 1, "$b"]}, + error_code=MORE_THAN_ONE_DATE_ERROR, + msg="$add should error when two dates are separated by a numeric operand", + ), +] + +# Property [Date with Non-Finite]: $add rejects NaN and Infinity as numeric operands when a +# date is also present, since the resulting date would be non-representable. +ADD_DATE_NON_FINITE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_nan", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": FLOAT_NAN}, + expression={"$add": ["$a", "$b"]}, + error_code=OVERFLOW_ERROR, + msg="$add should error when adding a date and float NaN", + ), + ExpressionTestCase( + "date_infinity", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": FLOAT_INFINITY}, + expression={"$add": ["$a", "$b"]}, + error_code=OVERFLOW_ERROR, + msg="$add should error when adding a date and float infinity", + ), + ExpressionTestCase( + "date_decimal_nan", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": DECIMAL128_NAN}, + expression={"$add": ["$a", "$b"]}, + error_code=OVERFLOW_ERROR, + msg="$add should error when adding a date and decimal128 NaN", + ), + ExpressionTestCase( + "date_decimal_infinity", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": DECIMAL128_INFINITY}, + expression={"$add": ["$a", "$b"]}, + error_code=OVERFLOW_ERROR, + msg="$add should error when adding a date and decimal128 infinity", + ), +] + +# Property [Date Overflow]: $add errors when the millisecond offset would push the date result +# beyond the representable date range. +ADD_DATE_OVERFLOW_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_int64_max", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": INT64_MAX}, + expression={"$add": ["$a", "$b"]}, + error_code=OVERFLOW_ERROR, + msg="$add should error when adding INT64_MAX milliseconds to a date overflows the date range", # noqa: E501 + ), +] + +ADD_ERROR_ALL_TESTS = ( + ADD_TYPE_ERROR_TESTS + + ADD_MIXED_VALID_INVALID_TESTS + + ADD_SINGLE_TYPE_ERROR_TESTS + + ADD_MULTIPLE_DATE_TESTS + + ADD_DATE_NON_FINITE_ERROR_TESTS + + ADD_DATE_OVERFLOW_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(ADD_ERROR_ALL_TESTS)) +def test_add_errors(collection, test_case: ExpressionTestCase): + """Test $add type, multiple-date, and date non-finite error cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_input_forms.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_input_forms.py new file mode 100644 index 000000000..7019c1b0b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_input_forms.py @@ -0,0 +1,47 @@ +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 [Expression Input]: $add evaluates a nested expression argument before summing. +ADD_EXPRESSION_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_add", + doc={"a": 1, "b": 2}, + expression={"$add": [{"$add": ["$a", "$b"]}, 3]}, + expected=6, + msg="$add should evaluate a nested $add expression as an operand", + ), +] + +# Property [Mixed Literal and Field]: $add accepts a mix of field references and inline literals +# in the same operand list. +ADD_MIXED_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "mixed_literal_and_field", + doc={"a": 10}, + expression={"$add": ["$a", 5]}, + expected=15, + msg="$add should sum a field reference and an inline literal operand", + ), +] + +ADD_INPUT_FORM_ALL_TESTS = ADD_EXPRESSION_INPUT_TESTS + ADD_MIXED_INPUT_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(ADD_INPUT_FORM_ALL_TESTS)) +def test_add_input_forms(collection, test_case: ExpressionTestCase): + """Test $add literal, nested expression, and mixed input form cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_non_finite.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_non_finite.py new file mode 100644 index 000000000..0343c3316 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_non_finite.py @@ -0,0 +1,154 @@ +import math + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + 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, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Property [Infinity]: $add propagates infinity according to IEEE 754 rules. +ADD_INFINITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "infinity", + doc={"a": FLOAT_INFINITY, "b": 1}, + expression={"$add": ["$a", "$b"]}, + expected=FLOAT_INFINITY, + msg="$add should return infinity when adding infinity and a finite number", + ), + ExpressionTestCase( + "negative_infinity", + doc={"a": FLOAT_NEGATIVE_INFINITY, "b": 1}, + expression={"$add": ["$a", "$b"]}, + expected=FLOAT_NEGATIVE_INFINITY, + msg="$add should return negative infinity when adding negative infinity and a finite number", # noqa: E501 + ), + ExpressionTestCase( + "single_infinity", + doc={"a": FLOAT_INFINITY}, + expression={"$add": ["$a"]}, + expected=FLOAT_INFINITY, + msg="$add should return infinity for a single infinity operand", + ), + ExpressionTestCase( + "inf_plus_inf", + doc={"a": FLOAT_INFINITY, "b": FLOAT_INFINITY}, + expression={"$add": ["$a", "$b"]}, + expected=FLOAT_INFINITY, + msg="$add should return infinity when adding two positive infinities", + ), + ExpressionTestCase( + "neg_inf_plus_neg_inf", + doc={"a": FLOAT_NEGATIVE_INFINITY, "b": FLOAT_NEGATIVE_INFINITY}, + expression={"$add": ["$a", "$b"]}, + expected=FLOAT_NEGATIVE_INFINITY, + msg="$add should return negative infinity when adding two negative infinities", + ), + ExpressionTestCase( + "inf_plus_zero", + doc={"a": FLOAT_INFINITY, "b": 0}, + expression={"$add": ["$a", "$b"]}, + expected=FLOAT_INFINITY, + msg="$add should return infinity when adding infinity and zero", + ), + ExpressionTestCase( + "neg_inf_plus_zero", + doc={"a": FLOAT_NEGATIVE_INFINITY, "b": 0}, + expression={"$add": ["$a", "$b"]}, + expected=FLOAT_NEGATIVE_INFINITY, + msg="$add should return negative infinity when adding negative infinity and zero", + ), + ExpressionTestCase( + "decimal_infinity", + doc={"a": DECIMAL128_INFINITY, "b": 1}, + expression={"$add": ["$a", "$b"]}, + expected=DECIMAL128_INFINITY, + msg="$add should return decimal128 infinity when adding decimal128 infinity and a number", + ), + ExpressionTestCase( + "decimal_negative_infinity", + doc={"a": DECIMAL128_NEGATIVE_INFINITY, "b": 1}, + expression={"$add": ["$a", "$b"]}, + expected=DECIMAL128_NEGATIVE_INFINITY, + msg="$add should return decimal128 negative infinity when adding decimal128 negative infinity and a number", # noqa: E501 + ), +] + +# Property [NaN]: $add propagates NaN according to IEEE 754 rules. +ADD_NAN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nan_add_one", + doc={"a": FLOAT_NAN, "b": 1}, + expression={"$add": ["$a", "$b"]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="$add should return NaN when adding float NaN and a finite number", + ), + ExpressionTestCase( + "inf_minus_inf", + doc={"a": FLOAT_INFINITY, "b": FLOAT_NEGATIVE_INFINITY}, + expression={"$add": ["$a", "$b"]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="$add should return NaN when adding float infinity and negative infinity", + ), + ExpressionTestCase( + "nan_plus_nan", + doc={"a": FLOAT_NAN, "b": FLOAT_NAN}, + expression={"$add": ["$a", "$b"]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="$add should return NaN when adding two float NaN values", + ), + ExpressionTestCase( + "nan_plus_inf", + doc={"a": FLOAT_NAN, "b": FLOAT_INFINITY}, + expression={"$add": ["$a", "$b"]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="$add should return NaN when adding float NaN and infinity", + ), + ExpressionTestCase( + "decimal_nan", + doc={"a": DECIMAL128_NAN, "b": 1}, + expression={"$add": ["$a", "$b"]}, + expected=DECIMAL128_NAN, + msg="$add should return decimal128 NaN when adding decimal128 NaN and a number", + ), + ExpressionTestCase( + "decimal_nan_plus_nan", + doc={"a": DECIMAL128_NAN, "b": DECIMAL128_NAN}, + expression={"$add": ["$a", "$b"]}, + expected=DECIMAL128_NAN, + msg="$add should return decimal128 NaN when adding two decimal128 NaN values", + ), + ExpressionTestCase( + "decimal_inf_minus_inf", + doc={"a": DECIMAL128_INFINITY, "b": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$add": ["$a", "$b"]}, + expected=DECIMAL128_NAN, + msg="$add should return decimal128 NaN when adding decimal128 infinity and negative infinity", # noqa: E501 + ), +] + +ADD_NON_FINITE_ALL_TESTS = ADD_INFINITY_TESTS + ADD_NAN_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(ADD_NON_FINITE_ALL_TESTS)) +def test_add_non_finite(collection, test_case: ExpressionTestCase): + """Test $add infinity and NaN propagation cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_null.py new file mode 100644 index 000000000..9651d52d3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_null.py @@ -0,0 +1,86 @@ +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import MISSING + +# Property [Null Propagation]: $add returns null if any operand is null or refers to a missing +# field. +ADD_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "single_null", + doc={"a": None}, + expression={"$add": ["$a"]}, + expected=None, + msg="$add should return null for a single null operand", + ), + ExpressionTestCase( + "null_operand", + doc={"a": 1, "b": None}, + expression={"$add": ["$a", "$b"]}, + expected=None, + msg="$add should return null when any operand is null", + ), + ExpressionTestCase( + "missing_field", + doc={}, + expression={"$add": [1, MISSING]}, + expected=None, + msg="$add should return null when any operand is a missing field", + ), + ExpressionTestCase( + "null_with_multiple", + doc={"a": 1, "b": 2, "c": None}, + expression={"$add": ["$a", "$b", "$c"]}, + expected=None, + msg="$add should return null when null appears among multiple operands", + ), + ExpressionTestCase( + "null_in_middle", + doc={"a": 1, "b": 2, "c": 3, "e": 5}, + expression={"$add": ["$a", "$b", "$c", None, "$e"]}, + expected=None, + msg="$add should return null when null appears in the middle of operands", + ), + ExpressionTestCase( + "all_null", + doc={"a": None, "b": None}, + expression={"$add": ["$a", "$b"]}, + expected=None, + msg="$add should return null when all operands are null", + ), + ExpressionTestCase( + "all_missing", + doc={}, + expression={"$add": [MISSING, MISSING]}, + expected=None, + msg="$add should return null when all operands are missing fields", + ), + ExpressionTestCase( + "date_and_null", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": None}, + expression={"$add": ["$a", "$b"]}, + expected=None, + msg="$add should return null when a date is paired with a null operand", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(ADD_NULL_TESTS)) +def test_add_null(collection, test_case: ExpressionTestCase): + """Test $add null and missing field propagation cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_numeric.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_numeric.py new file mode 100644 index 000000000..3c4d6f305 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_numeric.py @@ -0,0 +1,259 @@ +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Same-Type Addition]: $add of two values of the same numeric type returns a value of +# that type. +ADD_SAME_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "same_type_int32", + doc={"a": 1, "b": 2}, + expression={"$add": ["$a", "$b"]}, + expected=3, + msg="$add should add two int32 values", + ), + ExpressionTestCase( + "same_type_int64", + doc={"a": Int64(10), "b": Int64(20)}, + expression={"$add": ["$a", "$b"]}, + expected=Int64(30), + msg="$add should add two int64 values", + ), + ExpressionTestCase( + "same_type_double", + doc={"a": 1.5, "b": 2.5}, + expression={"$add": ["$a", "$b"]}, + expected=4.0, + msg="$add should add two double values", + ), + ExpressionTestCase( + "same_type_decimal", + doc={"a": Decimal128("10.5"), "b": Decimal128("20.5")}, + expression={"$add": ["$a", "$b"]}, + expected=Decimal128("31.0"), + msg="$add should add two decimal128 values", + ), +] + +# Property [Type Promotion]: $add promotes to the wider type when operands have different numeric +# types. Precedence: decimal128 > double > int64 > int32. +ADD_MIXED_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_int64", + doc={"a": 1, "b": Int64(20)}, + expression={"$add": ["$a", "$b"]}, + expected=Int64(21), + msg="$add should return int64 when adding int32 and int64", + ), + ExpressionTestCase( + "int32_double", + doc={"a": 1, "b": 2.5}, + expression={"$add": ["$a", "$b"]}, + expected=3.5, + msg="$add should return double when adding int32 and double", + ), + ExpressionTestCase( + "int32_decimal", + doc={"a": 1, "b": Decimal128("2.5")}, + expression={"$add": ["$a", "$b"]}, + expected=Decimal128("3.5"), + msg="$add should return decimal128 when adding int32 and decimal128", + ), + ExpressionTestCase( + "int64_double", + doc={"a": Int64(10), "b": 2.5}, + expression={"$add": ["$a", "$b"]}, + expected=12.5, + msg="$add should return double when adding int64 and double", + ), + ExpressionTestCase( + "int64_decimal", + doc={"a": Int64(10), "b": Decimal128("2.5")}, + expression={"$add": ["$a", "$b"]}, + expected=Decimal128("12.5"), + msg="$add should return decimal128 when adding int64 and decimal128", + ), + ExpressionTestCase( + "double_decimal", + doc={"a": 1.5, "b": Decimal128("2.5")}, + expression={"$add": ["$a", "$b"]}, + expected=pytest.approx(Decimal128("4.00000000000000")), + msg="$add should return decimal128 when adding double and decimal128", + ), + ExpressionTestCase( + "three_mixed_types", + doc={"a": Decimal128("1.5"), "b": 2.5, "c": Int64(3)}, + expression={"$add": ["$a", "$b", "$c"]}, + expected=pytest.approx(Decimal128("7.00000000000000")), + msg="$add should return decimal128 when adding decimal128, double, and int64", + ), +] + +# Property [Multiple Operands]: $add correctly sums three or more operands. +ADD_MULTIPLE_OPERANDS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "multiple_int32", + doc={"a": 1, "b": 2, "c": 3}, + expression={"$add": ["$a", "$b", "$c"]}, + expected=6, + msg="$add should add multiple int32 values", + ), + ExpressionTestCase( + "multiple_int64", + doc={"a": Int64(1), "b": Int64(2), "c": Int64(3)}, + expression={"$add": ["$a", "$b", "$c"]}, + expected=Int64(6), + msg="$add should add multiple int64 values", + ), + ExpressionTestCase( + "multiple_double", + doc={"a": 1.1, "b": 2.2, "c": 3.3}, + expression={"$add": ["$a", "$b", "$c"]}, + expected=pytest.approx(6.6), + msg="$add should add multiple double values", + ), + ExpressionTestCase( + "multiple_decimal", + doc={ + "a": Decimal128("1"), + "b": Decimal128("2"), + "c": Decimal128("3"), + "d": Decimal128("4"), + }, + expression={"$add": ["$a", "$b", "$c", "$d"]}, + expected=Decimal128("10"), + msg="$add should add multiple decimal128 values", + ), + ExpressionTestCase( + "five_operands", + doc={"a": 1, "b": 2, "c": 3, "d": 4, "e": 5}, + expression={"$add": ["$a", "$b", "$c", "$d", "$e"]}, + expected=15, + msg="$add should correctly sum five int32 operands", + ), + ExpressionTestCase( + "ten_operands", + doc={ + "a": 1, + "b": 2, + "c": 3, + "d": 4, + "e": 5, + "f": 6, + "g": 7, + "h": 8, + "i": 9, + "j": 10, + }, + expression={"$add": ["$a", "$b", "$c", "$d", "$e", "$f", "$g", "$h", "$i", "$j"]}, + expected=55, + msg="$add should correctly sum ten int32 operands", + ), +] + +# Property [Empty and Single Operand]: $add of zero operands returns 0; single operand returns +# that value unchanged. +ADD_SINGLE_AND_EMPTY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "empty", + doc={}, + expression={"$add": []}, + expected=0, + msg="$add should return 0 for empty operand list", + ), + ExpressionTestCase( + "single_int32", + doc={"a": 5}, + expression={"$add": ["$a"]}, + expected=5, + msg="$add should return the value for a single int32 operand", + ), + ExpressionTestCase( + "single_int64", + doc={"a": Int64(0)}, + expression={"$add": ["$a"]}, + expected=Int64(0), + msg="$add should return the value for a single int64 operand", + ), + ExpressionTestCase( + "single_double", + doc={"a": 0.0}, + expression={"$add": ["$a"]}, + expected=0.0, + msg="$add should return the value for a single double operand", + ), + ExpressionTestCase( + "single_decimal", + doc={"a": Decimal128("0")}, + expression={"$add": ["$a"]}, + expected=Decimal128("0"), + msg="$add should return the value for a single decimal128 operand", + ), +] + +# Property [Sign Handling]: $add handles negative values and zero correctly. +ADD_SIGN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "negative_positive", + doc={"a": -5, "b": 3}, + expression={"$add": ["$a", "$b"]}, + expected=-2, + msg="$add should add a negative and a positive int32 value", + ), + ExpressionTestCase( + "both_negative", + doc={"a": -10, "b": -20}, + expression={"$add": ["$a", "$b"]}, + expected=-30, + msg="$add should add two negative int32 values", + ), + ExpressionTestCase( + "zeros", + doc={"a": 0, "b": 0}, + expression={"$add": ["$a", "$b"]}, + expected=0, + msg="$add should return 0 when adding two int32 zeros", + ), + ExpressionTestCase( + "zero_negative_zero", + doc={"a": 0, "b": -0.0}, + expression={"$add": ["$a", "$b"]}, + expected=0.0, + msg="$add should return 0.0 when adding int32 zero and negative zero double", + ), + ExpressionTestCase( + "sum_to_zero", + doc={"a": 1, "b": 0, "c": -1}, + expression={"$add": ["$a", "$b", "$c"]}, + expected=0, + msg="$add should return 0 when operands sum to zero", + ), +] + +ADD_NUMERIC_ALL_TESTS = ( + ADD_SAME_TYPE_TESTS + + ADD_MIXED_TYPE_TESTS + + ADD_MULTIPLE_OPERANDS_TESTS + + ADD_SINGLE_AND_EMPTY_TESTS + + ADD_SIGN_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(ADD_NUMERIC_ALL_TESTS)) +def test_add_numeric(collection, test_case: ExpressionTestCase): + """Test $add numeric type combinations, multiple operands, and sign handling.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_overflow.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_overflow.py new file mode 100644 index 000000000..29a992905 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_overflow.py @@ -0,0 +1,95 @@ +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DOUBLE_FROM_INT64_MAX, + FLOAT_INFINITY, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT32_OVERFLOW, + INT32_UNDERFLOW, + INT64_MAX, + INT64_MIN, +) + +# Property [Int32 Overflow]: when an int32 result exceeds the int32 range, $add promotes to +# int64. +ADD_INT32_OVERFLOW_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_overflow", + doc={"a": INT32_MAX, "b": 1}, + expression={"$add": ["$a", "$b"]}, + expected=Int64(INT32_OVERFLOW), + msg="$add should promote to int64 when the int32 result overflows INT32_MAX", + ), + ExpressionTestCase( + "int32_underflow", + doc={"a": INT32_MIN, "b": -1}, + expression={"$add": ["$a", "$b"]}, + expected=Int64(INT32_UNDERFLOW), + msg="$add should promote to int64 when the int32 result underflows INT32_MIN", + ), +] + +# Property [Int64 Overflow]: when an int64 result exceeds the int64 range, $add promotes to +# double. +ADD_INT64_OVERFLOW_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int64_overflow", + doc={"a": INT64_MAX, "b": 1}, + expression={"$add": ["$a", "$b"]}, + expected=pytest.approx(DOUBLE_FROM_INT64_MAX), + msg="$add should promote to double when the int64 result overflows INT64_MAX", + ), + ExpressionTestCase( + "int64_underflow", + doc={"a": INT64_MIN, "b": -1}, + expression={"$add": ["$a", "$b"]}, + expected=pytest.approx(-DOUBLE_FROM_INT64_MAX), + msg="$add should promote to double when the int64 result underflows INT64_MIN", + ), +] + +# Property [Double Overflow]: when a double result exceeds the double range, $add returns +# infinity. +ADD_DOUBLE_OVERFLOW_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "double_overflow", + doc={"a": 1e308, "b": 1e308}, + expression={"$add": ["$a", "$b"]}, + expected=FLOAT_INFINITY, + msg="$add should return positive infinity on double overflow", + ), + ExpressionTestCase( + "double_underflow", + doc={"a": -1e308, "b": -1e308}, + expression={"$add": ["$a", "$b"]}, + expected=FLOAT_NEGATIVE_INFINITY, + msg="$add should return negative infinity on double underflow", + ), +] + +ADD_OVERFLOW_ALL_TESTS = ( + ADD_INT32_OVERFLOW_TESTS + ADD_INT64_OVERFLOW_TESTS + ADD_DOUBLE_OVERFLOW_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(ADD_OVERFLOW_ALL_TESTS)) +def test_add_overflow(collection, test_case: ExpressionTestCase): + """Test $add integer and double overflow and underflow cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_precision.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_precision.py new file mode 100644 index 000000000..f0cdd34ab --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_precision.py @@ -0,0 +1,103 @@ +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 +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_MAX, + DECIMAL128_MIN, + DECIMAL128_NEGATIVE_INFINITY, +) + +# Property [Decimal128 Precision]: $add preserves decimal128 precision, including exact +# representation of values that are inexact in double. +ADD_DECIMAL_PRECISION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal_precision", + doc={"a": Decimal128("1.5"), "b": Decimal128("2.5")}, + expression={"$add": ["$a", "$b"]}, + expected=Decimal128("4.0"), + msg="$add should preserve decimal128 precision", + ), + ExpressionTestCase( + "decimal_precision_small", + doc={"a": Decimal128("0.1"), "b": Decimal128("0.2")}, + expression={"$add": ["$a", "$b"]}, + expected=Decimal128("0.3"), + msg="$add should exactly represent 0.1 + 0.2 with decimal128", + ), + ExpressionTestCase( + "decimal_large_precision", + doc={ + "a": Decimal128("999999999999999999999999999999999"), + "b": Decimal128("1"), + }, + expression={"$add": ["$a", "$b"]}, + expected=Decimal128("1000000000000000000000000000000000"), + msg="$add should handle large decimal128 addition with full precision", + ), + ExpressionTestCase( + "decimal_large_negative_precision", + doc={ + "a": Decimal128("-999999999999999999999999999999999"), + "b": Decimal128("-1"), + }, + expression={"$add": ["$a", "$b"]}, + expected=Decimal128("-1000000000000000000000000000000000"), + msg="$add should handle large negative decimal128 addition with full precision", + ), +] + +# Property [Decimal128 Boundaries]: $add at decimal128 boundary values promotes to infinity when +# the result overflows, and returns zero when max and min cancel. +ADD_DECIMAL_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal128_max_plus_zero", + doc={"a": DECIMAL128_MAX, "b": Decimal128("0")}, + expression={"$add": ["$a", "$b"]}, + expected=DECIMAL128_MAX, + msg="$add should return decimal128 max when adding zero to decimal128 max", + ), + ExpressionTestCase( + "decimal128_max_plus_max", + doc={"a": DECIMAL128_MAX, "b": DECIMAL128_MAX}, + expression={"$add": ["$a", "$b"]}, + expected=DECIMAL128_INFINITY, + msg="$add should return decimal128 infinity when adding two decimal128 max values", + ), + ExpressionTestCase( + "decimal128_min_plus_min", + doc={"a": DECIMAL128_MIN, "b": DECIMAL128_MIN}, + expression={"$add": ["$a", "$b"]}, + expected=DECIMAL128_NEGATIVE_INFINITY, + msg="$add should return decimal128 negative infinity when adding two decimal128 min values", + ), + ExpressionTestCase( + "decimal128_max_plus_min", + doc={"a": DECIMAL128_MAX, "b": DECIMAL128_MIN}, + expression={"$add": ["$a", "$b"]}, + expected=Decimal128("0E+6111"), + msg="$add should return zero when adding decimal128 max and decimal128 min", + ), +] + +ADD_PRECISION_ALL_TESTS = ADD_DECIMAL_PRECISION_TESTS + ADD_DECIMAL_BOUNDARY_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(ADD_PRECISION_ALL_TESTS)) +def test_add_precision(collection, test_case: ExpressionTestCase): + """Test $add decimal128 precision and boundary value cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_return_type.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_return_type.py new file mode 100644 index 000000000..7af9e0b66 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_return_type.py @@ -0,0 +1,114 @@ +from datetime import datetime, timezone + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Return Type]: $add follows numeric type promotion rules to determine the result type. +# Precedence: decimal128 > double > int64 > int32. Date + numeric always returns date. +ADD_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type_int_int", + doc={"a": 1, "b": 2}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="int", + msg="$add should return int type when adding two int32 values", + ), + ExpressionTestCase( + "return_type_int_long", + doc={"a": 1, "b": Int64(2)}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="long", + msg="$add should return long type when adding int32 and int64", + ), + ExpressionTestCase( + "return_type_int_double", + doc={"a": 1, "b": 2.0}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="double", + msg="$add should return double type when adding int32 and double", + ), + ExpressionTestCase( + "return_type_int_decimal", + doc={"a": 1, "b": Decimal128("2")}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="decimal", + msg="$add should return decimal type when adding int32 and decimal128", + ), + ExpressionTestCase( + "return_type_long_long", + doc={"a": Int64(1), "b": Int64(2)}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="long", + msg="$add should return long type when adding two int64 values", + ), + ExpressionTestCase( + "return_type_long_double", + doc={"a": Int64(1), "b": 2.0}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="double", + msg="$add should return double type when adding int64 and double", + ), + ExpressionTestCase( + "return_type_long_decimal", + doc={"a": Int64(1), "b": Decimal128("2")}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="decimal", + msg="$add should return decimal type when adding int64 and decimal128", + ), + ExpressionTestCase( + "return_type_double_double", + doc={"a": 1.0, "b": 2.0}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="double", + msg="$add should return double type when adding two double values", + ), + ExpressionTestCase( + "return_type_double_decimal", + doc={"a": 1.0, "b": Decimal128("2")}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="decimal", + msg="$add should return decimal type when adding double and decimal128", + ), + ExpressionTestCase( + "return_type_decimal_decimal", + doc={"a": Decimal128("1"), "b": Decimal128("2")}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="decimal", + msg="$add should return decimal type when adding two decimal128 values", + ), + ExpressionTestCase( + "return_type_date_int", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 1000}, + expression={"$type": {"$add": ["$a", "$b"]}}, + expected="date", + msg="$add should return date type when adding a date and an int32", + ), + ExpressionTestCase( + "return_type_empty", + doc={}, + expression={"$type": {"$add": []}}, + expected="int", + msg="$add should return int type for an empty operand list", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(ADD_RETURN_TYPE_TESTS)) +def test_add_return_type(collection, test_case: ExpressionTestCase): + """Test $add return type promotion rules for all numeric type combinations.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) From 014f2bceb8c06ba36a9e652a57ac7d81397bd67e Mon Sep 17 00:00:00 2001 From: PatersonProjects Date: Tue, 30 Jun 2026 13:40:50 -0700 Subject: [PATCH 2/6] Added docStrings to all files Signed-off-by: PatersonProjects --- .../core/operator/expressions/arithmetic/add/test_add_date.py | 4 ++++ .../operator/expressions/arithmetic/add/test_add_errors.py | 2 ++ .../expressions/arithmetic/add/test_add_input_forms.py | 2 ++ .../expressions/arithmetic/add/test_add_non_finite.py | 2 ++ .../core/operator/expressions/arithmetic/add/test_add_null.py | 2 ++ .../operator/expressions/arithmetic/add/test_add_numeric.py | 4 ++++ .../operator/expressions/arithmetic/add/test_add_overflow.py | 2 ++ .../operator/expressions/arithmetic/add/test_add_precision.py | 2 ++ .../expressions/arithmetic/add/test_add_return_type.py | 2 ++ 9 files changed, 22 insertions(+) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_date.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_date.py index 7cd0f09f2..eff8f4af1 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_date.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_date.py @@ -1,3 +1,7 @@ +"""Tests for $add date arithmetic including numeric offsets, rounding boundaries, operand +position, and sign handling. +""" + from datetime import datetime, timedelta, timezone import pytest diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_errors.py index f5c817b3d..127f0e5de 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_errors.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_errors.py @@ -1,3 +1,5 @@ +"""Tests for $add error cases including invalid operand types, multiple dates, and date overflow.""" + from datetime import datetime, timezone import pytest diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_input_forms.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_input_forms.py index 7019c1b0b..fa2f73df4 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_input_forms.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_input_forms.py @@ -1,3 +1,5 @@ +"""Tests for $add input forms including nested expressions and mixed literal/field operands.""" + import pytest from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_non_finite.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_non_finite.py index 0343c3316..958818e98 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_non_finite.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_non_finite.py @@ -1,3 +1,5 @@ +"""Tests for $add infinity and NaN propagation.""" + import math import pytest diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_null.py index 9651d52d3..3854d72d8 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_null.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_null.py @@ -1,3 +1,5 @@ +"""Tests for $add null and missing field propagation.""" + from datetime import datetime, timezone import pytest diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_numeric.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_numeric.py index 3c4d6f305..971b01581 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_numeric.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_numeric.py @@ -1,3 +1,7 @@ +"""Tests for $add numeric operations including same-type and mixed-type addition, multiple +operands, empty/single operands, and sign handling. +""" + import pytest from bson import Decimal128, Int64 diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_overflow.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_overflow.py index 29a992905..2da84306e 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_overflow.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_overflow.py @@ -1,3 +1,5 @@ +"""Tests for $add integer and double overflow and underflow promotion.""" + import pytest from bson import Int64 diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_precision.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_precision.py index f0cdd34ab..6658eb03a 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_precision.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_precision.py @@ -1,3 +1,5 @@ +"""Tests for $add decimal128 precision and boundary value handling.""" + import pytest from bson import Decimal128 diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_return_type.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_return_type.py index 7af9e0b66..d681c7fd2 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_return_type.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_return_type.py @@ -1,3 +1,5 @@ +"""Tests for $add return type promotion rules across numeric and date operand combinations.""" + from datetime import datetime, timezone import pytest From 37407ae57c9912cf17ff31f5885fafea01f7db38 Mon Sep 17 00:00:00 2001 From: PatersonProjects Date: Tue, 30 Jun 2026 13:47:01 -0700 Subject: [PATCH 3/6] Revert "Added docStrings to all files" This reverts commit 014f2bceb8c06ba36a9e652a57ac7d81397bd67e. Signed-off-by: PatersonProjects --- .../core/operator/expressions/arithmetic/add/test_add_date.py | 4 ---- .../operator/expressions/arithmetic/add/test_add_errors.py | 2 -- .../expressions/arithmetic/add/test_add_input_forms.py | 2 -- .../expressions/arithmetic/add/test_add_non_finite.py | 2 -- .../core/operator/expressions/arithmetic/add/test_add_null.py | 2 -- .../operator/expressions/arithmetic/add/test_add_numeric.py | 4 ---- .../operator/expressions/arithmetic/add/test_add_overflow.py | 2 -- .../operator/expressions/arithmetic/add/test_add_precision.py | 2 -- .../expressions/arithmetic/add/test_add_return_type.py | 2 -- 9 files changed, 22 deletions(-) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_date.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_date.py index eff8f4af1..7cd0f09f2 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_date.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_date.py @@ -1,7 +1,3 @@ -"""Tests for $add date arithmetic including numeric offsets, rounding boundaries, operand -position, and sign handling. -""" - from datetime import datetime, timedelta, timezone import pytest diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_errors.py index 127f0e5de..f5c817b3d 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_errors.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_errors.py @@ -1,5 +1,3 @@ -"""Tests for $add error cases including invalid operand types, multiple dates, and date overflow.""" - from datetime import datetime, timezone import pytest diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_input_forms.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_input_forms.py index fa2f73df4..7019c1b0b 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_input_forms.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_input_forms.py @@ -1,5 +1,3 @@ -"""Tests for $add input forms including nested expressions and mixed literal/field operands.""" - import pytest from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_non_finite.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_non_finite.py index 958818e98..0343c3316 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_non_finite.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_non_finite.py @@ -1,5 +1,3 @@ -"""Tests for $add infinity and NaN propagation.""" - import math import pytest diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_null.py index 3854d72d8..9651d52d3 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_null.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_null.py @@ -1,5 +1,3 @@ -"""Tests for $add null and missing field propagation.""" - from datetime import datetime, timezone import pytest diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_numeric.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_numeric.py index 971b01581..3c4d6f305 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_numeric.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_numeric.py @@ -1,7 +1,3 @@ -"""Tests for $add numeric operations including same-type and mixed-type addition, multiple -operands, empty/single operands, and sign handling. -""" - import pytest from bson import Decimal128, Int64 diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_overflow.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_overflow.py index 2da84306e..29a992905 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_overflow.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_overflow.py @@ -1,5 +1,3 @@ -"""Tests for $add integer and double overflow and underflow promotion.""" - import pytest from bson import Int64 diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_precision.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_precision.py index 6658eb03a..f0cdd34ab 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_precision.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_precision.py @@ -1,5 +1,3 @@ -"""Tests for $add decimal128 precision and boundary value handling.""" - import pytest from bson import Decimal128 diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_return_type.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_return_type.py index d681c7fd2..7af9e0b66 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_return_type.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_return_type.py @@ -1,5 +1,3 @@ -"""Tests for $add return type promotion rules across numeric and date operand combinations.""" - from datetime import datetime, timezone import pytest From f851712aaa40362d3ada515b2c50fd52ffc9b3c4 Mon Sep 17 00:00:00 2001 From: PatersonProjects Date: Tue, 30 Jun 2026 13:47:24 -0700 Subject: [PATCH 4/6] Revert "Added tests" This reverts commit 51a482ab4db4faa743125c3ac8699d126a8fbf33. Signed-off-by: PatersonProjects --- .../arithmetic/add/test_add_date.py | 162 ----------- .../arithmetic/add/test_add_errors.py | 203 -------------- .../arithmetic/add/test_add_input_forms.py | 47 ---- .../arithmetic/add/test_add_non_finite.py | 154 ----------- .../arithmetic/add/test_add_null.py | 86 ------ .../arithmetic/add/test_add_numeric.py | 259 ------------------ .../arithmetic/add/test_add_overflow.py | 95 ------- .../arithmetic/add/test_add_precision.py | 103 ------- .../arithmetic/add/test_add_return_type.py | 114 -------- 9 files changed, 1223 deletions(-) delete mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_date.py delete mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_errors.py delete mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_input_forms.py delete mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_non_finite.py delete mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_null.py delete mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_numeric.py delete mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_overflow.py delete mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_precision.py delete mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_return_type.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_date.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_date.py deleted file mode 100644 index 7cd0f09f2..000000000 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_date.py +++ /dev/null @@ -1,162 +0,0 @@ -from datetime import datetime, timedelta, timezone - -import pytest -from bson import Decimal128, Int64 - -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 - ExpressionTestCase, -) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( - assert_expression_result, - execute_expression_with_insert, -) -from documentdb_tests.framework.parametrize import pytest_params - -# Property [Date Arithmetic]: $add accepts exactly one date operand and one or more numeric -# operands (in milliseconds). The date may appear in any position. -ADD_DATE_NUMERIC_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "date_int32", - doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 86400000}, - expression={"$add": ["$a", "$b"]}, - expected=datetime(2026, 1, 2, tzinfo=timezone.utc), - msg="$add should add int32 milliseconds to a date", - ), - ExpressionTestCase( - "date_int64", - doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": Int64(86400000)}, - expression={"$add": ["$a", "$b"]}, - expected=datetime(2026, 1, 2, tzinfo=timezone.utc), - msg="$add should add int64 milliseconds to a date", - ), - ExpressionTestCase( - "date_decimal", - doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": Decimal128("1.5")}, - expression={"$add": ["$a", "$b"]}, - expected=datetime(2026, 1, 1, 0, 0, 0, 2000, tzinfo=timezone.utc), - msg="$add should round a decimal128 fractional millisecond value when adding to a date", - ), - ExpressionTestCase( - "date_double_round_up", - doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 2.5}, - expression={"$add": ["$a", "$b"]}, - expected=datetime(2026, 1, 1, 0, 0, 0, 3000, tzinfo=timezone.utc), - msg="$add should round up a double fractional millisecond value (.5) when adding to a date", - ), - ExpressionTestCase( - "date_double_truncates", - doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 4.4}, - expression={"$add": ["$a", "$b"]}, - expected=datetime(2026, 1, 1, 0, 0, 0, 4000, tzinfo=timezone.utc), - msg="$add should truncate a double fractional millisecond value (<.5) when adding to a date", # noqa: E501 - ), -] - -# Property [Date Rounding Boundaries]: $add rounds fractional millisecond offsets using -# round-half-away-from-zero. Values with |frac| < 0.5 truncate toward zero; values with -# |frac| >= 0.5 round away from zero. -ADD_DATE_ROUNDING_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "date_double_0_1", - doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 0.1}, - expression={"$add": ["$a", "$b"]}, - expected=datetime(2026, 1, 1, tzinfo=timezone.utc), - msg="$add should truncate 0.1ms and leave the date unchanged", - ), - ExpressionTestCase( - "date_double_0_49", - doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 0.49}, - expression={"$add": ["$a", "$b"]}, - expected=datetime(2026, 1, 1, tzinfo=timezone.utc), - msg="$add should truncate 0.49ms and leave the date unchanged", - ), - ExpressionTestCase( - "date_double_0_51", - doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 0.51}, - expression={"$add": ["$a", "$b"]}, - expected=datetime(2026, 1, 1, 0, 0, 0, 1000, tzinfo=timezone.utc), - msg="$add should round up 0.51ms to 1ms when adding to a date", - ), - ExpressionTestCase( - "date_double_0_6", - doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 0.6}, - expression={"$add": ["$a", "$b"]}, - expected=datetime(2026, 1, 1, 0, 0, 0, 1000, tzinfo=timezone.utc), - msg="$add should round up 0.6ms to 1ms when adding to a date", - ), - ExpressionTestCase( - "date_double_neg_0_5", - doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": -0.5}, - expression={"$add": ["$a", "$b"]}, - expected=datetime(2026, 1, 1, tzinfo=timezone.utc) - timedelta(milliseconds=1), - msg="$add should round -0.5ms away from zero to -1ms when adding to a date", - ), - ExpressionTestCase( - "date_double_neg_0_51", - doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": -0.51}, - expression={"$add": ["$a", "$b"]}, - expected=datetime(2026, 1, 1, tzinfo=timezone.utc) - timedelta(milliseconds=1), - msg="$add should round -0.51ms away from zero to -1ms when adding to a date", - ), -] - -# Property [Date Operand Position]: the date operand may appear in any position among the -# operands; only one date is permitted. -ADD_DATE_POSITION_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "number_then_date", - doc={"a": 86400000, "b": datetime(2026, 1, 1, tzinfo=timezone.utc)}, - expression={"$add": ["$a", "$b"]}, - expected=datetime(2026, 1, 2, tzinfo=timezone.utc), - msg="$add should add a date when the numeric operand appears before the date", - ), - ExpressionTestCase( - "date_in_middle", - doc={"a": 1, "b": datetime(2026, 1, 1, tzinfo=timezone.utc), "c": 1000}, - expression={"$add": ["$a", "$b", "$c"]}, - expected=datetime(2026, 1, 1, 0, 0, 1, 1000, tzinfo=timezone.utc), - msg="$add should add a date when it appears in the middle of the operand list", - ), -] - -# Property [Date Sign Handling]: adding zero or a negative number of milliseconds to a date -# returns the date unchanged or subtracted. -ADD_DATE_SIGN_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "date_negative", - doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": -86400000}, - expression={"$add": ["$a", "$b"]}, - expected=datetime(2025, 12, 31, tzinfo=timezone.utc), - msg="$add should subtract milliseconds from a date when adding a negative number", - ), - ExpressionTestCase( - "date_zero", - doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 0}, - expression={"$add": ["$a", "$b"]}, - expected=datetime(2026, 1, 1, tzinfo=timezone.utc), - msg="$add should return the same date when adding zero milliseconds", - ), - ExpressionTestCase( - "date_negative_zero", - doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": -0.0}, - expression={"$add": ["$a", "$b"]}, - expected=datetime(2026, 1, 1, tzinfo=timezone.utc), - msg="$add should return the same date when adding negative zero", - ), -] - -ADD_DATE_ALL_TESTS = ( - ADD_DATE_NUMERIC_TESTS + ADD_DATE_POSITION_TESTS + ADD_DATE_SIGN_TESTS + ADD_DATE_ROUNDING_TESTS -) - - -@pytest.mark.parametrize("test_case", pytest_params(ADD_DATE_ALL_TESTS)) -def test_add_date(collection, test_case: ExpressionTestCase): - """Test $add date arithmetic: numeric types, operand position, and sign handling.""" - result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) - assert_expression_result( - result, - expected=test_case.expected, - error_code=test_case.error_code, - msg=test_case.msg, - ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_errors.py deleted file mode 100644 index f5c817b3d..000000000 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_errors.py +++ /dev/null @@ -1,203 +0,0 @@ -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 ( - MORE_THAN_ONE_DATE_ERROR, - OVERFLOW_ERROR, - TYPE_MISMATCH_ERROR, -) -from documentdb_tests.framework.parametrize import pytest_params -from documentdb_tests.framework.test_constants import ( - DECIMAL128_INFINITY, - DECIMAL128_NAN, - FLOAT_INFINITY, - FLOAT_NAN, - INT64_MAX, -) - -# Property [Type Strictness]: $add rejects non-numeric, non-date operand types. -ADD_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - f"type_{tid}", - doc={"a": 1, "b": val}, - expression={"$add": ["$a", "$b"]}, - error_code=TYPE_MISMATCH_ERROR, - msg=f"$add should reject a {tid} operand", - ) - for tid, val in [ - ("string", "string"), - ("bool", True), - ("array", [2, 3]), - ("object", {"a": 2}), - ("regex", Regex("abc")), - ("objectid", ObjectId("507f1f77bcf86cd799439011")), - ("binary", Binary(b"data")), - ("minkey", MinKey()), - ("maxkey", MaxKey()), - ("timestamp", Timestamp(1, 1)), - ("code", Code("function(){}")), - ] -] - -# Property [Mixed Valid and Invalid]: $add rejects an invalid operand when it appears among -# valid numeric operands. -ADD_MIXED_VALID_INVALID_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "mixed_valid_invalid", - doc={"a": 1, "b": 2, "c": "string"}, - expression={"$add": ["$a", "$b", "$c"]}, - error_code=TYPE_MISMATCH_ERROR, - msg="$add should error when a string appears among numeric operands", - ), -] - -# Property [Single Invalid Operand]: $add rejects a single operand of an invalid type. -ADD_SINGLE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "single_string", - doc={"a": "string"}, - expression={"$add": ["$a"]}, - error_code=TYPE_MISMATCH_ERROR, - msg="$add should reject a single string operand", - ), - ExpressionTestCase( - "single_boolean", - doc={"a": True}, - expression={"$add": ["$a"]}, - error_code=TYPE_MISMATCH_ERROR, - msg="$add should reject a single boolean operand", - ), - ExpressionTestCase( - "single_array", - doc={"a": [1, 2]}, - expression={"$add": ["$a"]}, - error_code=TYPE_MISMATCH_ERROR, - msg="$add should reject a single array operand", - ), - ExpressionTestCase( - "single_object", - doc={"a": {"x": 1}}, - expression={"$add": ["$a"]}, - error_code=TYPE_MISMATCH_ERROR, - msg="$add should reject a single object operand", - ), -] - -# Property [Multiple Dates]: $add rejects expressions with more than one date operand. -ADD_MULTIPLE_DATE_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "add_two_identical_dates", - doc={ - "a": datetime(2026, 1, 1, tzinfo=timezone.utc), - "b": datetime(2026, 1, 1, tzinfo=timezone.utc), - }, - expression={"$add": ["$a", "$b"]}, - error_code=MORE_THAN_ONE_DATE_ERROR, - msg="$add should error when adding two identical date operands", - ), - ExpressionTestCase( - "two_different_dates", - doc={ - "a": datetime(2026, 1, 1, tzinfo=timezone.utc), - "b": datetime(2026, 1, 2, tzinfo=timezone.utc), - }, - expression={"$add": ["$a", "$b"]}, - error_code=MORE_THAN_ONE_DATE_ERROR, - msg="$add should error when adding two different date operands", - ), - ExpressionTestCase( - "two_dates_with_numbers", - doc={ - "a": datetime(2026, 1, 1, tzinfo=timezone.utc), - "b": datetime(2026, 1, 2, tzinfo=timezone.utc), - }, - expression={"$add": [1, 2, 3, "$a", "$b"]}, - error_code=MORE_THAN_ONE_DATE_ERROR, - msg="$add should error when two dates appear among numeric operands", - ), - ExpressionTestCase( - "dates_separated_by_number", - doc={ - "a": datetime(2026, 1, 1, tzinfo=timezone.utc), - "b": datetime(2026, 1, 2, tzinfo=timezone.utc), - }, - expression={"$add": ["$a", 1, "$b"]}, - error_code=MORE_THAN_ONE_DATE_ERROR, - msg="$add should error when two dates are separated by a numeric operand", - ), -] - -# Property [Date with Non-Finite]: $add rejects NaN and Infinity as numeric operands when a -# date is also present, since the resulting date would be non-representable. -ADD_DATE_NON_FINITE_ERROR_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "date_nan", - doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": FLOAT_NAN}, - expression={"$add": ["$a", "$b"]}, - error_code=OVERFLOW_ERROR, - msg="$add should error when adding a date and float NaN", - ), - ExpressionTestCase( - "date_infinity", - doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": FLOAT_INFINITY}, - expression={"$add": ["$a", "$b"]}, - error_code=OVERFLOW_ERROR, - msg="$add should error when adding a date and float infinity", - ), - ExpressionTestCase( - "date_decimal_nan", - doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": DECIMAL128_NAN}, - expression={"$add": ["$a", "$b"]}, - error_code=OVERFLOW_ERROR, - msg="$add should error when adding a date and decimal128 NaN", - ), - ExpressionTestCase( - "date_decimal_infinity", - doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": DECIMAL128_INFINITY}, - expression={"$add": ["$a", "$b"]}, - error_code=OVERFLOW_ERROR, - msg="$add should error when adding a date and decimal128 infinity", - ), -] - -# Property [Date Overflow]: $add errors when the millisecond offset would push the date result -# beyond the representable date range. -ADD_DATE_OVERFLOW_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "date_int64_max", - doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": INT64_MAX}, - expression={"$add": ["$a", "$b"]}, - error_code=OVERFLOW_ERROR, - msg="$add should error when adding INT64_MAX milliseconds to a date overflows the date range", # noqa: E501 - ), -] - -ADD_ERROR_ALL_TESTS = ( - ADD_TYPE_ERROR_TESTS - + ADD_MIXED_VALID_INVALID_TESTS - + ADD_SINGLE_TYPE_ERROR_TESTS - + ADD_MULTIPLE_DATE_TESTS - + ADD_DATE_NON_FINITE_ERROR_TESTS - + ADD_DATE_OVERFLOW_TESTS -) - - -@pytest.mark.parametrize("test_case", pytest_params(ADD_ERROR_ALL_TESTS)) -def test_add_errors(collection, test_case: ExpressionTestCase): - """Test $add type, multiple-date, and date non-finite error cases.""" - result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) - assert_expression_result( - result, - expected=test_case.expected, - error_code=test_case.error_code, - msg=test_case.msg, - ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_input_forms.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_input_forms.py deleted file mode 100644 index 7019c1b0b..000000000 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_input_forms.py +++ /dev/null @@ -1,47 +0,0 @@ -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 [Expression Input]: $add evaluates a nested expression argument before summing. -ADD_EXPRESSION_INPUT_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "nested_add", - doc={"a": 1, "b": 2}, - expression={"$add": [{"$add": ["$a", "$b"]}, 3]}, - expected=6, - msg="$add should evaluate a nested $add expression as an operand", - ), -] - -# Property [Mixed Literal and Field]: $add accepts a mix of field references and inline literals -# in the same operand list. -ADD_MIXED_INPUT_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "mixed_literal_and_field", - doc={"a": 10}, - expression={"$add": ["$a", 5]}, - expected=15, - msg="$add should sum a field reference and an inline literal operand", - ), -] - -ADD_INPUT_FORM_ALL_TESTS = ADD_EXPRESSION_INPUT_TESTS + ADD_MIXED_INPUT_TESTS - - -@pytest.mark.parametrize("test_case", pytest_params(ADD_INPUT_FORM_ALL_TESTS)) -def test_add_input_forms(collection, test_case: ExpressionTestCase): - """Test $add literal, nested expression, and mixed input form cases.""" - result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) - assert_expression_result( - result, - expected=test_case.expected, - error_code=test_case.error_code, - msg=test_case.msg, - ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_non_finite.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_non_finite.py deleted file mode 100644 index 0343c3316..000000000 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_non_finite.py +++ /dev/null @@ -1,154 +0,0 @@ -import math - -import pytest - -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 - ExpressionTestCase, -) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( - 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, - FLOAT_INFINITY, - FLOAT_NAN, - FLOAT_NEGATIVE_INFINITY, -) - -# Property [Infinity]: $add propagates infinity according to IEEE 754 rules. -ADD_INFINITY_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "infinity", - doc={"a": FLOAT_INFINITY, "b": 1}, - expression={"$add": ["$a", "$b"]}, - expected=FLOAT_INFINITY, - msg="$add should return infinity when adding infinity and a finite number", - ), - ExpressionTestCase( - "negative_infinity", - doc={"a": FLOAT_NEGATIVE_INFINITY, "b": 1}, - expression={"$add": ["$a", "$b"]}, - expected=FLOAT_NEGATIVE_INFINITY, - msg="$add should return negative infinity when adding negative infinity and a finite number", # noqa: E501 - ), - ExpressionTestCase( - "single_infinity", - doc={"a": FLOAT_INFINITY}, - expression={"$add": ["$a"]}, - expected=FLOAT_INFINITY, - msg="$add should return infinity for a single infinity operand", - ), - ExpressionTestCase( - "inf_plus_inf", - doc={"a": FLOAT_INFINITY, "b": FLOAT_INFINITY}, - expression={"$add": ["$a", "$b"]}, - expected=FLOAT_INFINITY, - msg="$add should return infinity when adding two positive infinities", - ), - ExpressionTestCase( - "neg_inf_plus_neg_inf", - doc={"a": FLOAT_NEGATIVE_INFINITY, "b": FLOAT_NEGATIVE_INFINITY}, - expression={"$add": ["$a", "$b"]}, - expected=FLOAT_NEGATIVE_INFINITY, - msg="$add should return negative infinity when adding two negative infinities", - ), - ExpressionTestCase( - "inf_plus_zero", - doc={"a": FLOAT_INFINITY, "b": 0}, - expression={"$add": ["$a", "$b"]}, - expected=FLOAT_INFINITY, - msg="$add should return infinity when adding infinity and zero", - ), - ExpressionTestCase( - "neg_inf_plus_zero", - doc={"a": FLOAT_NEGATIVE_INFINITY, "b": 0}, - expression={"$add": ["$a", "$b"]}, - expected=FLOAT_NEGATIVE_INFINITY, - msg="$add should return negative infinity when adding negative infinity and zero", - ), - ExpressionTestCase( - "decimal_infinity", - doc={"a": DECIMAL128_INFINITY, "b": 1}, - expression={"$add": ["$a", "$b"]}, - expected=DECIMAL128_INFINITY, - msg="$add should return decimal128 infinity when adding decimal128 infinity and a number", - ), - ExpressionTestCase( - "decimal_negative_infinity", - doc={"a": DECIMAL128_NEGATIVE_INFINITY, "b": 1}, - expression={"$add": ["$a", "$b"]}, - expected=DECIMAL128_NEGATIVE_INFINITY, - msg="$add should return decimal128 negative infinity when adding decimal128 negative infinity and a number", # noqa: E501 - ), -] - -# Property [NaN]: $add propagates NaN according to IEEE 754 rules. -ADD_NAN_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "nan_add_one", - doc={"a": FLOAT_NAN, "b": 1}, - expression={"$add": ["$a", "$b"]}, - expected=pytest.approx(math.nan, nan_ok=True), - msg="$add should return NaN when adding float NaN and a finite number", - ), - ExpressionTestCase( - "inf_minus_inf", - doc={"a": FLOAT_INFINITY, "b": FLOAT_NEGATIVE_INFINITY}, - expression={"$add": ["$a", "$b"]}, - expected=pytest.approx(math.nan, nan_ok=True), - msg="$add should return NaN when adding float infinity and negative infinity", - ), - ExpressionTestCase( - "nan_plus_nan", - doc={"a": FLOAT_NAN, "b": FLOAT_NAN}, - expression={"$add": ["$a", "$b"]}, - expected=pytest.approx(math.nan, nan_ok=True), - msg="$add should return NaN when adding two float NaN values", - ), - ExpressionTestCase( - "nan_plus_inf", - doc={"a": FLOAT_NAN, "b": FLOAT_INFINITY}, - expression={"$add": ["$a", "$b"]}, - expected=pytest.approx(math.nan, nan_ok=True), - msg="$add should return NaN when adding float NaN and infinity", - ), - ExpressionTestCase( - "decimal_nan", - doc={"a": DECIMAL128_NAN, "b": 1}, - expression={"$add": ["$a", "$b"]}, - expected=DECIMAL128_NAN, - msg="$add should return decimal128 NaN when adding decimal128 NaN and a number", - ), - ExpressionTestCase( - "decimal_nan_plus_nan", - doc={"a": DECIMAL128_NAN, "b": DECIMAL128_NAN}, - expression={"$add": ["$a", "$b"]}, - expected=DECIMAL128_NAN, - msg="$add should return decimal128 NaN when adding two decimal128 NaN values", - ), - ExpressionTestCase( - "decimal_inf_minus_inf", - doc={"a": DECIMAL128_INFINITY, "b": DECIMAL128_NEGATIVE_INFINITY}, - expression={"$add": ["$a", "$b"]}, - expected=DECIMAL128_NAN, - msg="$add should return decimal128 NaN when adding decimal128 infinity and negative infinity", # noqa: E501 - ), -] - -ADD_NON_FINITE_ALL_TESTS = ADD_INFINITY_TESTS + ADD_NAN_TESTS - - -@pytest.mark.parametrize("test_case", pytest_params(ADD_NON_FINITE_ALL_TESTS)) -def test_add_non_finite(collection, test_case: ExpressionTestCase): - """Test $add infinity and NaN propagation cases.""" - result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) - assert_expression_result( - result, - expected=test_case.expected, - error_code=test_case.error_code, - msg=test_case.msg, - ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_null.py deleted file mode 100644 index 9651d52d3..000000000 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_null.py +++ /dev/null @@ -1,86 +0,0 @@ -from datetime import datetime, timezone - -import pytest - -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 - ExpressionTestCase, -) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( - assert_expression_result, - execute_expression_with_insert, -) -from documentdb_tests.framework.parametrize import pytest_params -from documentdb_tests.framework.test_constants import MISSING - -# Property [Null Propagation]: $add returns null if any operand is null or refers to a missing -# field. -ADD_NULL_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "single_null", - doc={"a": None}, - expression={"$add": ["$a"]}, - expected=None, - msg="$add should return null for a single null operand", - ), - ExpressionTestCase( - "null_operand", - doc={"a": 1, "b": None}, - expression={"$add": ["$a", "$b"]}, - expected=None, - msg="$add should return null when any operand is null", - ), - ExpressionTestCase( - "missing_field", - doc={}, - expression={"$add": [1, MISSING]}, - expected=None, - msg="$add should return null when any operand is a missing field", - ), - ExpressionTestCase( - "null_with_multiple", - doc={"a": 1, "b": 2, "c": None}, - expression={"$add": ["$a", "$b", "$c"]}, - expected=None, - msg="$add should return null when null appears among multiple operands", - ), - ExpressionTestCase( - "null_in_middle", - doc={"a": 1, "b": 2, "c": 3, "e": 5}, - expression={"$add": ["$a", "$b", "$c", None, "$e"]}, - expected=None, - msg="$add should return null when null appears in the middle of operands", - ), - ExpressionTestCase( - "all_null", - doc={"a": None, "b": None}, - expression={"$add": ["$a", "$b"]}, - expected=None, - msg="$add should return null when all operands are null", - ), - ExpressionTestCase( - "all_missing", - doc={}, - expression={"$add": [MISSING, MISSING]}, - expected=None, - msg="$add should return null when all operands are missing fields", - ), - ExpressionTestCase( - "date_and_null", - doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": None}, - expression={"$add": ["$a", "$b"]}, - expected=None, - msg="$add should return null when a date is paired with a null operand", - ), -] - - -@pytest.mark.parametrize("test_case", pytest_params(ADD_NULL_TESTS)) -def test_add_null(collection, test_case: ExpressionTestCase): - """Test $add null and missing field propagation cases.""" - result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) - assert_expression_result( - result, - expected=test_case.expected, - error_code=test_case.error_code, - msg=test_case.msg, - ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_numeric.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_numeric.py deleted file mode 100644 index 3c4d6f305..000000000 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_numeric.py +++ /dev/null @@ -1,259 +0,0 @@ -import pytest -from bson import Decimal128, Int64 - -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 - ExpressionTestCase, -) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( - assert_expression_result, - execute_expression_with_insert, -) -from documentdb_tests.framework.parametrize import pytest_params - -# Property [Same-Type Addition]: $add of two values of the same numeric type returns a value of -# that type. -ADD_SAME_TYPE_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "same_type_int32", - doc={"a": 1, "b": 2}, - expression={"$add": ["$a", "$b"]}, - expected=3, - msg="$add should add two int32 values", - ), - ExpressionTestCase( - "same_type_int64", - doc={"a": Int64(10), "b": Int64(20)}, - expression={"$add": ["$a", "$b"]}, - expected=Int64(30), - msg="$add should add two int64 values", - ), - ExpressionTestCase( - "same_type_double", - doc={"a": 1.5, "b": 2.5}, - expression={"$add": ["$a", "$b"]}, - expected=4.0, - msg="$add should add two double values", - ), - ExpressionTestCase( - "same_type_decimal", - doc={"a": Decimal128("10.5"), "b": Decimal128("20.5")}, - expression={"$add": ["$a", "$b"]}, - expected=Decimal128("31.0"), - msg="$add should add two decimal128 values", - ), -] - -# Property [Type Promotion]: $add promotes to the wider type when operands have different numeric -# types. Precedence: decimal128 > double > int64 > int32. -ADD_MIXED_TYPE_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "int32_int64", - doc={"a": 1, "b": Int64(20)}, - expression={"$add": ["$a", "$b"]}, - expected=Int64(21), - msg="$add should return int64 when adding int32 and int64", - ), - ExpressionTestCase( - "int32_double", - doc={"a": 1, "b": 2.5}, - expression={"$add": ["$a", "$b"]}, - expected=3.5, - msg="$add should return double when adding int32 and double", - ), - ExpressionTestCase( - "int32_decimal", - doc={"a": 1, "b": Decimal128("2.5")}, - expression={"$add": ["$a", "$b"]}, - expected=Decimal128("3.5"), - msg="$add should return decimal128 when adding int32 and decimal128", - ), - ExpressionTestCase( - "int64_double", - doc={"a": Int64(10), "b": 2.5}, - expression={"$add": ["$a", "$b"]}, - expected=12.5, - msg="$add should return double when adding int64 and double", - ), - ExpressionTestCase( - "int64_decimal", - doc={"a": Int64(10), "b": Decimal128("2.5")}, - expression={"$add": ["$a", "$b"]}, - expected=Decimal128("12.5"), - msg="$add should return decimal128 when adding int64 and decimal128", - ), - ExpressionTestCase( - "double_decimal", - doc={"a": 1.5, "b": Decimal128("2.5")}, - expression={"$add": ["$a", "$b"]}, - expected=pytest.approx(Decimal128("4.00000000000000")), - msg="$add should return decimal128 when adding double and decimal128", - ), - ExpressionTestCase( - "three_mixed_types", - doc={"a": Decimal128("1.5"), "b": 2.5, "c": Int64(3)}, - expression={"$add": ["$a", "$b", "$c"]}, - expected=pytest.approx(Decimal128("7.00000000000000")), - msg="$add should return decimal128 when adding decimal128, double, and int64", - ), -] - -# Property [Multiple Operands]: $add correctly sums three or more operands. -ADD_MULTIPLE_OPERANDS_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "multiple_int32", - doc={"a": 1, "b": 2, "c": 3}, - expression={"$add": ["$a", "$b", "$c"]}, - expected=6, - msg="$add should add multiple int32 values", - ), - ExpressionTestCase( - "multiple_int64", - doc={"a": Int64(1), "b": Int64(2), "c": Int64(3)}, - expression={"$add": ["$a", "$b", "$c"]}, - expected=Int64(6), - msg="$add should add multiple int64 values", - ), - ExpressionTestCase( - "multiple_double", - doc={"a": 1.1, "b": 2.2, "c": 3.3}, - expression={"$add": ["$a", "$b", "$c"]}, - expected=pytest.approx(6.6), - msg="$add should add multiple double values", - ), - ExpressionTestCase( - "multiple_decimal", - doc={ - "a": Decimal128("1"), - "b": Decimal128("2"), - "c": Decimal128("3"), - "d": Decimal128("4"), - }, - expression={"$add": ["$a", "$b", "$c", "$d"]}, - expected=Decimal128("10"), - msg="$add should add multiple decimal128 values", - ), - ExpressionTestCase( - "five_operands", - doc={"a": 1, "b": 2, "c": 3, "d": 4, "e": 5}, - expression={"$add": ["$a", "$b", "$c", "$d", "$e"]}, - expected=15, - msg="$add should correctly sum five int32 operands", - ), - ExpressionTestCase( - "ten_operands", - doc={ - "a": 1, - "b": 2, - "c": 3, - "d": 4, - "e": 5, - "f": 6, - "g": 7, - "h": 8, - "i": 9, - "j": 10, - }, - expression={"$add": ["$a", "$b", "$c", "$d", "$e", "$f", "$g", "$h", "$i", "$j"]}, - expected=55, - msg="$add should correctly sum ten int32 operands", - ), -] - -# Property [Empty and Single Operand]: $add of zero operands returns 0; single operand returns -# that value unchanged. -ADD_SINGLE_AND_EMPTY_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "empty", - doc={}, - expression={"$add": []}, - expected=0, - msg="$add should return 0 for empty operand list", - ), - ExpressionTestCase( - "single_int32", - doc={"a": 5}, - expression={"$add": ["$a"]}, - expected=5, - msg="$add should return the value for a single int32 operand", - ), - ExpressionTestCase( - "single_int64", - doc={"a": Int64(0)}, - expression={"$add": ["$a"]}, - expected=Int64(0), - msg="$add should return the value for a single int64 operand", - ), - ExpressionTestCase( - "single_double", - doc={"a": 0.0}, - expression={"$add": ["$a"]}, - expected=0.0, - msg="$add should return the value for a single double operand", - ), - ExpressionTestCase( - "single_decimal", - doc={"a": Decimal128("0")}, - expression={"$add": ["$a"]}, - expected=Decimal128("0"), - msg="$add should return the value for a single decimal128 operand", - ), -] - -# Property [Sign Handling]: $add handles negative values and zero correctly. -ADD_SIGN_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "negative_positive", - doc={"a": -5, "b": 3}, - expression={"$add": ["$a", "$b"]}, - expected=-2, - msg="$add should add a negative and a positive int32 value", - ), - ExpressionTestCase( - "both_negative", - doc={"a": -10, "b": -20}, - expression={"$add": ["$a", "$b"]}, - expected=-30, - msg="$add should add two negative int32 values", - ), - ExpressionTestCase( - "zeros", - doc={"a": 0, "b": 0}, - expression={"$add": ["$a", "$b"]}, - expected=0, - msg="$add should return 0 when adding two int32 zeros", - ), - ExpressionTestCase( - "zero_negative_zero", - doc={"a": 0, "b": -0.0}, - expression={"$add": ["$a", "$b"]}, - expected=0.0, - msg="$add should return 0.0 when adding int32 zero and negative zero double", - ), - ExpressionTestCase( - "sum_to_zero", - doc={"a": 1, "b": 0, "c": -1}, - expression={"$add": ["$a", "$b", "$c"]}, - expected=0, - msg="$add should return 0 when operands sum to zero", - ), -] - -ADD_NUMERIC_ALL_TESTS = ( - ADD_SAME_TYPE_TESTS - + ADD_MIXED_TYPE_TESTS - + ADD_MULTIPLE_OPERANDS_TESTS - + ADD_SINGLE_AND_EMPTY_TESTS - + ADD_SIGN_TESTS -) - - -@pytest.mark.parametrize("test_case", pytest_params(ADD_NUMERIC_ALL_TESTS)) -def test_add_numeric(collection, test_case: ExpressionTestCase): - """Test $add numeric type combinations, multiple operands, and sign handling.""" - result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) - assert_expression_result( - result, - expected=test_case.expected, - error_code=test_case.error_code, - msg=test_case.msg, - ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_overflow.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_overflow.py deleted file mode 100644 index 29a992905..000000000 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_overflow.py +++ /dev/null @@ -1,95 +0,0 @@ -import pytest -from bson import Int64 - -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 - ExpressionTestCase, -) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( - assert_expression_result, - execute_expression_with_insert, -) -from documentdb_tests.framework.parametrize import pytest_params -from documentdb_tests.framework.test_constants import ( - DOUBLE_FROM_INT64_MAX, - FLOAT_INFINITY, - FLOAT_NEGATIVE_INFINITY, - INT32_MAX, - INT32_MIN, - INT32_OVERFLOW, - INT32_UNDERFLOW, - INT64_MAX, - INT64_MIN, -) - -# Property [Int32 Overflow]: when an int32 result exceeds the int32 range, $add promotes to -# int64. -ADD_INT32_OVERFLOW_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "int32_overflow", - doc={"a": INT32_MAX, "b": 1}, - expression={"$add": ["$a", "$b"]}, - expected=Int64(INT32_OVERFLOW), - msg="$add should promote to int64 when the int32 result overflows INT32_MAX", - ), - ExpressionTestCase( - "int32_underflow", - doc={"a": INT32_MIN, "b": -1}, - expression={"$add": ["$a", "$b"]}, - expected=Int64(INT32_UNDERFLOW), - msg="$add should promote to int64 when the int32 result underflows INT32_MIN", - ), -] - -# Property [Int64 Overflow]: when an int64 result exceeds the int64 range, $add promotes to -# double. -ADD_INT64_OVERFLOW_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "int64_overflow", - doc={"a": INT64_MAX, "b": 1}, - expression={"$add": ["$a", "$b"]}, - expected=pytest.approx(DOUBLE_FROM_INT64_MAX), - msg="$add should promote to double when the int64 result overflows INT64_MAX", - ), - ExpressionTestCase( - "int64_underflow", - doc={"a": INT64_MIN, "b": -1}, - expression={"$add": ["$a", "$b"]}, - expected=pytest.approx(-DOUBLE_FROM_INT64_MAX), - msg="$add should promote to double when the int64 result underflows INT64_MIN", - ), -] - -# Property [Double Overflow]: when a double result exceeds the double range, $add returns -# infinity. -ADD_DOUBLE_OVERFLOW_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "double_overflow", - doc={"a": 1e308, "b": 1e308}, - expression={"$add": ["$a", "$b"]}, - expected=FLOAT_INFINITY, - msg="$add should return positive infinity on double overflow", - ), - ExpressionTestCase( - "double_underflow", - doc={"a": -1e308, "b": -1e308}, - expression={"$add": ["$a", "$b"]}, - expected=FLOAT_NEGATIVE_INFINITY, - msg="$add should return negative infinity on double underflow", - ), -] - -ADD_OVERFLOW_ALL_TESTS = ( - ADD_INT32_OVERFLOW_TESTS + ADD_INT64_OVERFLOW_TESTS + ADD_DOUBLE_OVERFLOW_TESTS -) - - -@pytest.mark.parametrize("test_case", pytest_params(ADD_OVERFLOW_ALL_TESTS)) -def test_add_overflow(collection, test_case: ExpressionTestCase): - """Test $add integer and double overflow and underflow cases.""" - result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) - assert_expression_result( - result, - expected=test_case.expected, - error_code=test_case.error_code, - msg=test_case.msg, - ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_precision.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_precision.py deleted file mode 100644 index f0cdd34ab..000000000 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_precision.py +++ /dev/null @@ -1,103 +0,0 @@ -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 -from documentdb_tests.framework.test_constants import ( - DECIMAL128_INFINITY, - DECIMAL128_MAX, - DECIMAL128_MIN, - DECIMAL128_NEGATIVE_INFINITY, -) - -# Property [Decimal128 Precision]: $add preserves decimal128 precision, including exact -# representation of values that are inexact in double. -ADD_DECIMAL_PRECISION_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "decimal_precision", - doc={"a": Decimal128("1.5"), "b": Decimal128("2.5")}, - expression={"$add": ["$a", "$b"]}, - expected=Decimal128("4.0"), - msg="$add should preserve decimal128 precision", - ), - ExpressionTestCase( - "decimal_precision_small", - doc={"a": Decimal128("0.1"), "b": Decimal128("0.2")}, - expression={"$add": ["$a", "$b"]}, - expected=Decimal128("0.3"), - msg="$add should exactly represent 0.1 + 0.2 with decimal128", - ), - ExpressionTestCase( - "decimal_large_precision", - doc={ - "a": Decimal128("999999999999999999999999999999999"), - "b": Decimal128("1"), - }, - expression={"$add": ["$a", "$b"]}, - expected=Decimal128("1000000000000000000000000000000000"), - msg="$add should handle large decimal128 addition with full precision", - ), - ExpressionTestCase( - "decimal_large_negative_precision", - doc={ - "a": Decimal128("-999999999999999999999999999999999"), - "b": Decimal128("-1"), - }, - expression={"$add": ["$a", "$b"]}, - expected=Decimal128("-1000000000000000000000000000000000"), - msg="$add should handle large negative decimal128 addition with full precision", - ), -] - -# Property [Decimal128 Boundaries]: $add at decimal128 boundary values promotes to infinity when -# the result overflows, and returns zero when max and min cancel. -ADD_DECIMAL_BOUNDARY_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "decimal128_max_plus_zero", - doc={"a": DECIMAL128_MAX, "b": Decimal128("0")}, - expression={"$add": ["$a", "$b"]}, - expected=DECIMAL128_MAX, - msg="$add should return decimal128 max when adding zero to decimal128 max", - ), - ExpressionTestCase( - "decimal128_max_plus_max", - doc={"a": DECIMAL128_MAX, "b": DECIMAL128_MAX}, - expression={"$add": ["$a", "$b"]}, - expected=DECIMAL128_INFINITY, - msg="$add should return decimal128 infinity when adding two decimal128 max values", - ), - ExpressionTestCase( - "decimal128_min_plus_min", - doc={"a": DECIMAL128_MIN, "b": DECIMAL128_MIN}, - expression={"$add": ["$a", "$b"]}, - expected=DECIMAL128_NEGATIVE_INFINITY, - msg="$add should return decimal128 negative infinity when adding two decimal128 min values", - ), - ExpressionTestCase( - "decimal128_max_plus_min", - doc={"a": DECIMAL128_MAX, "b": DECIMAL128_MIN}, - expression={"$add": ["$a", "$b"]}, - expected=Decimal128("0E+6111"), - msg="$add should return zero when adding decimal128 max and decimal128 min", - ), -] - -ADD_PRECISION_ALL_TESTS = ADD_DECIMAL_PRECISION_TESTS + ADD_DECIMAL_BOUNDARY_TESTS - - -@pytest.mark.parametrize("test_case", pytest_params(ADD_PRECISION_ALL_TESTS)) -def test_add_precision(collection, test_case: ExpressionTestCase): - """Test $add decimal128 precision and boundary value cases.""" - result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) - assert_expression_result( - result, - expected=test_case.expected, - error_code=test_case.error_code, - msg=test_case.msg, - ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_return_type.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_return_type.py deleted file mode 100644 index 7af9e0b66..000000000 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/add/test_add_return_type.py +++ /dev/null @@ -1,114 +0,0 @@ -from datetime import datetime, timezone - -import pytest -from bson import Decimal128, Int64 - -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 - ExpressionTestCase, -) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( - assert_expression_result, - execute_expression_with_insert, -) -from documentdb_tests.framework.parametrize import pytest_params - -# Property [Return Type]: $add follows numeric type promotion rules to determine the result type. -# Precedence: decimal128 > double > int64 > int32. Date + numeric always returns date. -ADD_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "return_type_int_int", - doc={"a": 1, "b": 2}, - expression={"$type": {"$add": ["$a", "$b"]}}, - expected="int", - msg="$add should return int type when adding two int32 values", - ), - ExpressionTestCase( - "return_type_int_long", - doc={"a": 1, "b": Int64(2)}, - expression={"$type": {"$add": ["$a", "$b"]}}, - expected="long", - msg="$add should return long type when adding int32 and int64", - ), - ExpressionTestCase( - "return_type_int_double", - doc={"a": 1, "b": 2.0}, - expression={"$type": {"$add": ["$a", "$b"]}}, - expected="double", - msg="$add should return double type when adding int32 and double", - ), - ExpressionTestCase( - "return_type_int_decimal", - doc={"a": 1, "b": Decimal128("2")}, - expression={"$type": {"$add": ["$a", "$b"]}}, - expected="decimal", - msg="$add should return decimal type when adding int32 and decimal128", - ), - ExpressionTestCase( - "return_type_long_long", - doc={"a": Int64(1), "b": Int64(2)}, - expression={"$type": {"$add": ["$a", "$b"]}}, - expected="long", - msg="$add should return long type when adding two int64 values", - ), - ExpressionTestCase( - "return_type_long_double", - doc={"a": Int64(1), "b": 2.0}, - expression={"$type": {"$add": ["$a", "$b"]}}, - expected="double", - msg="$add should return double type when adding int64 and double", - ), - ExpressionTestCase( - "return_type_long_decimal", - doc={"a": Int64(1), "b": Decimal128("2")}, - expression={"$type": {"$add": ["$a", "$b"]}}, - expected="decimal", - msg="$add should return decimal type when adding int64 and decimal128", - ), - ExpressionTestCase( - "return_type_double_double", - doc={"a": 1.0, "b": 2.0}, - expression={"$type": {"$add": ["$a", "$b"]}}, - expected="double", - msg="$add should return double type when adding two double values", - ), - ExpressionTestCase( - "return_type_double_decimal", - doc={"a": 1.0, "b": Decimal128("2")}, - expression={"$type": {"$add": ["$a", "$b"]}}, - expected="decimal", - msg="$add should return decimal type when adding double and decimal128", - ), - ExpressionTestCase( - "return_type_decimal_decimal", - doc={"a": Decimal128("1"), "b": Decimal128("2")}, - expression={"$type": {"$add": ["$a", "$b"]}}, - expected="decimal", - msg="$add should return decimal type when adding two decimal128 values", - ), - ExpressionTestCase( - "return_type_date_int", - doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 1000}, - expression={"$type": {"$add": ["$a", "$b"]}}, - expected="date", - msg="$add should return date type when adding a date and an int32", - ), - ExpressionTestCase( - "return_type_empty", - doc={}, - expression={"$type": {"$add": []}}, - expected="int", - msg="$add should return int type for an empty operand list", - ), -] - - -@pytest.mark.parametrize("test_case", pytest_params(ADD_RETURN_TYPE_TESTS)) -def test_add_return_type(collection, test_case: ExpressionTestCase): - """Test $add return type promotion rules for all numeric type combinations.""" - result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) - assert_expression_result( - result, - expected=test_case.expected, - error_code=test_case.error_code, - msg=test_case.msg, - ) From ac4f271a52c1d8ec62e83a9e0f1a6ff727b9f516 Mon Sep 17 00:00:00 2001 From: PatersonProjects Date: Tue, 7 Jul 2026 12:09:42 -0700 Subject: [PATCH 5/6] Added facet tests Signed-off-by: PatersonProjects --- .../core/operator/stages/facet/__init__.py | 0 .../facet/test_facet_argument_validation.py | 81 ++++ .../facet/test_facet_bson_passthrough.py | 168 +++++++ .../stages/facet/test_facet_collation.py | 76 ++++ .../stages/facet/test_facet_core_semantics.py | 157 +++++++ .../stages/facet/test_facet_errors.py | 412 +++++++++++++++++ .../stages/facet/test_facet_independence.py | 118 +++++ .../facet/test_facet_output_structure.py | 105 +++++ .../facet/test_facet_subpipeline_stages.py | 413 ++++++++++++++++++ .../stages/test_stages_position_facet.py | 225 ++++++++++ .../operator/stages/utils/stage_test_case.py | 3 + documentdb_tests/framework/error_codes.py | 3 + 12 files changed, 1761 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/facet/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_argument_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_bson_passthrough.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_collation.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_core_semantics.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_independence.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_output_structure.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_subpipeline_stages.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_facet.py diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/facet/__init__.py b/documentdb_tests/compatibility/tests/core/operator/stages/facet/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_argument_validation.py b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_argument_validation.py new file mode 100644 index 000000000..210ebbf79 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_argument_validation.py @@ -0,0 +1,81 @@ +"""Aggregation $facet stage tests - valid argument edge cases. + +Covers the positive TEST_COVERAGE.md §4 (Argument Handling) cases for $facet: +valid edge cases such as empty sub-pipelines, many sub-pipelines, and +unusual-but-valid output field names. + +Note: the "two sub-pipelines with the same output field name" spec item is not +tested here. A BSON document cannot carry two identical field names through the +driver -- the wire encoding collapses them to a single (last-wins) entry before +the command is sent -- so a genuine duplicate-output-field $facet cannot be +constructed via pymongo and the server never observes the duplicate. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +DOCS = [{"_id": 1, "cat": "A"}, {"_id": 2, "cat": "B"}] + +LONG_NAME = "f" * 300 + +# Property [Valid Edge Cases]: empty sub-pipelines, many sub-pipelines, and +# unusual-but-valid output field names are accepted. +FACET_ARGUMENT_SUCCESS_TESTS: list[StageTestCase] = [ + StageTestCase( + id="many_subpipelines", + docs=DOCS, + pipeline=[{"$facet": {f"f{i}": [{"$count": "n"}] for i in range(50)}}], + expected=[{f"f{i}": [{"n": 2}] for i in range(50)}], + msg="A $facet with many valid sub-pipelines should complete without error", + ), + StageTestCase( + id="very_long_field_name", + docs=DOCS, + pipeline=[{"$facet": {LONG_NAME: [{"$count": "n"}]}}], + expected=[{LONG_NAME: [{"n": 2}]}], + msg="$facet should accept and preserve a very long output field name", + ), + StageTestCase( + id="unicode_field_name", + docs=DOCS, + pipeline=[{"$facet": {"日本語": [{"$count": "n"}]}}], + expected=[{"日本語": [{"n": 2}]}], + msg="$facet should accept and correctly retrieve a unicode output field name", + ), +] + +# Combined list for parametrization. +FACET_ARGUMENT_TESTS = FACET_ARGUMENT_SUCCESS_TESTS + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(FACET_ARGUMENT_TESTS)) +def test_facet_argument_validation(collection, test_case: StageTestCase): + """Test $facet valid argument edge cases.""" + coll = populate_collection(collection, test_case) + command: dict[str, Any] = { + "aggregate": coll.name, + "pipeline": test_case.pipeline, + "cursor": {}, + } + command.update(test_case.extra_command_fields) + result = execute_command(coll, command) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ignore_doc_order=test_case.ignore_doc_order, + ignore_order_in=test_case.ignore_order_in, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_bson_passthrough.py b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_bson_passthrough.py new file mode 100644 index 000000000..ca3dbb1b8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_bson_passthrough.py @@ -0,0 +1,168 @@ +"""Aggregation $facet stage tests - BSON type pass-through. + +Verifies that $facet passes documents of every standard BSON type through to +sub-pipelines without altering their values (TEST_COVERAGE.md §1 Data Type +Coverage, applied to the pass-through behaviour of the stage). +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [BSON Pass-Through]: $facet preserves one document of each standard +# BSON type inside its sub-pipelines. +FACET_BSON_PASSTHROUGH_TESTS: list[StageTestCase] = [ + StageTestCase( + id="int32", + docs=[{"_id": 1, "val": 42}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": 42}]}], + msg="$facet should pass an int32 value through to sub-pipelines unchanged", + ), + StageTestCase( + id="int64", + docs=[{"_id": 1, "val": Int64(42)}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": Int64(42)}]}], + msg="$facet should pass an int64 value through to sub-pipelines unchanged", + ), + StageTestCase( + id="double", + docs=[{"_id": 1, "val": 1.5}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": 1.5}]}], + msg="$facet should pass a double value through to sub-pipelines unchanged", + ), + StageTestCase( + id="decimal128", + docs=[{"_id": 1, "val": Decimal128("1.5")}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": Decimal128("1.5")}]}], + msg="$facet should pass a decimal128 value through to sub-pipelines unchanged", + ), + StageTestCase( + id="string", + docs=[{"_id": 1, "val": "hello"}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": "hello"}]}], + msg="$facet should pass a string value through to sub-pipelines unchanged", + ), + StageTestCase( + id="bool", + docs=[{"_id": 1, "val": True}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": True}]}], + msg="$facet should pass a boolean value through to sub-pipelines unchanged", + ), + StageTestCase( + id="date", + docs=[{"_id": 1, "val": datetime(2024, 1, 1, tzinfo=timezone.utc)}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": datetime(2024, 1, 1, tzinfo=timezone.utc)}]}], + msg="$facet should pass a date value through to sub-pipelines unchanged", + ), + StageTestCase( + id="null", + docs=[{"_id": 1, "val": None}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": None}]}], + msg="$facet should pass a null value through to sub-pipelines unchanged", + ), + StageTestCase( + id="object", + docs=[{"_id": 1, "val": {"a": 1, "b": {"c": 2}}}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": {"a": 1, "b": {"c": 2}}}]}], + msg="$facet should pass an object value through to sub-pipelines unchanged", + ), + StageTestCase( + id="array", + docs=[{"_id": 1, "val": [1, "two", 3.0]}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": [1, "two", 3.0]}]}], + msg="$facet should pass an array value through to sub-pipelines unchanged", + ), + StageTestCase( + id="objectId", + docs=[{"_id": 1, "val": ObjectId("507f1f77bcf86cd799439011")}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": ObjectId("507f1f77bcf86cd799439011")}]}], + msg="$facet should pass an objectId value through to sub-pipelines unchanged", + ), + StageTestCase( + id="binData", + docs=[{"_id": 1, "val": Binary(b"payload", 128)}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": Binary(b"payload", 128)}]}], + msg="$facet should pass a binary value through to sub-pipelines unchanged", + ), + StageTestCase( + id="timestamp", + docs=[{"_id": 1, "val": Timestamp(123, 4)}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": Timestamp(123, 4)}]}], + msg="$facet should pass a timestamp value through to sub-pipelines unchanged", + ), + StageTestCase( + id="minKey", + docs=[{"_id": 1, "val": MinKey()}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": MinKey()}]}], + msg="$facet should pass a minKey value through to sub-pipelines unchanged", + ), + StageTestCase( + id="maxKey", + docs=[{"_id": 1, "val": MaxKey()}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": MaxKey()}]}], + msg="$facet should pass a maxKey value through to sub-pipelines unchanged", + ), + StageTestCase( + id="regex", + docs=[{"_id": 1, "val": Regex(r"^hello", "i")}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": Regex(r"^hello", "i")}]}], + msg="$facet should pass a regex value through to sub-pipelines unchanged", + ), + StageTestCase( + id="javascript", + docs=[{"_id": 1, "val": Code("function() {}")}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": Code("function() {}")}]}], + msg="$facet should pass a javascript value through to sub-pipelines unchanged", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(FACET_BSON_PASSTHROUGH_TESTS)) +def test_facet_bson_passthrough(collection, test_case: StageTestCase): + """$facet passes a document field of each BSON type through unchanged.""" + coll = populate_collection(collection, test_case) + command: dict[str, Any] = { + "aggregate": coll.name, + "pipeline": test_case.pipeline, + "cursor": {}, + } + command.update(test_case.extra_command_fields) + result = execute_command(coll, command) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ignore_doc_order=test_case.ignore_doc_order, + ignore_order_in=test_case.ignore_order_in, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_collation.py b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_collation.py new file mode 100644 index 000000000..3ee161699 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_collation.py @@ -0,0 +1,76 @@ +"""Aggregation $facet stage tests - collation wiring. + +Per TEST_COVERAGE.md §19 (Foundational Spec Behaviors), collation semantics are +tested comprehensively under tests/core/collation/. These tests only verify +that $facet correctly wires the command-level collation into its sub-pipelines +(e.g. a case-insensitive $match and $sortByCount respect the collation). +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +CI = {"locale": "en", "strength": 2} # case-insensitive collation +DOCS = [{"_id": 1, "cat": "a"}, {"_id": 2, "cat": "A"}, {"_id": 3, "cat": "b"}] + +FACET_COLLATION_TESTS: list[StageTestCase] = [ + StageTestCase( + id="applies_to_subpipeline_match", + docs=DOCS, + pipeline=[{"$facet": {"ci": [{"$match": {"cat": "a"}}, {"$sort": {"_id": 1}}]}}], + expected=[{"ci": [{"_id": 1, "cat": "a"}, {"_id": 2, "cat": "A"}]}], + extra_command_fields={"collation": CI}, + msg="Case-insensitive collation should apply to a $match in a sub-pipeline", + ), + StageTestCase( + id="applies_to_subpipeline_sortByCount", + docs=DOCS, + pipeline=[ + { + "$facet": { + "byCat": [ + {"$sortByCount": "$cat"}, + {"$project": {"_id": 0, "count": 1}}, + {"$sort": {"count": -1}}, + ] + } + } + ], + expected=[{"byCat": [{"count": 2}, {"count": 1}]}], + extra_command_fields={"collation": CI}, + msg="Case-insensitive collation should merge 'a'/'A' in $sortByCount grouping", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.collation +@pytest.mark.parametrize("test_case", pytest_params(FACET_COLLATION_TESTS)) +def test_facet_collation(collection, test_case: StageTestCase): + """A command-level collation applies inside $facet sub-pipelines.""" + coll = populate_collection(collection, test_case) + command: dict[str, Any] = { + "aggregate": coll.name, + "pipeline": test_case.pipeline, + "cursor": {}, + } + command.update(test_case.extra_command_fields) + result = execute_command(coll, command) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ignore_doc_order=test_case.ignore_doc_order, + ignore_order_in=test_case.ignore_order_in, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_core_semantics.py b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_core_semantics.py new file mode 100644 index 000000000..722778268 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_core_semantics.py @@ -0,0 +1,157 @@ +"""Aggregation $facet stage tests - core pipeline stage semantics. + +Covers TEST_COVERAGE.md §15 (Pipeline Stage Coverage) core semantics for the +$facet stage: single/multiple sub-pipelines, sole-stage behavior, empty and +non-existent collections, empty sub-pipeline results, shared input snapshot, +single-output-document guarantee, always-array output fields, and case-sensitive +output field names. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Len + +DOCS = [ + {"_id": 1, "category": "A", "price": 10}, + {"_id": 2, "category": "B", "price": 20}, + {"_id": 3, "category": "A", "price": 30}, +] + +# Property [Core Semantics]: $facet emits one output document, preserves +# sub-pipeline input independence, and produces one array per declared output +# field on empty, non-existent, and populated collections. +FACET_CORE_SEMANTICS_TESTS: list[StageTestCase] = [ + StageTestCase( + id="single_subpipeline", + docs=DOCS, + pipeline=[{"$facet": {"categoryA": [{"$match": {"category": "A"}}]}}], + expected=[ + { + "categoryA": [ + {"_id": 1, "category": "A", "price": 10}, + {"_id": 3, "category": "A", "price": 30}, + ] + } + ], + msg="Single sub-pipeline should yield one document with one array field", + ), + StageTestCase( + id="sole_stage", + docs=DOCS, + pipeline=[{"$facet": {"all": []}}], + expected=[{"all": DOCS}], + msg="$facet as the only stage should pass all docs through an empty sub-pipeline", + ), + StageTestCase( + id="empty_collection", + docs=[], + pipeline=[ + { + "$facet": { + "a": [{"$match": {"category": "A"}}], + "b": [{"$match": {"category": "B"}}], + } + } + ], + expected=[{"a": [], "b": []}], + msg="Empty collection should yield one document with empty arrays per sub-pipeline", + ), + StageTestCase( + id="nonexistent_collection", + docs=None, + pipeline=[ + { + "$facet": { + "a": [{"$match": {"category": "A"}}], + "b": [{"$match": {"category": "B"}}], + } + } + ], + expected=[{"a": [], "b": []}], + msg="Non-existent collection should yield one document with empty arrays per sub-pipeline", + ), + StageTestCase( + id="no_matching_documents", + docs=DOCS, + pipeline=[{"$facet": {"none": [{"$match": {"category": "Z"}}]}}], + expected=[{"none": []}], + msg="A non-matching sub-pipeline should return an empty array field", + ), + StageTestCase( + id="output_field_always_array", + docs=DOCS, + pipeline=[{"$facet": {"total": [{"$count": "n"}]}}], + expected={"total": [Len(1), Eq([{"n": 3}])]}, + msg="Output field should be a one-element array, not a bare document", + ), + StageTestCase( + id="output_field_names_case_sensitive", + docs=DOCS, + pipeline=[ + { + "$facet": { + "cat": [{"$match": {"category": "A"}}], + "Cat": [{"$match": {"category": "B"}}], + } + } + ], + expected=[ + { + "cat": [ + {"_id": 1, "category": "A", "price": 10}, + {"_id": 3, "category": "A", "price": 30}, + ], + "Cat": [{"_id": 2, "category": "B", "price": 20}], + } + ], + msg="Output field names 'cat' and 'Cat' should be distinct (case-sensitive)", + ), + StageTestCase( + id="skip_beyond_count_empty", + docs=DOCS, + pipeline=[{"$facet": {"a": [{"$skip": 100}]}}], + expected=[{"a": []}], + msg="$facet should yield an empty array when $skip exceeds the document count", + ), + StageTestCase( + id="allowDiskUse_true_operates_normally", + docs=DOCS, + pipeline=[{"$facet": {"n": [{"$count": "n"}]}}], + expected=[{"n": [{"n": 3}]}], + extra_command_fields={"allowDiskUse": True}, + msg="$facet should operate normally when allowDiskUse:true is set", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(FACET_CORE_SEMANTICS_TESTS)) +def test_facet_core_semantics(collection, test_case: StageTestCase): + """Test core semantic behaviours of the $facet stage.""" + coll = populate_collection(collection, test_case) + command: dict[str, Any] = { + "aggregate": coll.name, + "pipeline": test_case.pipeline, + "cursor": {}, + } + command.update(test_case.extra_command_fields) + result = execute_command(coll, command) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ignore_doc_order=test_case.ignore_doc_order, + ignore_order_in=test_case.ignore_order_in, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_errors.py new file mode 100644 index 000000000..199c5b448 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_errors.py @@ -0,0 +1,412 @@ +"""Aggregation $facet stage tests - all error cases. + +Consolidates negative TEST_COVERAGE.md cases for the $facet stage: argument +handling, parse-time validation, forbidden sub-pipeline stages, sub-pipeline +runtime errors, output size limits, and boundary behaviours. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + BSON_OBJECT_TOO_LARGE_ERROR, + FACET_PIPELINE_INVALID_STAGE_ERROR, + FACET_PIPELINE_NOT_ARRAY_ERROR, + FACET_PIPELINE_STAGE_NOT_OBJECT_ERROR, + FACET_SPEC_NOT_OBJECT_ERROR, + FIELD_PATH_DOLLAR_PREFIX_ERROR, + FIELD_PATH_DOT_ERROR, + FIELD_PATH_EMPTY_COMPONENT_ERROR, + INVALID_NAMESPACE_ERROR, + LIMIT_NOT_POSITIVE_ERROR, + PIPELINE_STAGE_EXTRA_FIELD_ERROR, + UNKNOWN_PIPELINE_STAGE_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Documents used by the argument, forbidden-stage, and independence error cases. +DOCS = [{"_id": 1, "cat": "A"}, {"_id": 2, "cat": "B"}] + +# Documents used by the boundary/core-semantics error case. +CORE_DOCS = [ + {"_id": 1, "category": "A", "price": 10}, + {"_id": 2, "category": "B", "price": 20}, + {"_id": 3, "category": "A", "price": 30}, +] + +# Documents used by the output size limit error cases. +_BIG_STRING = "x" * 100_000 +_BIG_DOCS = [{"_id": i, "s": _BIG_STRING} for i in range(200)] + +# Property [Specification Type]: the $facet argument must be a non-empty object; +# non-objects, null, and an empty object are all rejected with 40169. +FACET_SPEC_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + id="spec_number", + docs=DOCS, + pipeline=[{"$facet": 1}], + error_code=FACET_SPEC_NOT_OBJECT_ERROR, + msg="Numeric $facet specification should be rejected", + ), + StageTestCase( + id="spec_string", + docs=DOCS, + pipeline=[{"$facet": "x"}], + error_code=FACET_SPEC_NOT_OBJECT_ERROR, + msg="String $facet specification should be rejected", + ), + StageTestCase( + id="spec_bool", + docs=DOCS, + pipeline=[{"$facet": True}], + error_code=FACET_SPEC_NOT_OBJECT_ERROR, + msg="Boolean $facet specification should be rejected", + ), + StageTestCase( + id="spec_array", + docs=DOCS, + pipeline=[{"$facet": [1, 2]}], + error_code=FACET_SPEC_NOT_OBJECT_ERROR, + msg="Array $facet specification should be rejected", + ), + StageTestCase( + id="spec_null", + docs=DOCS, + pipeline=[{"$facet": None}], + error_code=FACET_SPEC_NOT_OBJECT_ERROR, + msg="Null $facet specification should be rejected", + ), + StageTestCase( + id="spec_empty_object", + docs=DOCS, + pipeline=[{"$facet": {}}], + error_code=FACET_SPEC_NOT_OBJECT_ERROR, + msg="Empty $facet specification object should be rejected", + ), +] + +# Property [Sub-Pipeline Type]: each output field's value must be an array; +# non-array values are rejected with 40170. +FACET_PIPELINE_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + id="subpipeline_string", + docs=DOCS, + pipeline=[{"$facet": {"a": "notArray"}}], + error_code=FACET_PIPELINE_NOT_ARRAY_ERROR, + msg="String sub-pipeline value should be rejected", + ), + StageTestCase( + id="subpipeline_number", + docs=DOCS, + pipeline=[{"$facet": {"a": 5}}], + error_code=FACET_PIPELINE_NOT_ARRAY_ERROR, + msg="Numeric sub-pipeline value should be rejected", + ), + StageTestCase( + id="subpipeline_object", + docs=DOCS, + pipeline=[{"$facet": {"a": {"$match": {}}}}], + error_code=FACET_PIPELINE_NOT_ARRAY_ERROR, + msg="Object sub-pipeline value should be rejected", + ), +] + +# Property [Sub-Pipeline Element Type]: every element of a sub-pipeline array +# must be a stage document; non-documents and null are rejected with 40171. +FACET_PIPELINE_ELEMENT_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + id="element_number", + docs=DOCS, + pipeline=[{"$facet": {"a": [1]}}], + error_code=FACET_PIPELINE_STAGE_NOT_OBJECT_ERROR, + msg="Numeric element in a sub-pipeline should be rejected", + ), + StageTestCase( + id="element_string", + docs=DOCS, + pipeline=[{"$facet": {"a": ["x"]}}], + error_code=FACET_PIPELINE_STAGE_NOT_OBJECT_ERROR, + msg="String element in a sub-pipeline should be rejected", + ), + StageTestCase( + id="element_null", + docs=DOCS, + pipeline=[{"$facet": {"a": [None]}}], + error_code=FACET_PIPELINE_STAGE_NOT_OBJECT_ERROR, + msg="Null element in a sub-pipeline should be rejected", + ), +] + +# Property [Stage Name Validation]: unknown stage names and names without a +# leading '$' inside a sub-pipeline are rejected with 40324 at parse time. +FACET_STAGE_NAME_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + id="unknown_stage", + docs=DOCS, + pipeline=[{"$facet": {"a": [{"$notAStage": {}}]}}], + error_code=UNKNOWN_PIPELINE_STAGE_ERROR, + msg="Unknown stage name in a sub-pipeline should be rejected", + ), + StageTestCase( + id="stage_name_no_dollar", + docs=DOCS, + pipeline=[{"$facet": {"a": [{"match": {}}]}}], + error_code=UNKNOWN_PIPELINE_STAGE_ERROR, + msg="Stage name without a leading '$' should be rejected", + ), +] + +# Property [Field Name Validation]: invalid output field name forms (empty string, +# dotted path, dollar-prefix, double-dollar system-variable) are each rejected with +# the appropriate field-path error code. +FACET_FIELD_NAME_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + id="empty_string_field_name", + docs=DOCS, + pipeline=[{"$facet": {"": [{"$match": {}}]}}], + error_code=FIELD_PATH_EMPTY_COMPONENT_ERROR, + msg="Empty-string output field name should be rejected", + ), + StageTestCase( + id="dotted_field_name", + docs=DOCS, + pipeline=[{"$facet": {"a.b": [{"$count": "n"}]}}], + error_code=FIELD_PATH_DOT_ERROR, + msg="Dotted output field name should be rejected", + ), + StageTestCase( + id="dollar_prefix_field_name", + docs=DOCS, + pipeline=[{"$facet": {"$x": [{"$count": "n"}]}}], + error_code=FIELD_PATH_DOLLAR_PREFIX_ERROR, + msg="Dollar-prefixed output field name should be rejected", + ), + StageTestCase( + id="double_dollar_field_name", + docs=DOCS, + pipeline=[{"$facet": {"$$ROOT": [{"$count": "n"}]}}], + error_code=FIELD_PATH_DOLLAR_PREFIX_ERROR, + msg="System-variable-like output field name should be rejected", + ), +] + +# Property [Extra Stage Fields]: an unexpected top-level key alongside $facet in +# the stage document is rejected with 40323. +FACET_EXTRA_FIELD_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + id="extra_top_level_key", + docs=DOCS, + pipeline=[{"$facet": {"a": [{"$count": "n"}]}, "extraKey": 1}], + error_code=PIPELINE_STAGE_EXTRA_FIELD_ERROR, + msg="An unexpected extra top-level key in the $facet stage should be rejected", + ), +] + +# Property [Parse-Time Validation]: structural errors fire even on empty and +# non-existent collections, before any documents are processed. +FACET_PARSE_TIME_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + id="empty_collection_unknown_stage", + docs=[], + pipeline=[{"$facet": {"a": [{"$notAStage": {}}]}}], + error_code=UNKNOWN_PIPELINE_STAGE_ERROR, + msg="Unknown stage should be rejected at parse time on an empty collection", + ), + StageTestCase( + id="nonexistent_collection_unknown_stage", + docs=None, + pipeline=[{"$facet": {"a": [{"$notAStage": {}}]}}], + error_code=UNKNOWN_PIPELINE_STAGE_ERROR, + msg="Unknown stage should be rejected at parse time on a non-existent collection", + ), + StageTestCase( + id="empty_collection_non_array_pipeline", + docs=[], + pipeline=[{"$facet": {"a": "notArray"}}], + error_code=FACET_PIPELINE_NOT_ARRAY_ERROR, + msg="Non-array sub-pipeline should be rejected at parse time on an empty collection", + ), +] + +# Property [Core Semantic Boundary]: $limit 0 inside a sub-pipeline is rejected. +FACET_BOUNDARY_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + id="limit_zero_rejected", + docs=CORE_DOCS, + pipeline=[{"$facet": {"a": [{"$limit": 0}]}}], + error_code=LIMIT_NOT_POSITIVE_ERROR, + msg="$facet should reject $limit 0 inside a sub-pipeline", + ), +] + +# Property [Forbidden Sub-Pipeline Stages]: stages that cannot run inside a +# $facet sub-pipeline are rejected with 40600, independent of documents. +FACET_FORBIDDEN_STAGE_TESTS: list[StageTestCase] = [ + StageTestCase( + id="collStats", + docs=DOCS, + pipeline=[{"$facet": {"a": [{"$collStats": {}}]}}], + error_code=FACET_PIPELINE_INVALID_STAGE_ERROR, + msg="$collStats inside a $facet sub-pipeline should be rejected with 40600", + ), + StageTestCase( + id="nested_facet", + docs=DOCS, + pipeline=[{"$facet": {"a": [{"$facet": {"b": [{"$match": {}}]}}]}}], + error_code=FACET_PIPELINE_INVALID_STAGE_ERROR, + msg="Nested $facet should be rejected with 40600", + ), + StageTestCase( + id="geoNear", + docs=DOCS, + pipeline=[ + { + "$facet": { + "a": [ + { + "$geoNear": { + "near": {"type": "Point", "coordinates": [0, 0]}, + "distanceField": "d", + "spherical": True, + } + } + ] + } + } + ], + error_code=FACET_PIPELINE_INVALID_STAGE_ERROR, + msg="$geoNear inside a $facet sub-pipeline should be rejected with 40600", + ), + StageTestCase( + id="indexStats", + docs=DOCS, + pipeline=[{"$facet": {"a": [{"$indexStats": {}}]}}], + error_code=FACET_PIPELINE_INVALID_STAGE_ERROR, + msg="$indexStats inside a $facet sub-pipeline should be rejected with 40600", + ), + StageTestCase( + id="out", + docs=DOCS, + pipeline=[{"$facet": {"a": [{"$out": "outcoll"}]}}], + error_code=FACET_PIPELINE_INVALID_STAGE_ERROR, + msg="$out inside a $facet sub-pipeline should be rejected with 40600", + ), + StageTestCase( + id="merge", + docs=DOCS, + pipeline=[{"$facet": {"a": [{"$merge": {"into": "mcoll"}}]}}], + error_code=FACET_PIPELINE_INVALID_STAGE_ERROR, + msg="$merge inside a $facet sub-pipeline should be rejected with 40600", + ), + StageTestCase( + id="planCacheStats", + docs=DOCS, + pipeline=[{"$facet": {"a": [{"$planCacheStats": {}}]}}], + error_code=FACET_PIPELINE_INVALID_STAGE_ERROR, + msg="$planCacheStats inside a $facet sub-pipeline should be rejected with 40600", + ), + StageTestCase( + id="listSessions", + docs=DOCS, + pipeline=[{"$facet": {"a": [{"$listSessions": {}}]}}], + error_code=INVALID_NAMESPACE_ERROR, + msg="$listSessions inside a $facet sub-pipeline should be rejected with 73", + ), + StageTestCase( + id="valid_plus_forbidden", + docs=DOCS, + pipeline=[{"$facet": {"ok": [{"$match": {"cat": "A"}}], "bad": [{"$out": "outcoll"}]}}], + error_code=FACET_PIPELINE_INVALID_STAGE_ERROR, + msg="A forbidden stage in any sub-pipeline should fail the whole $facet", + ), +] + +# Property [Sub-Pipeline Independence]: a runtime error in any sub-pipeline fails +# the whole $facet stage. +INDEPENDENCE_DOCS = [ + {"_id": 1, "cat": "A", "v": 10}, + {"_id": 2, "cat": "A", "v": 20}, + {"_id": 3, "cat": "B", "v": 30}, +] +FACET_INDEPENDENCE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + id="subpipeline_error_fails_whole_stage", + docs=INDEPENDENCE_DOCS, + pipeline=[ + { + "$facet": { + "ok": [{"$match": {"cat": "A"}}], + "bad": [{"$project": {"x": {"$divide": [1, 0]}}}], + } + } + ], + error_code=BAD_VALUE_ERROR, + msg="An error in any sub-pipeline should fail the whole $facet stage", + ), +] + +# Property [Output Size Limits]: the final $facet output document is subject to +# the 16 MiB BSON limit, and allowDiskUse does not raise that limit. +FACET_SIZE_LIMIT_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + id="output_exceeds_16mib", + docs=_BIG_DOCS, + pipeline=[{"$facet": {"all": [{"$group": {"_id": None, "arr": {"$push": "$s"}}}]}}], + error_code=BSON_OBJECT_TOO_LARGE_ERROR, + msg="A $facet output document exceeding 16 MiB should be rejected", + ), + StageTestCase( + id="allowdiskuse_does_not_raise_16mib_limit", + docs=_BIG_DOCS, + pipeline=[{"$facet": {"all": [{"$group": {"_id": None, "arr": {"$push": "$s"}}}]}}], + error_code=BSON_OBJECT_TOO_LARGE_ERROR, + extra_command_fields={"allowDiskUse": True}, + msg="allowDiskUse:true should not raise the 16 MiB output-document limit", + ), +] + +FACET_ERROR_TESTS = ( + FACET_SPEC_TYPE_ERROR_TESTS + + FACET_PIPELINE_TYPE_ERROR_TESTS + + FACET_PIPELINE_ELEMENT_ERROR_TESTS + + FACET_STAGE_NAME_ERROR_TESTS + + FACET_FIELD_NAME_ERROR_TESTS + + FACET_EXTRA_FIELD_ERROR_TESTS + + FACET_PARSE_TIME_ERROR_TESTS + + FACET_BOUNDARY_ERROR_TESTS + + FACET_FORBIDDEN_STAGE_TESTS + + FACET_INDEPENDENCE_ERROR_TESTS + + FACET_SIZE_LIMIT_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(FACET_ERROR_TESTS)) +def test_facet_errors(collection, test_case: StageTestCase): + """Test all $facet error cases.""" + coll = populate_collection(collection, test_case) + command: dict[str, Any] = { + "aggregate": coll.name, + "pipeline": test_case.pipeline, + "cursor": {}, + } + command.update(test_case.extra_command_fields) + result = execute_command(coll, command) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ignore_doc_order=test_case.ignore_doc_order, + ignore_order_in=test_case.ignore_order_in, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_independence.py b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_independence.py new file mode 100644 index 000000000..865386908 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_independence.py @@ -0,0 +1,118 @@ +"""Aggregation $facet stage tests - sub-pipeline independence. + +Verifies that $facet sub-pipelines are fully independent: they share the same +input snapshot, one sub-pipeline's transformations do not affect another's +input, and a $limit/$match in one does not reduce the input seen by others. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +DOCS = [ + {"_id": 1, "cat": "A", "v": 10}, + {"_id": 2, "cat": "A", "v": 20}, + {"_id": 3, "cat": "B", "v": 30}, +] + +# Property [Sub-Pipeline Independence]: a $limit, $match, or $addFields in one +# sub-pipeline does not affect the input seen by another; a runtime error in any +# sub-pipeline fails the whole stage. +FACET_INDEPENDENCE_TESTS: list[StageTestCase] = [ + StageTestCase( + id="limit_in_one_does_not_reduce_input", + docs=DOCS, + pipeline=[ + { + "$facet": { + "limited": [{"$sort": {"_id": 1}}, {"$limit": 1}], + "counted": [{"$count": "n"}], + } + } + ], + expected=[{"limited": [DOCS[0]], "counted": [{"n": 3}]}], + msg="A $limit in one sub-pipeline must not affect another sub-pipeline's input", + ), + StageTestCase( + id="count_counts_all_input", + docs=DOCS, + pipeline=[ + { + "$facet": { + "filtered": [{"$match": {"cat": "A"}}], + "counted": [{"$count": "n"}], + } + } + ], + expected=[{"filtered": [DOCS[0], DOCS[1]], "counted": [{"n": 3}]}], + msg="$count should count all input documents regardless of sibling sub-pipelines", + ), + StageTestCase( + id="transformation_does_not_leak", + docs=DOCS, + pipeline=[ + { + "$facet": { + "adder": [{"$addFields": {"extra": 1}}, {"$match": {"_id": 1}}], + "viewer": [{"$match": {"_id": 1}}, {"$project": {"_id": 1, "extra": 1}}], + } + } + ], + expected=[{"adder": [{"_id": 1, "cat": "A", "v": 10, "extra": 1}], "viewer": [{"_id": 1}]}], + msg="A field added in one sub-pipeline must not leak into another sub-pipeline", + ), + StageTestCase( + id="same_result_as_independent_pipelines", + docs=DOCS, + pipeline=[ + { + "$facet": { + "byCat": [ + {"$group": {"_id": "$cat", "sum": {"$sum": "$v"}}}, + {"$sort": {"_id": 1}}, + ], + "high": [{"$match": {"v": {"$gte": 20}}}, {"$sort": {"_id": 1}}], + } + } + ], + expected=[ + { + "byCat": [{"_id": "A", "sum": 30}, {"_id": "B", "sum": 30}], + "high": [DOCS[1], DOCS[2]], + } + ], + msg="Sub-pipeline results should equal running each pipeline independently", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(FACET_INDEPENDENCE_TESTS)) +def test_facet_independence(collection, test_case: StageTestCase): + """Test sub-pipeline independence of the $facet stage.""" + coll = populate_collection(collection, test_case) + command: dict[str, Any] = { + "aggregate": coll.name, + "pipeline": test_case.pipeline, + "cursor": {}, + } + command.update(test_case.extra_command_fields) + result = execute_command(coll, command) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ignore_doc_order=test_case.ignore_doc_order, + ignore_order_in=test_case.ignore_order_in, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_output_structure.py b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_output_structure.py new file mode 100644 index 000000000..465885cb7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_output_structure.py @@ -0,0 +1,105 @@ +"""Aggregation $facet stage tests - output document structure. + +Covers TEST_COVERAGE.md §15 (Document Handling): the single output document, +one array field per sub-pipeline, output-field order matching declaration +order, in-array document order preservation, and faithful representation of +nested arrays, null fields, array-of-arrays, and many-field documents. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, OrderedKeys + +DOCS = [ + {"_id": 1, "cat": "A", "v": 30}, + {"_id": 2, "cat": "B", "v": 10}, + {"_id": 3, "cat": "C", "v": 20}, +] + +MANY_FIELD_DOC = {"_id": 1} +MANY_FIELD_DOC.update({f"k{i}": i for i in range(30)}) + +# Property [Output Structure]: $facet produces a single document with one array +# per sub-pipeline; array order, output-field order, and nested/special values are +# preserved. +FACET_OUTPUT_STRUCTURE_TESTS: list[StageTestCase] = [ + StageTestCase( + id="array_preserves_subpipeline_order", + docs=DOCS, + pipeline=[{"$facet": {"sorted": [{"$sort": {"v": 1}}, {"$project": {"_id": 1}}]}}], + expected=[{"sorted": [{"_id": 2}, {"_id": 3}, {"_id": 1}]}], + msg="Output array should preserve the sub-pipeline's document order", + ), + StageTestCase( + id="field_order_matches_declaration", + docs=DOCS, + pipeline=[ + {"$facet": {"z": [{"$count": "n"}], "a": [{"$count": "n"}], "m": [{"$count": "n"}]}} + ], + expected={"": OrderedKeys(["z", "a", "m"])}, + msg="Output field order should match sub-pipeline declaration order", + ), + StageTestCase( + id="one_field_per_subpipeline", + docs=DOCS, + pipeline=[{"$facet": {"one": [{"$count": "n"}], "two": [{"$count": "n"}]}}], + expected=[{"one": [{"n": 3}], "two": [{"n": 3}]}], + msg="Output should contain exactly one field per sub-pipeline", + ), + StageTestCase( + id="nested_arrays_preserved", + docs=[{"_id": 1, "nested": [[1, 2], [3, 4]]}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "nested": [[1, 2], [3, 4]]}]}], + msg="Nested arrays should be preserved in the output array", + ), + StageTestCase( + id="array_of_arrays_from_push", + docs=[{"_id": 1, "arr": [1, 2]}, {"_id": 2, "arr": [3, 4]}], + pipeline=[ + {"$facet": {"grouped": [{"$group": {"_id": None, "pushed": {"$push": "$arr"}}}]}} + ], + expected={"grouped": Eq([{"_id": None, "pushed": [[1, 2], [3, 4]]}])}, + msg="$push of an array field should yield an array-of-arrays in the output", + ), + StageTestCase( + id="many_field_document", + docs=[MANY_FIELD_DOC], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [MANY_FIELD_DOC]}], + msg="A many-field document should appear intact in the output array", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(FACET_OUTPUT_STRUCTURE_TESTS)) +def test_facet_output_structure(collection, test_case: StageTestCase): + """Test output document structure of the $facet stage.""" + coll = populate_collection(collection, test_case) + command: dict[str, Any] = { + "aggregate": coll.name, + "pipeline": test_case.pipeline, + "cursor": {}, + } + command.update(test_case.extra_command_fields) + result = execute_command(coll, command) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ignore_doc_order=test_case.ignore_doc_order, + ignore_order_in=test_case.ignore_order_in, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_subpipeline_stages.py b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_subpipeline_stages.py new file mode 100644 index 000000000..c1836544c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_subpipeline_stages.py @@ -0,0 +1,413 @@ +"""Aggregation $facet stage tests - sub-pipeline stage support. + +Verifies that a variety of aggregation stages work inside $facet sub-pipelines +and produce the expected output arrays. Per the container-features rule +(FOLDER_STRUCTURE.md), these are one-case-per-sub-feature smoke tests, not +exhaustive edge-case coverage of each inner stage. Also covers JS-derived +use-case scenarios (stages.js, use_cases.js). +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult, assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Len + +DOCS = [ + {"_id": 1, "cat": "A", "price": 10, "tags": ["x", "y"]}, + {"_id": 2, "cat": "A", "price": 20, "tags": ["y"]}, + {"_id": 3, "cat": "B", "price": 30, "tags": ["z"]}, + {"_id": 4, "cat": "C", "price": 40, "tags": []}, +] + +# Property [Sub-Pipeline Stage Support]: common aggregation stages and +# use-case combinations produce the expected output arrays inside $facet. +FACET_SUBPIPELINE_STAGE_TESTS: list[StageTestCase] = [ + StageTestCase( + id="sortByCount", + docs=DOCS, + pipeline=[{"$facet": {"byCat": [{"$sortByCount": "$cat"}]}}], + expected=[ + { + "byCat": [ + {"_id": "A", "count": 2}, + {"_id": "B", "count": 1}, + {"_id": "C", "count": 1}, + ] + } + ], + msg="$sortByCount sub-pipeline should return {_id, count} documents sorted by count", + ignore_order_in=["byCat"], + ), + StageTestCase( + id="bucket", + docs=DOCS, + pipeline=[ + {"$facet": {"buckets": [{"$bucket": {"groupBy": "$price", "boundaries": [0, 25, 50]}}]}} + ], + expected=[{"buckets": [{"_id": 0, "count": 2}, {"_id": 25, "count": 2}]}], + msg="$bucket sub-pipeline should return bucket documents", + ), + StageTestCase( + id="bucketAuto", + docs=DOCS, + pipeline=[{"$facet": {"auto": [{"$bucketAuto": {"groupBy": "$price", "buckets": 2}}]}}], + expected=[ + { + "auto": [ + {"_id": {"min": 10, "max": 30}, "count": 2}, + {"_id": {"min": 30, "max": 40}, "count": 2}, + ] + } + ], + msg="$bucketAuto sub-pipeline should return auto-bucket documents", + ), + StageTestCase( + id="match_count", + docs=DOCS, + pipeline=[{"$facet": {"nA": [{"$match": {"cat": "A"}}, {"$count": "n"}]}}], + expected=[{"nA": [{"n": 2}]}], + msg="$match + $count sub-pipeline should return a single count document", + ), + StageTestCase( + id="group_sort", + docs=DOCS, + pipeline=[ + { + "$facet": { + "totals": [ + {"$group": {"_id": "$cat", "total": {"$sum": "$price"}}}, + {"$sort": {"_id": 1}}, + ] + } + } + ], + expected=[ + { + "totals": [ + {"_id": "A", "total": 30}, + {"_id": "B", "total": 30}, + {"_id": "C", "total": 40}, + ] + } + ], + msg="$group + $sort sub-pipeline should return sorted grouped results", + ), + StageTestCase( + id="limit", + docs=DOCS, + pipeline=[{"$facet": {"first2": [{"$sort": {"_id": 1}}, {"$limit": 2}]}}], + expected=[{"first2": [DOCS[0], DOCS[1]]}], + msg="$limit sub-pipeline should return at most N documents", + ), + StageTestCase( + id="skip", + docs=DOCS, + pipeline=[{"$facet": {"rest": [{"$sort": {"_id": 1}}, {"$skip": 2}]}}], + expected=[{"rest": [DOCS[2], DOCS[3]]}], + msg="$skip sub-pipeline should skip the specified number of documents", + ), + StageTestCase( + id="project", + docs=DOCS, + pipeline=[ + {"$facet": {"proj": [{"$sort": {"_id": 1}}, {"$project": {"_id": 0, "cat": 1}}]}} + ], + expected=[{"proj": [{"cat": "A"}, {"cat": "A"}, {"cat": "B"}, {"cat": "C"}]}], + msg="$project sub-pipeline should return only projected fields", + ), + StageTestCase( + id="unwind", + docs=DOCS, + pipeline=[ + { + "$facet": { + "unwound": [ + {"$match": {"_id": 1}}, + {"$unwind": "$tags"}, + {"$project": {"_id": 1, "tags": 1}}, + ] + } + } + ], + expected=[{"unwound": [{"_id": 1, "tags": "x"}, {"_id": 1, "tags": "y"}]}], + msg="$unwind sub-pipeline should return unwound documents", + ), + StageTestCase( + id="addFields", + docs=DOCS, + pipeline=[ + { + "$facet": { + "withDouble": [ + {"$match": {"_id": 1}}, + {"$addFields": {"double": {"$multiply": ["$price", 2]}}}, + ] + } + } + ], + expected=[ + {"withDouble": [{"_id": 1, "cat": "A", "price": 10, "tags": ["x", "y"], "double": 20}]} + ], + msg="$addFields sub-pipeline should add a computed field", + ), + StageTestCase( + id="replaceRoot", + docs=DOCS, + pipeline=[ + { + "$facet": { + "replaced": [ + {"$match": {"_id": 1}}, + {"$replaceRoot": {"newRoot": {"only": "$cat"}}}, + ] + } + } + ], + expected=[{"replaced": [{"only": "A"}]}], + msg="$replaceRoot sub-pipeline should replace the document root", + ), + StageTestCase( + id="multiple_chained_stages", + docs=DOCS, + pipeline=[ + { + "$facet": { + "chain": [ + {"$match": {"cat": "A"}}, + {"$group": {"_id": None, "total": {"$sum": "$price"}}}, + {"$project": {"_id": 0, "total": 1}}, + ] + } + } + ], + expected=[{"chain": [{"total": 30}]}], + msg="A chained sub-pipeline should return the result of the full chain", + ), + StageTestCase( + id="two_group_subpipelines", + docs=DOCS, + pipeline=[ + { + "$facet": { + "byCat": [ + {"$group": {"_id": "$cat", "sum": {"$sum": "$price"}}}, + {"$sort": {"_id": 1}}, + ], + "byId": [ + {"$group": {"_id": "$_id", "sum": {"$sum": "$price"}}}, + {"$sort": {"_id": 1}}, + ], + } + } + ], + expected=[ + { + "byCat": [ + {"_id": "A", "sum": 30}, + {"_id": "B", "sum": 30}, + {"_id": "C", "sum": 40}, + ], + "byId": [ + {"_id": 1, "sum": 10}, + {"_id": 2, "sum": 20}, + {"_id": 3, "sum": 30}, + {"_id": 4, "sum": 40}, + ], + } + ], + msg="Two $group sub-pipelines should each return their own grouped results", + ), + StageTestCase( + id="multi_subpipeline_use_case", + docs=DOCS, + pipeline=[ + { + "$facet": { + "byCat": [{"$sortByCount": "$cat"}, {"$sort": {"count": -1, "_id": 1}}], + "priceBuckets": [ + { + "$bucket": { + "groupBy": "$price", + "boundaries": [0, 25, 50], + "default": "other", + } + } + ], + "autoBuckets": [{"$bucketAuto": {"groupBy": "$price", "buckets": 2}}], + } + } + ], + expected=[ + { + "byCat": [ + {"_id": "A", "count": 2}, + {"_id": "B", "count": 1}, + {"_id": "C", "count": 1}, + ], + "priceBuckets": [{"_id": 0, "count": 2}, {"_id": 25, "count": 2}], + "autoBuckets": [ + {"_id": {"min": 10, "max": 30}, "count": 2}, + {"_id": {"min": 30, "max": 40}, "count": 2}, + ], + } + ], + msg="Multi-sub-pipeline use case should return three independent result arrays", + ), + StageTestCase( + id="match_dotted_array_path_count", + docs=DOCS, + pipeline=[{"$facet": {"withY": [{"$match": {"tags": "y"}}, {"$count": "n"}]}}], + expected=[{"withY": [{"n": 2}]}], + msg="$match on an array field + $count should count matching documents", + ), + StageTestCase( + id="nonexistent_field_no_crash", + docs=DOCS, + pipeline=[ + { + "$facet": { + "byMissing": [{"$sortByCount": "$doesNotExist"}], + "autoMissing": [{"$bucketAuto": {"groupBy": "$doesNotExist", "buckets": 1}}], + } + } + ], + expected={ + "byMissing": Eq([{"_id": None, "count": 4}]), + "autoMissing": [Len(1), Eq([{"_id": {"min": None, "max": None}, "count": 4}])], + }, + msg="Sub-pipelines on a non-existent field should not crash and return one doc each", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(FACET_SUBPIPELINE_STAGE_TESTS)) +def test_facet_subpipeline_stages(collection, test_case: StageTestCase): + """Test that common stages work correctly inside $facet sub-pipelines.""" + coll = populate_collection(collection, test_case) + command: dict[str, Any] = { + "aggregate": coll.name, + "pipeline": test_case.pipeline, + "cursor": {}, + } + command.update(test_case.extra_command_fields) + result = execute_command(coll, command) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ignore_doc_order=test_case.ignore_doc_order, + ignore_order_in=test_case.ignore_order_in, + ) + + +@pytest.mark.aggregate +def test_facet_subpipeline_lookup_basic(collection): + """A simple equi-join $lookup inside a $facet sub-pipeline returns joined results.""" + collection.insert_many([{"_id": 1, "lf": "a"}, {"_id": 2, "lf": "b"}]) + foreign = f"{collection.name}_foreign" + db = collection.database + db[foreign].insert_many([{"_id": 10, "ff": "a"}, {"_id": 11, "ff": "b"}]) + try: + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$facet": { + "joined": [ + { + "$lookup": { + "from": foreign, + "localField": "lf", + "foreignField": "ff", + "as": "j", + } + }, + {"$sort": {"_id": 1}}, + ] + } + } + ], + "cursor": {}, + }, + ) + assertSuccess( + result, + [ + { + "joined": [ + {"_id": 1, "lf": "a", "j": [{"_id": 10, "ff": "a"}]}, + {"_id": 2, "lf": "b", "j": [{"_id": 11, "ff": "b"}]}, + ] + } + ], + msg="Equi-join $lookup inside $facet should return joined arrays", + ) + finally: + db.drop_collection(foreign) + + +@pytest.mark.aggregate +def test_facet_subpipeline_graphlookup_basic(collection): + """A $graphLookup inside a $facet sub-pipeline returns the traversal.""" + collection.insert_many( + [ + {"_id": 1, "name": "a", "reportsTo": None}, + {"_id": 2, "name": "b", "reportsTo": "a"}, + {"_id": 3, "name": "c", "reportsTo": "b"}, + ] + ) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$facet": { + "graph": [ + {"$match": {"_id": 3}}, + { + "$graphLookup": { + "from": collection.name, + "startWith": "$reportsTo", + "connectFromField": "reportsTo", + "connectToField": "name", + "as": "chain", + } + }, + { + "$project": { + "_id": 1, + "chainNames": { + "$sortArray": { + "input": "$chain.name", + "sortBy": 1, + } + }, + } + }, + ] + } + } + ], + "cursor": {}, + }, + ) + assertSuccess( + result, + [{"graph": [{"_id": 3, "chainNames": ["a", "b"]}]}], + msg="$graphLookup inside $facet should return the traversal chain", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_facet.py b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_facet.py new file mode 100644 index 000000000..d1a6ac6ca --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_facet.py @@ -0,0 +1,225 @@ +"""Tests for $facet composing with other stages at different pipeline positions. + +Per FOLDER_STRUCTURE.md, interactions between $facet and adjacent stages live +in the parent stages/ directory. Covers stages before $facet (which shape the +shared input), stages after $facet (which consume facet output arrays), +consecutive $facet stages, $facet as a middle stage, and preservation of +index-sorted input order into sub-pipelines. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from pymongo.operations import IndexModel + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +DOCS = [ + {"_id": 1, "cat": "A", "v": 30, "tags": ["x", "y"]}, + {"_id": 2, "cat": "A", "v": 10, "tags": ["y"]}, + {"_id": 3, "cat": "B", "v": 20, "tags": ["z"]}, +] + +# Property [Position Interactions]: $facet composes correctly with adjacent +# stages, preserves pre-sorted and pre-filtered input, and allows later stages +# to consume its output arrays. +FACET_POSITION_TESTS: list[StageTestCase] = [ + StageTestCase( + id="first_stage_processes_all_documents", + docs=DOCS, + pipeline=[{"$facet": {"n": [{"$count": "n"}]}}], + expected=[{"n": [{"n": 3}]}], + msg="$facet as first stage should process all documents", + ), + StageTestCase( + id="match_before_facet_filters_subpipelines", + docs=DOCS, + pipeline=[{"$match": {"cat": "A"}}, {"$facet": {"n": [{"$count": "n"}]}}], + expected=[{"n": [{"n": 2}]}], + msg="$match before $facet should filter input to all sub-pipelines", + ), + StageTestCase( + id="sort_before_facet_preserves_order", + docs=DOCS, + pipeline=[ + {"$sort": {"v": 1}}, + {"$facet": {"docs": [{"$project": {"_id": 1}}]}}, + ], + expected=[{"docs": [{"_id": 2}, {"_id": 3}, {"_id": 1}]}], + msg="$sort before $facet should preserve document order into sub-pipelines", + ), + StageTestCase( + id="limit_before_facet_reduces_input", + docs=DOCS, + pipeline=[ + {"$sort": {"_id": 1}}, + {"$limit": 2}, + {"$facet": {"n": [{"$count": "n"}]}}, + ], + expected=[{"n": [{"n": 2}]}], + msg="$limit before $facet should reduce the input set", + ), + StageTestCase( + id="skip_before_facet_removes_documents", + docs=DOCS, + pipeline=[ + {"$sort": {"_id": 1}}, + {"$skip": 2}, + {"$facet": {"docs": [{"$project": {"_id": 1}}]}}, + ], + expected=[{"docs": [{"_id": 3}]}], + msg="$skip before $facet should remove skipped documents", + ), + StageTestCase( + id="unwind_before_facet_expands_documents", + docs=DOCS, + pipeline=[ + {"$unwind": "$tags"}, + {"$facet": {"n": [{"$count": "n"}]}}, + ], + expected=[{"n": [{"n": 4}]}], + msg="$unwind before $facet should expand input documents", + ), + StageTestCase( + id="group_before_facet_passes_grouped_documents", + docs=DOCS, + pipeline=[ + {"$group": {"_id": "$cat", "total": {"$sum": "$v"}}}, + {"$facet": {"grouped": [{"$sort": {"_id": 1}}]}}, + ], + expected=[{"grouped": [{"_id": "A", "total": 40}, {"_id": "B", "total": 20}]}], + msg="$group before $facet should pass grouped documents to sub-pipelines", + ), + StageTestCase( + id="addfields_before_facet_visible_in_subpipelines", + docs=DOCS, + pipeline=[ + {"$addFields": {"doubled": {"$multiply": ["$v", 2]}}}, + {"$facet": {"docs": [{"$match": {"_id": 1}}, {"$project": {"_id": 1, "doubled": 1}}]}}, + ], + expected=[{"docs": [{"_id": 1, "doubled": 60}]}], + msg="Fields added before $facet should be visible in sub-pipelines", + ), + StageTestCase( + id="project_before_facet_reduces_fields", + docs=DOCS, + pipeline=[ + {"$project": {"_id": 1, "cat": 1}}, + {"$facet": {"docs": [{"$match": {"_id": 1}}]}}, + ], + expected=[{"docs": [{"_id": 1, "cat": "A"}]}], + msg="$project before $facet should reduce fields seen by sub-pipelines", + ), + StageTestCase( + id="replaceroot_before_facet_visible_in_subpipelines", + docs=DOCS, + pipeline=[ + {"$match": {"_id": 1}}, + {"$replaceRoot": {"newRoot": {"only": "$cat"}}}, + {"$facet": {"docs": [{"$project": {"_id": 0, "only": 1}}]}}, + ], + expected=[{"docs": [{"only": "A"}]}], + msg="$replaceRoot before $facet should be visible to sub-pipelines", + ), + StageTestCase( + id="project_after_facet_references_output_arrays", + docs=DOCS, + pipeline=[ + {"$facet": {"a": [{"$match": {"cat": "A"}}], "b": [{"$match": {"cat": "B"}}]}}, + {"$project": {"a": 1}}, + ], + expected=[ + { + "a": [ + {"_id": 1, "cat": "A", "v": 30, "tags": ["x", "y"]}, + {"_id": 2, "cat": "A", "v": 10, "tags": ["y"]}, + ] + } + ], + msg="$project after $facet should reference facet output arrays", + ), + StageTestCase( + id="unwind_after_facet_deconstructs_output_array", + docs=DOCS, + pipeline=[ + {"$facet": {"a": [{"$match": {"cat": "A"}}, {"$sort": {"_id": 1}}]}}, + {"$unwind": "$a"}, + {"$project": {"_id": "$a._id"}}, + ], + expected=[{"_id": 1}, {"_id": 2}], + msg="$unwind after $facet should deconstruct the facet output array", + ), + StageTestCase( + id="addfields_after_facet_derives_from_output", + docs=DOCS, + pipeline=[ + {"$facet": {"a": [{"$match": {"cat": "A"}}]}}, + {"$addFields": {"count": {"$size": "$a"}}}, + {"$project": {"count": 1}}, + ], + expected=[{"count": 2}], + msg="$addFields after $facet should derive fields from output arrays", + ), + StageTestCase( + id="consecutive_facet_stages", + docs=DOCS, + pipeline=[ + {"$facet": {"a": [{"$match": {"cat": "A"}}]}}, + {"$facet": {"outer": [{"$project": {"n": {"$size": "$a"}}}]}}, + ], + expected=[{"outer": [{"n": 2}]}], + msg="A second $facet should receive the first $facet's single output document", + ), + StageTestCase( + id="facet_as_middle_stage", + docs=DOCS, + pipeline=[ + {"$match": {"cat": "A"}}, + {"$facet": {"a": [{"$sort": {"_id": 1}}]}}, + {"$project": {"first_id": {"$arrayElemAt": ["$a._id", 0]}}}, + ], + expected=[{"first_id": 1}], + msg="$facet as a middle stage should compose with surrounding stages", + ), + StageTestCase( + id="indexed_sort_before_facet_preserves_order", + docs=DOCS, + indexes=[IndexModel([("v", 1)])], + pipeline=[ + {"$sort": {"v": 1}}, + {"$facet": {"docs": [{"$project": {"_id": 1}}]}}, + ], + expected=[{"docs": [{"_id": 2}, {"_id": 3}, {"_id": 1}]}], + msg="Index-sorted order should be preserved into $facet sub-pipelines", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(FACET_POSITION_TESTS)) +def test_stages_position_facet(collection, test_case: StageTestCase): + """Test $facet composing with stages at different pipeline positions.""" + coll = populate_collection(collection, test_case) + command: dict[str, Any] = { + "aggregate": coll.name, + "pipeline": test_case.pipeline, + "cursor": {}, + } + command.update(test_case.extra_command_fields) + result = execute_command(coll, command) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ignore_doc_order=test_case.ignore_doc_order, + ignore_order_in=test_case.ignore_order_in, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/utils/stage_test_case.py b/documentdb_tests/compatibility/tests/core/operator/stages/utils/stage_test_case.py index 0fa6bb76e..d6a7eb7cc 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/utils/stage_test_case.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/utils/stage_test_case.py @@ -24,6 +24,9 @@ class StageTestCase(BaseTestCase): docs: list[dict[str, Any]] | None = None setup: Callable | None = None pipeline: list[dict[str, Any]] = field(default_factory=list) + ignore_doc_order: bool = False + ignore_order_in: list[str] | None = None + extra_command_fields: dict[str, Any] = field(default_factory=dict) def populate_collection(collection: Collection, test_case: StageTestCase) -> Collection: diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 78a86b0c1..2982b4052 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -291,6 +291,9 @@ COUNT_FIELD_DOLLAR_PREFIX_ERROR = 40158 COUNT_FIELD_NULL_BYTE_ERROR = 40159 COUNT_FIELD_DOT_ERROR = 40160 +FACET_SPEC_NOT_OBJECT_ERROR = 40169 +FACET_PIPELINE_NOT_ARRAY_ERROR = 40170 +FACET_PIPELINE_STAGE_NOT_OBJECT_ERROR = 40171 CONFLICTING_PATH_ERROR = 40176 MULTIPLE_EXPRESSIONS_ERROR = 40181 DOTTED_FIELD_IN_SUB_OBJECT_ERROR = 40183 From 9dff2140075b9d0016813f0fef8eb4ed5de8f1f5 Mon Sep 17 00:00:00 2001 From: PatersonProjects Date: Tue, 7 Jul 2026 13:11:07 -0700 Subject: [PATCH 6/6] Removed unneeded docstring note Signed-off-by: PatersonProjects --- .../operator/stages/facet/test_facet_argument_validation.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_argument_validation.py b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_argument_validation.py index 210ebbf79..70b58e26b 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_argument_validation.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_argument_validation.py @@ -3,12 +3,6 @@ Covers the positive TEST_COVERAGE.md §4 (Argument Handling) cases for $facet: valid edge cases such as empty sub-pipelines, many sub-pipelines, and unusual-but-valid output field names. - -Note: the "two sub-pipelines with the same output field name" spec item is not -tested here. A BSON document cannot carry two identical field names through the -driver -- the wire encoding collapses them to a single (last-wins) entry before -the command is sent -- so a genuine duplicate-output-field $facet cannot be -constructed via pymongo and the server never observes the duplicate. """ from __future__ import annotations