diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_basic.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_basic.py new file mode 100644 index 000000000..2556eef3e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_basic.py @@ -0,0 +1,195 @@ +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 +from documentdb_tests.framework.test_constants import ( + DECIMAL128_TWO_AND_HALF, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_TWO_AND_HALF, + DOUBLE_ZERO, + INT32_ZERO, +) + +# Property [Same-type arithmetic]: $subtract preserves the BSON type when both operands share +# a type. +SAME_TYPE_ARITHMETIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "same_type_int32", + doc={"a": 10, "b": 3}, + expression={"$subtract": ["$a", "$b"]}, + expected=7, + msg="$subtract should return int32 for int32 - int32", + ), + ExpressionTestCase( + "same_type_int64", + doc={"a": Int64(20), "b": Int64(5)}, + expression={"$subtract": ["$a", "$b"]}, + expected=Int64(15), + msg="$subtract should return int64 for int64 - int64", + ), + ExpressionTestCase( + "same_type_double", + doc={"a": 10.5, "b": DOUBLE_TWO_AND_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=8.0, + msg="$subtract should return double for double - double", + ), + ExpressionTestCase( + "same_type_decimal", + doc={"a": Decimal128("20.5"), "b": Decimal128("10.5")}, + expression={"$subtract": ["$a", "$b"]}, + expected=Decimal128("10.0"), + msg="$subtract should return Decimal128 for Decimal128 - Decimal128", + ), +] + +# Property [Mixed-type promotion]: $subtract promotes the result to the wider of the two input +# types. +MIXED_TYPE_PROMOTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_int64", + doc={"a": 10, "b": Int64(3)}, + expression={"$subtract": ["$a", "$b"]}, + expected=Int64(7), + msg="$subtract should promote to int64 for int32 - int64", + ), + ExpressionTestCase( + "int32_double", + doc={"a": 10, "b": DOUBLE_TWO_AND_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=7.5, + msg="$subtract should promote to double for int32 - double", + ), + ExpressionTestCase( + "int32_decimal", + doc={"a": 10, "b": DECIMAL128_TWO_AND_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=Decimal128("7.5"), + msg="$subtract should promote to Decimal128 for int32 - Decimal128", + ), + ExpressionTestCase( + "int64_double", + doc={"a": Int64(20), "b": 5.5}, + expression={"$subtract": ["$a", "$b"]}, + expected=14.5, + msg="$subtract should promote to double for int64 - double", + ), + ExpressionTestCase( + "int64_decimal", + doc={"a": Int64(20), "b": Decimal128("5.5")}, + expression={"$subtract": ["$a", "$b"]}, + expected=Decimal128("14.5"), + msg="$subtract should promote to Decimal128 for int64 - Decimal128", + ), + ExpressionTestCase( + "double_decimal", + doc={"a": 10.5, "b": DECIMAL128_TWO_AND_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=Decimal128("8.0000000000000"), + msg="$subtract should promote to Decimal128 for double - Decimal128", + ), +] + +# Property [Sign handling]: $subtract correctly computes the sign of the result. +SIGN_HANDLING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "negative_positive", + doc={"a": -10, "b": 3}, + expression={"$subtract": ["$a", "$b"]}, + expected=-13, + msg="$subtract should return negative for negative minuend minus positive subtrahend", + ), + ExpressionTestCase( + "positive_negative", + doc={"a": 10, "b": -3}, + expression={"$subtract": ["$a", "$b"]}, + expected=13, + msg="$subtract should return positive for positive minuend minus negative subtrahend", + ), + ExpressionTestCase( + "both_negative", + doc={"a": -10, "b": -3}, + expression={"$subtract": ["$a", "$b"]}, + expected=-7, + msg="$subtract should return negative when both operands are negative and |a| > |b|", + ), + ExpressionTestCase( + "result_negative", + doc={"a": 5, "b": 10}, + expression={"$subtract": ["$a", "$b"]}, + expected=-5, + msg="$subtract should return negative when the minuend is smaller than the subtrahend", + ), + ExpressionTestCase( + "result_negative_double", + doc={"a": 5.5, "b": 10}, + expression={"$subtract": ["$a", "$b"]}, + expected=-4.5, + msg="$subtract should return negative double when minuend is smaller than subtrahend", + ), +] + +# Property [Zero handling]: $subtract handles zero operands correctly. +ZERO_HANDLING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_minuend", + doc={"a": INT32_ZERO, "b": 5}, + expression={"$subtract": ["$a", "$b"]}, + expected=-5, + msg="$subtract should return negated subtrahend when the minuend is zero", + ), + ExpressionTestCase( + "zero_subtrahend", + doc={"a": 5, "b": INT32_ZERO}, + expression={"$subtract": ["$a", "$b"]}, + expected=5, + msg="$subtract should return the minuend unchanged when the subtrahend is zero", + ), + ExpressionTestCase( + "zeros", + doc={"a": INT32_ZERO, "b": INT32_ZERO}, + expression={"$subtract": ["$a", "$b"]}, + expected=INT32_ZERO, + msg="$subtract should return zero for zero - zero", + ), + ExpressionTestCase( + "zero_negative_zero", + doc={"a": INT32_ZERO, "b": DOUBLE_NEGATIVE_ZERO}, + expression={"$subtract": ["$a", "$b"]}, + expected=DOUBLE_ZERO, + msg="$subtract of int32 zero minus double negative-zero should return double positive-zero", + ), + ExpressionTestCase( + "negative_zero_zero", + doc={"a": DOUBLE_NEGATIVE_ZERO, "b": INT32_ZERO}, + expression={"$subtract": ["$a", "$b"]}, + expected=DOUBLE_NEGATIVE_ZERO, + msg="$subtract of double negative-zero minus int32 zero should return double negative-zero", + ), +] + +SUBTRACT_BASIC_TESTS: list[ExpressionTestCase] = ( + SAME_TYPE_ARITHMETIC_TESTS + + MIXED_TYPE_PROMOTION_TESTS + + SIGN_HANDLING_TESTS + + ZERO_HANDLING_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(SUBTRACT_BASIC_TESTS)) +def test_subtract_basic(collection, test_case: ExpressionTestCase): + """Test $subtract same-type and mixed-type numeric arithmetic.""" + 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/subtract/test_subtract_boundaries.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_boundaries.py new file mode 100644 index 000000000..405e382e6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_boundaries.py @@ -0,0 +1,415 @@ +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 +from documentdb_tests.framework.test_constants import ( + DECIMAL128_HALF, + DECIMAL128_JUST_ABOVE_HALF, + DECIMAL128_JUST_BELOW_HALF, + DECIMAL128_LARGE_EXPONENT, + DECIMAL128_MANY_TRAILING_ZEROS, + DECIMAL128_MAX, + DECIMAL128_MIN, + DECIMAL128_NEGATIVE_HALF, + DECIMAL128_NEGATIVE_ONE_AND_HALF, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_SMALL_EXPONENT, + DECIMAL128_TRAILING_ZERO, + DECIMAL128_TWO_AND_HALF, + DECIMAL128_ZERO, + DOUBLE_FROM_INT64_MAX, + DOUBLE_HALF, + DOUBLE_JUST_ABOVE_HALF, + DOUBLE_JUST_BELOW_HALF, + DOUBLE_MAX_SAFE_INTEGER, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEAR_MAX, + DOUBLE_NEAR_MIN, + DOUBLE_NEGATIVE_HALF, + DOUBLE_NEGATIVE_ONE_AND_HALF, + DOUBLE_ONE_AND_HALF, + DOUBLE_TWO_AND_HALF, + FLOAT_INFINITY, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MAX_MINUS_1, + INT32_MIN, + INT32_MIN_PLUS_1, + INT32_OVERFLOW, + INT32_UNDERFLOW, + INT32_ZERO, + INT64_MAX, + INT64_MAX_MINUS_1, + INT64_MIN, + INT64_MIN_PLUS_1, +) + +# Property [Int32 overflow]: $subtract promotes int32 results to int64 on overflow/underflow. +INT32_OVERFLOW_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_overflow", + doc={"a": INT32_MAX, "b": -1}, + expression={"$subtract": ["$a", "$b"]}, + expected=Int64(INT32_OVERFLOW), + msg="$subtract should promote to int64 when the int32 result exceeds INT32_MAX", + ), + ExpressionTestCase( + "int32_underflow", + doc={"a": INT32_MIN, "b": 1}, + expression={"$subtract": ["$a", "$b"]}, + expected=Int64(INT32_UNDERFLOW), + msg="$subtract should promote to int64 when the int32 result is below INT32_MIN", + ), + # Int32 boundary values + ExpressionTestCase( + "int32_max_minuend", + doc={"a": INT32_MAX, "b": 10}, + expression={"$subtract": ["$a", "$b"]}, + expected=2147483637, + msg="$subtract should correctly subtract from INT32_MAX", + ), + ExpressionTestCase( + "int32_max_minus_1_minuend", + doc={"a": INT32_MAX_MINUS_1, "b": 10}, + expression={"$subtract": ["$a", "$b"]}, + expected=2147483636, + msg="$subtract should correctly subtract from INT32_MAX - 1", + ), + ExpressionTestCase( + "int32_min_minuend", + doc={"a": INT32_MIN, "b": 10}, + expression={"$subtract": ["$a", "$b"]}, + expected=Int64(-2147483658), + msg="$subtract should promote to int64 when subtracting from INT32_MIN", + ), + ExpressionTestCase( + "int32_min_plus_1_minuend", + doc={"a": INT32_MIN_PLUS_1, "b": 10}, + expression={"$subtract": ["$a", "$b"]}, + expected=Int64(-2147483657), + msg="$subtract should promote to int64 when subtracting from INT32_MIN + 1", + ), + ExpressionTestCase( + "int32_max_subtrahend", + doc={"a": 10, "b": INT32_MAX}, + expression={"$subtract": ["$a", "$b"]}, + expected=-2147483637, + msg="$subtract should correctly subtract INT32_MAX as the subtrahend", + ), + ExpressionTestCase( + "int32_min_subtrahend", + doc={"a": 10, "b": INT32_MIN}, + expression={"$subtract": ["$a", "$b"]}, + expected=Int64(2147483658), + msg="$subtract should promote to int64 when INT32_MIN is the subtrahend", + ), +] + +# Property [Int64 overflow]: $subtract promotes int64 results to double on overflow/underflow. +INT64_OVERFLOW_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int64_overflow", + doc={"a": INT64_MAX, "b": -1}, + expression={"$subtract": ["$a", "$b"]}, + expected=DOUBLE_FROM_INT64_MAX, + msg="$subtract should promote to double when the int64 result exceeds INT64_MAX", + ), + ExpressionTestCase( + "int64_underflow", + doc={"a": INT64_MIN, "b": 1}, + expression={"$subtract": ["$a", "$b"]}, + expected=-DOUBLE_FROM_INT64_MAX, + msg="$subtract should promote to double when the int64 result is below INT64_MIN", + ), + # Int64 boundary values + ExpressionTestCase( + "int64_max_minuend", + doc={"a": INT64_MAX, "b": Int64(10)}, + expression={"$subtract": ["$a", "$b"]}, + expected=Int64(9223372036854775797), + msg="$subtract should correctly subtract from INT64_MAX", + ), + ExpressionTestCase( + "int64_max_minus_1_minuend", + doc={"a": INT64_MAX_MINUS_1, "b": Int64(10)}, + expression={"$subtract": ["$a", "$b"]}, + expected=Int64(9223372036854775796), + msg="$subtract should correctly subtract from INT64_MAX - 1", + ), + ExpressionTestCase( + "int64_min_minuend", + doc={"a": INT64_MIN, "b": Int64(10)}, + expression={"$subtract": ["$a", "$b"]}, + expected=-DOUBLE_FROM_INT64_MAX, + msg="$subtract should overflow to double when subtracting from INT64_MIN", + ), + ExpressionTestCase( + "int64_min_plus_1_minuend", + doc={"a": INT64_MIN_PLUS_1, "b": Int64(10)}, + expression={"$subtract": ["$a", "$b"]}, + expected=-DOUBLE_FROM_INT64_MAX, + msg="$subtract should overflow to double when subtracting from INT64_MIN + 1", + ), + ExpressionTestCase( + "int64_max_subtrahend", + doc={"a": Int64(10), "b": INT64_MAX}, + expression={"$subtract": ["$a", "$b"]}, + expected=Int64(-9223372036854775797), + msg="$subtract should correctly subtract INT64_MAX as the subtrahend", + ), + ExpressionTestCase( + "int64_min_subtrahend", + doc={"a": Int64(10), "b": INT64_MIN}, + expression={"$subtract": ["$a", "$b"]}, + expected=DOUBLE_FROM_INT64_MAX, + msg="$subtract should overflow to double when INT64_MIN is the subtrahend", + ), +] + +# Property [Double overflow]: $subtract returns ±Infinity on double overflow. +DOUBLE_OVERFLOW_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "double_overflow", + doc={"a": DOUBLE_NEAR_MAX, "b": -DOUBLE_NEAR_MAX}, + expression={"$subtract": ["$a", "$b"]}, + expected=FLOAT_INFINITY, + msg="$subtract should return Infinity on double overflow", + ), + ExpressionTestCase( + "double_underflow", + doc={"a": -DOUBLE_NEAR_MAX, "b": DOUBLE_NEAR_MAX}, + expression={"$subtract": ["$a", "$b"]}, + expected=FLOAT_NEGATIVE_INFINITY, + msg="$subtract should return -Infinity on double underflow", + ), + # Double boundary values + ExpressionTestCase( + "double_min_subnormal_minuend", + doc={"a": DOUBLE_MIN_SUBNORMAL, "b": INT32_ZERO}, + expression={"$subtract": ["$a", "$b"]}, + expected=DOUBLE_MIN_SUBNORMAL, + msg="$subtract should handle the minimum subnormal double value", + ), + ExpressionTestCase( + "double_near_min_minuend", + doc={"a": DOUBLE_NEAR_MIN, "b": INT32_ZERO}, + expression={"$subtract": ["$a", "$b"]}, + expected=DOUBLE_NEAR_MIN, + msg="$subtract should handle doubles near the minimum normal value", + ), + ExpressionTestCase( + "double_near_max_minuend", + doc={"a": DOUBLE_NEAR_MAX, "b": 1e307}, + expression={"$subtract": ["$a", "$b"]}, + expected=9e307, + msg="$subtract should handle doubles near the maximum value", + ), + ExpressionTestCase( + "double_max_safe_integer_minuend", + doc={"a": float(DOUBLE_MAX_SAFE_INTEGER), "b": 1}, + expression={"$subtract": ["$a", "$b"]}, + expected=9007199254740991.0, + msg="$subtract should handle the maximum safe integer double as the minuend", + ), + ExpressionTestCase( + "double_max_safe_integer_subtrahend", + doc={"a": 1, "b": float(DOUBLE_MAX_SAFE_INTEGER)}, + expression={"$subtract": ["$a", "$b"]}, + expected=-9007199254740991.0, + msg="$subtract should handle the maximum safe integer double as the subtrahend", + ), +] + +# Property [Decimal128 precision]: $subtract preserves Decimal128 full precision. +DECIMAL128_PRECISION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal128_max_minuend", + doc={"a": DECIMAL128_MAX, "b": Decimal128("1")}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_MAX, + msg="$subtract should handle DECIMAL128_MAX as the minuend", + ), + ExpressionTestCase( + "decimal128_min_minuend", + doc={"a": DECIMAL128_MIN, "b": Decimal128("1")}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_MIN, + msg="$subtract should handle DECIMAL128_MIN as the minuend", + ), + ExpressionTestCase( + "decimal128_small_exponent_minuend", + doc={"a": DECIMAL128_SMALL_EXPONENT, "b": DECIMAL128_ZERO}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_SMALL_EXPONENT, + msg="$subtract should handle Decimal128 with a small exponent", + ), + ExpressionTestCase( + "decimal128_large_exponent_minuend", + doc={"a": DECIMAL128_LARGE_EXPONENT, "b": Decimal128("1")}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_LARGE_EXPONENT, + msg="$subtract should handle Decimal128 with a large exponent", + ), + ExpressionTestCase( + "decimal128_trailing_zero", + doc={"a": DECIMAL128_TRAILING_ZERO, "b": DECIMAL128_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_HALF, + msg="$subtract should handle Decimal128 with a trailing zero", + ), + ExpressionTestCase( + "decimal128_many_trailing_zeros", + doc={"a": DECIMAL128_MANY_TRAILING_ZEROS, "b": DECIMAL128_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=Decimal128("0.50000000000000000000000000000000"), + msg="$subtract should handle Decimal128 with many trailing zeros", + ), + # Decimal128 precision + ExpressionTestCase( + "decimal_precision", + doc={"a": Decimal128("10.5"), "b": DECIMAL128_TWO_AND_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=Decimal128("8.0"), + msg="$subtract should preserve Decimal128 precision", + ), + ExpressionTestCase( + "decimal_precision_small", + doc={"a": Decimal128("0.3"), "b": Decimal128("0.1")}, + expression={"$subtract": ["$a", "$b"]}, + expected=Decimal128("0.2"), + msg="$subtract should compute 0.3 - 0.1 exactly in Decimal128 without floating-point error", + ), + ExpressionTestCase( + "decimal_large_precision", + doc={ + "a": Decimal128("1000000000000000000000000000000000"), + "b": Decimal128("1"), + }, + expression={"$subtract": ["$a", "$b"]}, + expected=Decimal128("999999999999999999999999999999999"), + msg="$subtract should handle large Decimal128 values with full precision", + ), + # Double rounding edge cases + ExpressionTestCase( + "double_half_minuend", + doc={"a": DOUBLE_HALF, "b": 0.25}, + expression={"$subtract": ["$a", "$b"]}, + expected=0.25, + msg="$subtract should correctly compute 0.5 - 0.25 = 0.25", + ), + ExpressionTestCase( + "double_one_and_half_minuend", + doc={"a": DOUBLE_ONE_AND_HALF, "b": DOUBLE_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=1.0, + msg="$subtract should correctly compute 1.5 - 0.5 = 1.0", + ), + ExpressionTestCase( + "double_two_and_half_minuend", + doc={"a": DOUBLE_TWO_AND_HALF, "b": DOUBLE_ONE_AND_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=1.0, + msg="$subtract should correctly compute 2.5 - 1.5 = 1.0", + ), + ExpressionTestCase( + "double_negative_half_minuend", + doc={"a": DOUBLE_NEGATIVE_HALF, "b": DOUBLE_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=-1.0, + msg="$subtract should correctly compute -0.5 - 0.5 = -1.0", + ), + ExpressionTestCase( + "double_negative_one_and_half_minuend", + doc={"a": DOUBLE_NEGATIVE_ONE_AND_HALF, "b": DOUBLE_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=-2.0, + msg="$subtract should correctly compute -1.5 - 0.5 = -2.0", + ), + ExpressionTestCase( + "double_just_below_half_minuend", + doc={"a": DOUBLE_JUST_BELOW_HALF, "b": 0.25}, + expression={"$subtract": ["$a", "$b"]}, + expected=pytest.approx(0.2499999999999994), + msg="$subtract should handle a double just below 0.5", + ), + ExpressionTestCase( + "double_just_above_half_minuend", + doc={"a": DOUBLE_JUST_ABOVE_HALF, "b": 0.25}, + expression={"$subtract": ["$a", "$b"]}, + expected=pytest.approx(0.250000001), + msg="$subtract should handle a double just above 0.5", + ), + # Decimal128 rounding edge cases + ExpressionTestCase( + "decimal_half_minuend", + doc={"a": DECIMAL128_HALF, "b": Decimal128("0.25")}, + expression={"$subtract": ["$a", "$b"]}, + expected=Decimal128("0.25"), + msg="$subtract should correctly compute Decimal128 0.5 - 0.25 = 0.25", + ), + ExpressionTestCase( + "decimal_one_and_half_minuend", + doc={"a": DECIMAL128_ONE_AND_HALF, "b": DECIMAL128_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_TRAILING_ZERO, + msg="$subtract should correctly compute Decimal128 1.5 - 0.5 = 1.0", + ), + ExpressionTestCase( + "decimal_two_and_half_minuend", + doc={"a": DECIMAL128_TWO_AND_HALF, "b": DECIMAL128_ONE_AND_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_TRAILING_ZERO, + msg="$subtract should correctly compute Decimal128 2.5 - 1.5 = 1.0", + ), + ExpressionTestCase( + "decimal_negative_half_minuend", + doc={"a": DECIMAL128_NEGATIVE_HALF, "b": DECIMAL128_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=Decimal128("-1.0"), + msg="$subtract should correctly compute Decimal128 -0.5 - 0.5 = -1.0", + ), + ExpressionTestCase( + "decimal_negative_one_and_half_minuend", + doc={"a": DECIMAL128_NEGATIVE_ONE_AND_HALF, "b": DECIMAL128_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=Decimal128("-2.0"), + msg="$subtract should correctly compute Decimal128 -1.5 - 0.5 = -2.0", + ), + ExpressionTestCase( + "decimal_just_below_half_minuend", + doc={"a": DECIMAL128_JUST_BELOW_HALF, "b": Decimal128("0.25")}, + expression={"$subtract": ["$a", "$b"]}, + expected=Decimal128("0.2499999999999999999999999999999999"), + msg="$subtract should handle Decimal128 just below 0.5", + ), + ExpressionTestCase( + "decimal_just_above_half_minuend", + doc={"a": DECIMAL128_JUST_ABOVE_HALF, "b": Decimal128("0.25")}, + expression={"$subtract": ["$a", "$b"]}, + expected=Decimal128("0.2500000000000000000000000000000001"), + msg="$subtract should handle Decimal128 just above 0.5", + ), +] + +SUBTRACT_BOUNDARY_TESTS: list[ExpressionTestCase] = ( + INT32_OVERFLOW_TESTS + INT64_OVERFLOW_TESTS + DOUBLE_OVERFLOW_TESTS + DECIMAL128_PRECISION_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(SUBTRACT_BOUNDARY_TESTS)) +def test_subtract_boundaries(collection, test_case: ExpressionTestCase): + """Test $subtract boundary values, overflow behavior, and numeric precision.""" + 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/subtract/test_subtract_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_bson_types.py new file mode 100644 index 000000000..a29653bdb --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_bson_types.py @@ -0,0 +1,70 @@ +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.assertions import assertNotError +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) + +# Property [Minuend type acceptance]: $subtract accepts numeric and date types as minuend. +# Property [Minuend type rejection]: $subtract rejects all other BSON types as minuend. +SUBTRACT_MINUEND_SPEC = BsonTypeTestCase( + id="subtract_minuend", + msg="$subtract minuend type", + valid_types=[ + BsonType.DOUBLE, + BsonType.INT, + BsonType.LONG, + BsonType.DECIMAL, + BsonType.DATE, + BsonType.NULL, + ], +) + +# Property [Subtrahend type acceptance]: $subtract accepts numeric types as subtrahend. +# Property [Subtrahend type rejection]: $subtract rejects all other BSON types as subtrahend, +# including Date when the minuend is numeric. +SUBTRACT_SUBTRAHEND_SPEC = BsonTypeTestCase( + id="subtract_subtrahend", + msg="$subtract subtrahend type", + valid_types=[BsonType.DOUBLE, BsonType.INT, BsonType.LONG, BsonType.DECIMAL, BsonType.NULL], +) + +MINUEND_REJECTION_CASES = generate_bson_rejection_test_cases([SUBTRACT_MINUEND_SPEC]) +MINUEND_ACCEPTANCE_CASES = generate_bson_acceptance_test_cases([SUBTRACT_MINUEND_SPEC]) +SUBTRAHEND_REJECTION_CASES = generate_bson_rejection_test_cases([SUBTRACT_SUBTRAHEND_SPEC]) +SUBTRAHEND_ACCEPTANCE_CASES = generate_bson_acceptance_test_cases([SUBTRACT_SUBTRAHEND_SPEC]) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", MINUEND_REJECTION_CASES) +def test_subtract_rejects_invalid_minuend(collection, bson_type, sample_value, spec): + """Test $subtract rejects invalid BSON types as the minuend.""" + result = execute_expression_with_insert(collection, {"$subtract": [sample_value, 1]}, {}) + assert_expression_result(result, error_code=spec.expected_code(bson_type)) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", MINUEND_ACCEPTANCE_CASES) +def test_subtract_accepts_valid_minuend(collection, bson_type, sample_value, spec): + """Test $subtract accepts valid BSON types (numeric and date) as the minuend.""" + result = execute_expression_with_insert(collection, {"$subtract": [sample_value, 1]}, {}) + assertNotError(result, msg=f"{spec.msg} should accept {bson_type.value}") + + +@pytest.mark.parametrize("bson_type,sample_value,spec", SUBTRAHEND_REJECTION_CASES) +def test_subtract_rejects_invalid_subtrahend(collection, bson_type, sample_value, spec): + """Test $subtract rejects invalid BSON types as the subtrahend.""" + result = execute_expression_with_insert(collection, {"$subtract": [10, sample_value]}, {}) + assert_expression_result(result, error_code=spec.expected_code(bson_type)) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", SUBTRAHEND_ACCEPTANCE_CASES) +def test_subtract_accepts_valid_subtrahend(collection, bson_type, sample_value, spec): + """Test $subtract accepts valid BSON types (numeric) as the subtrahend.""" + result = execute_expression_with_insert(collection, {"$subtract": [10, sample_value]}, {}) + assertNotError(result, msg=f"{spec.msg} should accept {bson_type.value}") diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_dates.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_dates.py new file mode 100644 index 000000000..0a577fe7e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_dates.py @@ -0,0 +1,160 @@ +from datetime import datetime, timezone + +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 ( + DECIMAL128_ONE_AND_HALF, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_TWO_AND_HALF, + INT32_ZERO, +) + +# Property [Date - numeric]: $subtract returns a date when the minuend is a date and subtrahend +# is numeric (milliseconds). +DATE_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_int32", + doc={ + "a": datetime(2026, 1, 2, 0, 0, 0, tzinfo=timezone.utc), + "b": 86400000, + }, + expression={"$subtract": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$subtract should subtract int32 milliseconds from a date", + ), + ExpressionTestCase( + "date_int64", + doc={ + "a": datetime(2026, 1, 2, 0, 0, 0, tzinfo=timezone.utc), + "b": Int64(86400000), + }, + expression={"$subtract": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$subtract should subtract int64 milliseconds from a date", + ), + # Date minus negative number (moves date forward) + ExpressionTestCase( + "date_negative", + doc={ + "a": datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + "b": -86400000, + }, + expression={"$subtract": ["$a", "$b"]}, + expected=datetime(2026, 1, 2, 0, 0, 0, tzinfo=timezone.utc), + msg="$subtract should move a date forward when subtracting a negative millisecond offset", + ), + ExpressionTestCase( + "date_zero", + doc={ + "a": datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + "b": INT32_ZERO, + }, + expression={"$subtract": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$subtract should return the same date when subtracting zero milliseconds", + ), + ExpressionTestCase( + "date_negative_zero", + doc={ + "a": datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + "b": DOUBLE_NEGATIVE_ZERO, + }, + expression={"$subtract": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$subtract should return the same date when subtracting negative-zero milliseconds", + ), +] + +# Property [Date - date]: $subtract returns the difference in milliseconds when both operands +# are dates. +DATE_DATE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "two_dates", + doc={ + "a": datetime(2026, 1, 2, 0, 0, 0, tzinfo=timezone.utc), + "b": datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + }, + expression={"$subtract": ["$a", "$b"]}, + expected=Int64(86400000), + msg="$subtract of two dates should return the difference in milliseconds", + ), + ExpressionTestCase( + "two_dates_negative", + doc={ + "a": datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + "b": datetime(2026, 1, 2, 0, 0, 0, tzinfo=timezone.utc), + }, + expression={"$subtract": ["$a", "$b"]}, + expected=Int64(-86400000), + msg="$subtract of two dates should return negative ms when the minuend date is earlier", + ), +] + +# Property [Date rounding]: fractional ms operands are rounded. +DATE_ROUNDING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_decimal", + doc={ + "a": datetime(2026, 1, 1, 0, 0, 1, tzinfo=timezone.utc), + "b": DECIMAL128_ONE_AND_HALF, + }, + expression={"$subtract": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, 998000, tzinfo=timezone.utc), + msg="$subtract should round Decimal128 1.5 ms to 2 ms before subtracting", + ), + ExpressionTestCase( + "date_double_round_up", + doc={ + "a": datetime(2026, 1, 1, 0, 0, 0, 3000, tzinfo=timezone.utc), + "b": DOUBLE_TWO_AND_HALF, + }, + expression={"$subtract": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$subtract should round double 2.5 ms up to 3 ms before subtracting", + ), + ExpressionTestCase( + "date_double_round_down_negative", + doc={ + "a": datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + "b": -2.5, + }, + expression={"$subtract": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, 3000, tzinfo=timezone.utc), + msg="$subtract should round double -2.5 ms down to -3 ms before subtracting", + ), + ExpressionTestCase( + "date_double_truncates", + doc={ + "a": datetime(2026, 1, 1, 0, 0, 0, 5000, tzinfo=timezone.utc), + "b": 4.4, + }, + expression={"$subtract": ["$a", "$b"]}, + expected=datetime(2026, 1, 1, 0, 0, 0, 1000, tzinfo=timezone.utc), + msg="$subtract should round double 4.4 ms to 4 ms before subtracting from a date", + ), +] + +SUBTRACT_DATE_TESTS: list[ExpressionTestCase] = ( + DATE_NUMERIC_TESTS + DATE_DATE_TESTS + DATE_ROUNDING_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(SUBTRACT_DATE_TESTS)) +def test_subtract_dates(collection, test_case: ExpressionTestCase): + """Test $subtract date arithmetic: date minus numeric and date minus date.""" + 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/subtract/test_subtract_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_errors.py new file mode 100644 index 000000000..23f44658d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_errors.py @@ -0,0 +1,121 @@ +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.error_codes import ( + EXPRESSION_TYPE_MISMATCH_ERROR, + OVERFLOW_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NAN, + FLOAT_INFINITY, + FLOAT_NAN, +) + +# Property [Date constraint]: $subtract enforces date arithmetic type rules. +DATE_CONSTRAINT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "number_minus_date", + doc={"a": 1, "b": datetime(2026, 1, 1, tzinfo=timezone.utc)}, + expression={"$subtract": ["$a", "$b"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="$subtract should reject a date subtrahend when the minuend is a number", + ), + ExpressionTestCase( + "date_minus_string", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": "string"}, + expression={"$subtract": ["$a", "$b"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="$subtract should reject a string subtrahend when the minuend is a date", + ), + ExpressionTestCase( + "date_minus_bool", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": True}, + expression={"$subtract": ["$a", "$b"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="$subtract should reject a boolean subtrahend when the minuend is a date", + ), + ExpressionTestCase( + "date_minus_array", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": [1, 2]}, + expression={"$subtract": ["$a", "$b"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="$subtract should reject an array subtrahend when the minuend is a date", + ), +] + +# Property [Date NaN/Inf]: $subtract rejects NaN/Infinity as a date offset with +# TYPE_MISMATCH_DATE_ERROR. +DATE_NAN_INF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_minus_nan", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": FLOAT_NAN}, + expression={"$subtract": ["$a", "$b"]}, + error_code=OVERFLOW_ERROR, + msg="$subtract should reject NaN as a date millisecond offset", + ), + ExpressionTestCase( + "date_minus_infinity", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": FLOAT_INFINITY}, + expression={"$subtract": ["$a", "$b"]}, + error_code=OVERFLOW_ERROR, + msg="$subtract should reject Infinity as a date millisecond offset", + ), + ExpressionTestCase( + "date_minus_decimal_nan", + doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": DECIMAL128_NAN}, + expression={"$subtract": ["$a", "$b"]}, + error_code=OVERFLOW_ERROR, + msg="$subtract should reject Decimal128 NaN as a date millisecond offset", + ), +] + +# Property [Arity]: $subtract requires exactly two arguments. +ARITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_args", + doc={}, + expression={"$subtract": []}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$subtract should reject an empty argument list", + ), + ExpressionTestCase( + "one_arg", + doc={}, + expression={"$subtract": [1]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$subtract should reject a single argument", + ), + ExpressionTestCase( + "three_args", + doc={}, + expression={"$subtract": [1, 2, 3]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$subtract should reject three arguments", + ), +] + +SUBTRACT_ERROR_TESTS: list[ExpressionTestCase] = ( + DATE_CONSTRAINT_TESTS + DATE_NAN_INF_TESTS + ARITY_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(SUBTRACT_ERROR_TESTS)) +def test_subtract_errors(collection, test_case: ExpressionTestCase): + """Test $subtract error handling for invalid types, date constraints, and arity violations.""" + 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/subtract/test_subtract_input_forms.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_input_forms.py new file mode 100644 index 000000000..dbb9d5c27 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_input_forms.py @@ -0,0 +1,102 @@ +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.error_codes import EXPRESSION_TYPE_MISMATCH_ERROR +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import INT32_ZERO + +# Property [Nested expressions]: $subtract accepts other expressions as operands. +NESTED_EXPRESSION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_subtract_2", + doc={}, + expression={"$subtract": [{"$subtract": [10, 3]}, 2]}, + expected=5, + msg="$subtract should accept a nested $subtract expression as the minuend", + ), + ExpressionTestCase( + "nested_subtract_3", + doc={}, + expression={"$subtract": [{"$subtract": [{"$subtract": [100, 10]}, 20]}, 30]}, + expected=40, + msg="$subtract should support three levels of nested $subtract expressions", + ), +] + +# Property [Field path lookups]: $subtract resolves nested and deeply-nested field paths. +FIELD_PATH_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field", + doc={"a": {"b": 10}, "c": 3}, + expression={"$subtract": ["$a.b", "$c"]}, + expected=7, + msg="$subtract should resolve a nested field path such as $a.b", + ), + ExpressionTestCase( + "nonexistent_field", + doc={"a": {"missing": 1}, "b": 5}, + expression={"$subtract": ["$a.nonexistent", "$b"]}, + expected=None, + msg="$subtract should return null when a field path resolves to a missing field", + ), + ExpressionTestCase( + "deeply_nested_field", + doc={"a": {"b": {"c": {"d": 20}}}}, + expression={"$subtract": ["$a.b.c.d", 8]}, + expected=12, + msg="$subtract should resolve a deeply nested field path", + ), +] + +# Property [Array element access]: $subtract works with $arrayElemAt expressions as operands. +ARRAY_ELEMENT_ACCESS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "array_index", + doc={"arr": [10, 5, 2]}, + expression={ + "$subtract": [ + {"$arrayElemAt": ["$arr", INT32_ZERO]}, + {"$arrayElemAt": ["$arr", 1]}, + ] + }, + expected=5, + msg="$subtract should support $arrayElemAt expressions as operands", + ), +] + +# Property [Composite array rejection]: $subtract rejects a composite array from $x.y on +# an array-of-objects. +COMPOSITE_ARRAY_REJECTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "composite_array_field", + doc={"x": [{"y": 10}, {"y": 3}]}, + expression={"$subtract": "$x.y"}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$subtract should reject a composite array produced by $x.y on an array-of-objects", + ), +] + +SUBTRACT_INPUT_FORM_TESTS: list[ExpressionTestCase] = ( + NESTED_EXPRESSION_TESTS + + FIELD_PATH_LOOKUP_TESTS + + ARRAY_ELEMENT_ACCESS_TESTS + + COMPOSITE_ARRAY_REJECTION_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(SUBTRACT_INPUT_FORM_TESTS)) +def test_subtract_input_forms(collection, test_case: ExpressionTestCase): + """Test $subtract with various expression input forms and field path lookups.""" + 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/subtract/test_subtract_non_finite.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_non_finite.py new file mode 100644 index 000000000..987660216 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_non_finite.py @@ -0,0 +1,185 @@ +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, + DOUBLE_ONE_AND_HALF, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_ZERO, +) + +# Property [Infinity propagation]: $subtract propagates Infinity when one operand is infinite. +INFINITY_PROPAGATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "infinity_minuend", + doc={"a": FLOAT_INFINITY, "b": 1}, + expression={"$subtract": ["$a", "$b"]}, + expected=FLOAT_INFINITY, + msg="$subtract of Infinity minus a number should return Infinity", + ), + ExpressionTestCase( + "negative_infinity_minuend", + doc={"a": FLOAT_NEGATIVE_INFINITY, "b": 1}, + expression={"$subtract": ["$a", "$b"]}, + expected=FLOAT_NEGATIVE_INFINITY, + msg="$subtract of -Infinity minus a number should return -Infinity", + ), + ExpressionTestCase( + "infinity_subtrahend", + doc={"a": 1, "b": FLOAT_INFINITY}, + expression={"$subtract": ["$a", "$b"]}, + expected=FLOAT_NEGATIVE_INFINITY, + msg="$subtract of a number minus Infinity should return -Infinity", + ), + ExpressionTestCase( + "negative_infinity_subtrahend", + doc={"a": 1, "b": FLOAT_NEGATIVE_INFINITY}, + expression={"$subtract": ["$a", "$b"]}, + expected=FLOAT_INFINITY, + msg="$subtract of a number minus -Infinity should return Infinity", + ), + ExpressionTestCase( + "inf_minus_zero", + doc={"a": FLOAT_INFINITY, "b": INT32_ZERO}, + expression={"$subtract": ["$a", "$b"]}, + expected=FLOAT_INFINITY, + msg="$subtract of Infinity minus zero should return Infinity", + ), + ExpressionTestCase( + "neg_inf_minus_zero", + doc={"a": FLOAT_NEGATIVE_INFINITY, "b": INT32_ZERO}, + expression={"$subtract": ["$a", "$b"]}, + expected=FLOAT_NEGATIVE_INFINITY, + msg="$subtract of -Infinity minus zero should return -Infinity", + ), + ExpressionTestCase( + "inf_minus_inf", + doc={"a": FLOAT_INFINITY, "b": FLOAT_INFINITY}, + expression={"$subtract": ["$a", "$b"]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="$subtract of Infinity minus Infinity should return NaN", + ), + ExpressionTestCase( + "neg_inf_minus_neg_inf", + doc={"a": FLOAT_NEGATIVE_INFINITY, "b": FLOAT_NEGATIVE_INFINITY}, + expression={"$subtract": ["$a", "$b"]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="$subtract of -Infinity minus -Infinity should return NaN", + ), + # Decimal128 Infinity + ExpressionTestCase( + "decimal_infinity_minuend", + doc={"a": DECIMAL128_INFINITY, "b": 1}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_INFINITY, + msg="$subtract of Decimal128 Infinity minus a number should return Decimal128 Infinity", + ), + ExpressionTestCase( + "decimal_negative_infinity_minuend", + doc={"a": DECIMAL128_NEGATIVE_INFINITY, "b": 1}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_NEGATIVE_INFINITY, + msg="$subtract of Decimal128 -Infinity minus a number should return Decimal128 -Infinity", + ), + ExpressionTestCase( + "decimal_inf_minus_inf", + doc={"a": DECIMAL128_INFINITY, "b": DECIMAL128_INFINITY}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_NAN, + msg="$subtract of Decimal128 Infinity minus Decimal128 Infinity should return Decimal128 NaN", # noqa: E501 + ), +] + +# Property [NaN propagation]: $subtract propagates NaN when either operand is NaN. +NAN_PROPAGATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nan_minuend", + doc={"a": FLOAT_NAN, "b": 1}, + expression={"$subtract": ["$a", "$b"]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="$subtract with a NaN minuend should return NaN", + ), + ExpressionTestCase( + "nan_subtrahend", + doc={"a": 10, "b": FLOAT_NAN}, + expression={"$subtract": ["$a", "$b"]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="$subtract with a NaN subtrahend should return NaN", + ), + ExpressionTestCase( + "both_nan", + doc={"a": FLOAT_NAN, "b": FLOAT_NAN}, + expression={"$subtract": ["$a", "$b"]}, + expected=pytest.approx(math.nan, nan_ok=True), + msg="$subtract with both operands as NaN should return NaN", + ), + # Decimal128 NaN + ExpressionTestCase( + "decimal_nan_minuend", + doc={"a": DECIMAL128_NAN, "b": 1}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_NAN, + msg="$subtract with a Decimal128 NaN minuend should return Decimal128 NaN", + ), + ExpressionTestCase( + "decimal_nan_subtrahend", + doc={"a": 10, "b": DECIMAL128_NAN}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_NAN, + msg="$subtract with a Decimal128 NaN subtrahend should return Decimal128 NaN", + ), +] + +# Property [Inf - Inf = NaN]: subtracting equal signed infinities produces NaN. +INF_MINUS_INF_NAN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal_nan_minus_double", + doc={"a": DECIMAL128_NAN, "b": DOUBLE_ONE_AND_HALF}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_NAN, + msg="$subtract of Decimal128 NaN minus a double should return Decimal128 NaN", + ), + ExpressionTestCase( + "double_minus_decimal_nan", + doc={"a": DOUBLE_ONE_AND_HALF, "b": DECIMAL128_NAN}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_NAN, + msg="$subtract of a double minus Decimal128 NaN should return Decimal128 NaN", + ), + ExpressionTestCase( + "int_minus_decimal_inf", + doc={"a": 1, "b": DECIMAL128_INFINITY}, + expression={"$subtract": ["$a", "$b"]}, + expected=DECIMAL128_NEGATIVE_INFINITY, + msg="$subtract of an int minus Decimal128 Infinity should return Decimal128 -Infinity", + ), +] + +SUBTRACT_NON_FINITE_TESTS: list[ExpressionTestCase] = ( + INFINITY_PROPAGATION_TESTS + NAN_PROPAGATION_TESTS + INF_MINUS_INF_NAN_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(SUBTRACT_NON_FINITE_TESTS)) +def test_subtract_non_finite(collection, test_case: ExpressionTestCase): + """Test $subtract behavior with non-finite values (NaN and Infinity).""" + 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/subtract/test_subtract_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_null.py new file mode 100644 index 000000000..3b31b3bed --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/arithmetic/subtract/test_subtract_null.py @@ -0,0 +1,83 @@ +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]: $subtract returns null when either operand is null or missing. +NULL_PROPAGATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_subtrahend", + doc={"a": 10, "b": None}, + expression={"$subtract": ["$a", "$b"]}, + expected=None, + msg="$subtract should return null when the subtrahend is null", + ), + ExpressionTestCase( + "null_minuend", + doc={"a": None, "b": 5}, + expression={"$subtract": ["$a", "$b"]}, + expected=None, + msg="$subtract should return null when the minuend is null", + ), + ExpressionTestCase( + "both_null", + doc={"a": None, "b": None}, + expression={"$subtract": ["$a", "$b"]}, + expected=None, + msg="$subtract should return null when both operands are null", + ), + ExpressionTestCase( + "missing_subtrahend", + doc={"a": 10}, + expression={"$subtract": ["$a", MISSING]}, + expected=None, + msg="$subtract should return null when the subtrahend field is missing", + ), + ExpressionTestCase( + "missing_minuend", + doc={"b": 5}, + expression={"$subtract": [MISSING, "$b"]}, + expected=None, + msg="$subtract should return null when the minuend field is missing", + ), +] + +# Property [Null short-circuit]: a null/missing operand short-circuits evaluation before type +# checking. +NULL_SHORT_CIRCUIT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_with_non_numeric", + doc={"a": None}, + expression={"$subtract": ["$a", "string"]}, + expected=None, + msg="$subtract should return null when a null minuend short-circuits type checking", + ), + ExpressionTestCase( + "missing_with_non_numeric", + doc={}, + expression={"$subtract": [MISSING, True]}, + expected=None, + msg="$subtract should return null when a missing minuend short-circuits type checking", + ), +] + +SUBTRACT_NULL_TESTS: list[ExpressionTestCase] = NULL_PROPAGATION_TESTS + NULL_SHORT_CIRCUIT_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(SUBTRACT_NULL_TESTS)) +def test_subtract_null(collection, test_case: ExpressionTestCase): + """Test $subtract null and missing field propagation.""" + 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, + )