diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_arity.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_arity.py new file mode 100644 index 000000000..f48172eb1 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_arity.py @@ -0,0 +1,80 @@ +"""$toDecimal arity and field path syntax tests.""" + +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, +) +from documentdb_tests.framework.error_codes import ( + FAILED_TO_PARSE_ERROR, + INVALID_DOLLAR_FIELD_PATH, + TO_TYPE_ARITY_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Arity]: $toDecimal unwraps a single-element literal array at parse time; +# empty and multi-element arrays are arity errors. +TODECIMAL_ARITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "single_element", + msg="Single-element literal array argument is unwrapped to a scalar", + expression={"$toDecimal": [42]}, + expected=Decimal128("42"), + ), + ExpressionTestCase( + "single_null", + msg="Single-element literal array wrapping null unwraps and returns null", + expression={"$toDecimal": [None]}, + expected=None, + ), + ExpressionTestCase( + "empty_array", + msg="Empty literal array argument is an arity error", + expression={"$toDecimal": []}, + error_code=TO_TYPE_ARITY_ERROR, + ), + ExpressionTestCase( + "multi_element", + msg="Multi-element literal array argument is an arity error", + expression={"$toDecimal": [1, 2]}, + error_code=TO_TYPE_ARITY_ERROR, + ), + ExpressionTestCase( + "large_array", + msg="Large literal array argument is an arity error", + expression={"$toDecimal": list(range(100))}, + error_code=TO_TYPE_ARITY_ERROR, + ), +] + +# Property [Invalid Field Path]: $toDecimal rejects malformed field path syntax. +TODECIMAL_INVALID_FIELD_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "bare_dollar", + msg="Bare '$' is an invalid field path", + expression={"$toDecimal": "$"}, + error_code=INVALID_DOLLAR_FIELD_PATH, + ), + ExpressionTestCase( + "double_dollar", + msg="'$$' is rejected as an empty variable name", + expression={"$toDecimal": "$$"}, + error_code=FAILED_TO_PARSE_ERROR, + ), +] + + +@pytest.mark.parametrize( + "test", pytest_params(TODECIMAL_ARITY_TESTS + TODECIMAL_INVALID_FIELD_PATH_TESTS) +) +def test_toDecimal_arity(collection, test: ExpressionTestCase): + """$toDecimal literal array arguments are unwrapped or rejected based on arity.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_datetime.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_datetime.py new file mode 100644 index 000000000..b65c98f5e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_datetime.py @@ -0,0 +1,169 @@ +"""$toDecimal datetime conversion tests and unsupported BSON type errors.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, 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, +) +from documentdb_tests.framework.error_codes import CONVERSION_FAILURE_ERROR +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_BEFORE_EPOCH, + DATE_EPOCH, + DATE_YEAR_1, + DATE_YEAR_9999, + DECIMAL128_ZERO, +) + +# Property [Datetime]: $toDecimal converts datetime to milliseconds since Unix epoch as Decimal128. +TODECIMAL_DATETIME_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "datetime_epoch", + msg="Epoch datetime converts to Decimal128('0')", + expression={"$toDecimal": DATE_EPOCH}, + expected=DECIMAL128_ZERO, + ), + ExpressionTestCase( + "datetime_1ms_after_epoch", + msg="1 ms after epoch converts to Decimal128('1')", + expression={"$toDecimal": datetime(1970, 1, 1, 0, 0, 0, 1_000, tzinfo=timezone.utc)}, + expected=Decimal128("1"), + ), + ExpressionTestCase( + "datetime_one_day", + msg="One day after epoch converts to Decimal128('86400000')", + expression={"$toDecimal": datetime(1970, 1, 2, 0, 0, 0, tzinfo=timezone.utc)}, + expected=Decimal128("86400000"), + ), + ExpressionTestCase( + "datetime_before_epoch", + msg="1 ms before epoch converts to Decimal128('-1')", + expression={"$toDecimal": DATE_BEFORE_EPOCH}, + expected=Decimal128("-1"), + ), + ExpressionTestCase( + "datetime_pre_epoch", + msg="Pre-epoch date (1960-01-01) converts to negative ms value", + expression={"$toDecimal": datetime(1960, 1, 1, tzinfo=timezone.utc)}, + expected=Decimal128("-315619200000"), + ), + ExpressionTestCase( + "datetime_with_millis", + msg="Datetime with sub-second precision preserves milliseconds", + expression={"$toDecimal": datetime(2018, 3, 27, 5, 4, 47, 890_000, tzinfo=timezone.utc)}, + expected=Decimal128("1522127087890"), + ), + ExpressionTestCase( + "datetime_2024", + msg="A modern date converts to its ms-since-epoch value", + expression={"$toDecimal": datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expected=Decimal128("1704067200000"), + ), + ExpressionTestCase( + "datetime_millisecond_precision", + msg="Datetime preserves millisecond precision", + expression={"$toDecimal": datetime(1970, 1, 1, 0, 0, 0, 500_000, tzinfo=timezone.utc)}, + expected=Decimal128("500"), + ), + ExpressionTestCase( + "datetime_far_past", + msg="Far-past datetime (year 1) converts to its ms-since-epoch value", + expression={"$toDecimal": DATE_YEAR_1}, + expected=Decimal128("-62135596800000"), + ), + ExpressionTestCase( + "datetime_far_future", + msg="Far-future datetime (year 9999) converts to its ms-since-epoch value", + expression={"$toDecimal": DATE_YEAR_9999}, + expected=Decimal128("253402300799999"), + ), +] + +# Property [Unsupported Types]: $toDecimal fails with a conversion error for BSON types it +# cannot convert (object, binary, ObjectId, regex, timestamp, code, MinKey, MaxKey, array). +TODECIMAL_UNSUPPORTED_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "type_object", + msg="Object BSON type is a conversion failure", + expression={"$toDecimal": {"$literal": {"a": 1}}}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "type_binary", + msg="Binary BSON type is a conversion failure", + expression={"$toDecimal": Binary(b"data")}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "type_binary_uuid", + msg="Binary UUID subtype 4 is a conversion failure", + expression={"$toDecimal": Binary(b"\x00" * 16, 4)}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "type_objectid", + msg="ObjectId BSON type is a conversion failure", + expression={"$toDecimal": ObjectId("507f1f77bcf86cd799439011")}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "type_regex", + msg="Regex BSON type is a conversion failure", + expression={"$toDecimal": Regex("abc")}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "type_timestamp", + msg="Timestamp BSON type is a conversion failure", + expression={"$toDecimal": Timestamp(1, 1)}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "type_code", + msg="Code BSON type is a conversion failure", + expression={"$toDecimal": Code("function() {}")}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "type_minkey", + msg="MinKey BSON type is a conversion failure", + expression={"$toDecimal": MinKey()}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "type_maxkey", + msg="MaxKey BSON type is a conversion failure", + expression={"$toDecimal": MaxKey()}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "type_array", + msg="Array value (from $literal) is a conversion failure", + expression={"$toDecimal": {"$literal": [1, 2]}}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "type_nested_array", + msg="Nested literal array after single-element unwrap is a conversion failure", + expression={"$toDecimal": [["hello"]]}, + error_code=CONVERSION_FAILURE_ERROR, + ), +] + +TODECIMAL_DATETIME_TESTS = TODECIMAL_DATETIME_TESTS + TODECIMAL_UNSUPPORTED_TYPE_TESTS + + +@pytest.mark.parametrize("test", pytest_params(TODECIMAL_DATETIME_TESTS)) +def test_toDecimal_datetime(collection, test: ExpressionTestCase): + """$toDecimal converts datetime to ms-since-epoch Decimal128; rejects unsupported types.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_decimal128.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_decimal128.py new file mode 100644 index 000000000..3c9fbf975 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_decimal128.py @@ -0,0 +1,140 @@ +"""$toDecimal Decimal128 passthrough tests: identity, trailing zeros, exponent, and specials.""" + +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, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_LARGE_EXPONENT, + DECIMAL128_MANY_TRAILING_ZEROS, + DECIMAL128_MAX, + DECIMAL128_MAX_COEFFICIENT, + DECIMAL128_MAX_NEGATIVE, + DECIMAL128_MIN, + DECIMAL128_MIN_POSITIVE, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_NEGATIVE_ONE_AND_HALF, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_TRAILING_ZERO, + DECIMAL128_ZERO, +) + +# Property [Decimal128 Passthrough]: $toDecimal is the identity function for Decimal128 inputs, +# preserving trailing zeros, exponent form, sign bits, and special values exactly. +TODECIMAL_DECIMAL128_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dec128_zero", + msg="Decimal128 zero passes through unchanged", + expression={"$toDecimal": DECIMAL128_ZERO}, + expected=DECIMAL128_ZERO, + ), + ExpressionTestCase( + "dec128_negative_zero", + msg="Decimal128 negative zero passes through preserving sign", + expression={"$toDecimal": DECIMAL128_NEGATIVE_ZERO}, + expected=DECIMAL128_NEGATIVE_ZERO, + ), + ExpressionTestCase( + "dec128_one", + msg="Decimal128 1 passes through unchanged", + expression={"$toDecimal": Decimal128("1")}, + expected=Decimal128("1"), + ), + ExpressionTestCase( + "dec128_trailing_zero", + msg="Decimal128 trailing zero (1.0) passes through preserving the trailing zero", + expression={"$toDecimal": DECIMAL128_TRAILING_ZERO}, + expected=DECIMAL128_TRAILING_ZERO, + ), + ExpressionTestCase( + "dec128_many_trailing_zeros", + msg="Decimal128 with many trailing zeros passes through preserving all zeros", + expression={"$toDecimal": DECIMAL128_MANY_TRAILING_ZEROS}, + expected=DECIMAL128_MANY_TRAILING_ZEROS, + ), + ExpressionTestCase( + "dec128_exponent_form", + msg="Decimal128 in exponent form (1E+6144) passes through preserving exponent", + expression={"$toDecimal": DECIMAL128_LARGE_EXPONENT}, + expected=DECIMAL128_LARGE_EXPONENT, + ), + ExpressionTestCase( + "dec128_negative", + msg="Decimal128 negative value passes through unchanged", + expression={"$toDecimal": DECIMAL128_NEGATIVE_ONE_AND_HALF}, + expected=DECIMAL128_NEGATIVE_ONE_AND_HALF, + ), + ExpressionTestCase( + "dec128_max", + msg="Decimal128 max value passes through unchanged", + expression={"$toDecimal": DECIMAL128_MAX}, + expected=DECIMAL128_MAX, + ), + ExpressionTestCase( + "dec128_min", + msg="Decimal128 min value passes through unchanged", + expression={"$toDecimal": DECIMAL128_MIN}, + expected=DECIMAL128_MIN, + ), + ExpressionTestCase( + "dec128_min_positive", + msg="Decimal128 min positive value (1E-6176) passes through unchanged", + expression={"$toDecimal": DECIMAL128_MIN_POSITIVE}, + expected=DECIMAL128_MIN_POSITIVE, + ), + ExpressionTestCase( + "dec128_max_negative", + msg="Decimal128 max negative value (-1E-6176) passes through unchanged", + expression={"$toDecimal": DECIMAL128_MAX_NEGATIVE}, + expected=DECIMAL128_MAX_NEGATIVE, + ), + ExpressionTestCase( + "dec128_nan", + msg="Decimal128 NaN passes through unchanged", + expression={"$toDecimal": DECIMAL128_NAN}, + expected=DECIMAL128_NAN, + ), + ExpressionTestCase( + "dec128_negative_nan", + msg="Decimal128 -NaN passes through preserving sign bit", + expression={"$toDecimal": DECIMAL128_NEGATIVE_NAN}, + expected=DECIMAL128_NEGATIVE_NAN, + ), + ExpressionTestCase( + "dec128_infinity", + msg="Decimal128 Infinity passes through unchanged", + expression={"$toDecimal": DECIMAL128_INFINITY}, + expected=DECIMAL128_INFINITY, + ), + ExpressionTestCase( + "dec128_negative_infinity", + msg="Decimal128 -Infinity passes through unchanged", + expression={"$toDecimal": DECIMAL128_NEGATIVE_INFINITY}, + expected=DECIMAL128_NEGATIVE_INFINITY, + ), + ExpressionTestCase( + "dec128_high_precision", + msg="Decimal128 maximum 34-digit coefficient passes through at full precision", + expression={"$toDecimal": DECIMAL128_MAX_COEFFICIENT}, + expected=DECIMAL128_MAX_COEFFICIENT, + ), +] + + +@pytest.mark.parametrize("test", pytest_params(TODECIMAL_DECIMAL128_TESTS)) +def test_toDecimal_decimal128(collection, test: ExpressionTestCase): + """$toDecimal is the identity function for Decimal128 inputs.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_double.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_double.py new file mode 100644 index 000000000..6705b327b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_double.py @@ -0,0 +1,147 @@ +"""$toDecimal double conversion tests: 15 significant digits, zero, NaN, and infinity.""" + +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, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_MAX, + DOUBLE_MAX_SAFE_INTEGER, + DOUBLE_MIN, + DOUBLE_MIN_NEGATIVE_SUBNORMAL, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_TWO_AND_HALF, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Property [Double Conversion]: non-zero finite doubles produce exactly 15 significant digits; +# zero, negative zero, NaN, and infinity have special mappings. +TODECIMAL_DOUBLE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "double_two_point_five", + msg="2.5 converts with 15 significant digits", + expression={"$toDecimal": DOUBLE_TWO_AND_HALF}, + expected=Decimal128("2.50000000000000"), + ), + ExpressionTestCase( + "double_negative", + msg="Negative double converts with 15 significant digits", + expression={"$toDecimal": -DOUBLE_TWO_AND_HALF}, + expected=Decimal128("-2.50000000000000"), + ), + ExpressionTestCase( + "double_zero", + msg="0.0 converts to Decimal128('0') with no trailing zeros", + expression={"$toDecimal": DOUBLE_ZERO}, + expected=DECIMAL128_ZERO, + ), + ExpressionTestCase( + "double_negative_zero", + msg="Negative zero double preserves sign", + expression={"$toDecimal": DOUBLE_NEGATIVE_ZERO}, + expected=DECIMAL128_NEGATIVE_ZERO, + ), + ExpressionTestCase( + "double_nan", + msg="double NaN converts to Decimal128 NaN", + expression={"$toDecimal": FLOAT_NAN}, + expected=DECIMAL128_NAN, + ), + ExpressionTestCase( + "double_pos_inf", + msg="double Infinity converts to Decimal128 Infinity", + expression={"$toDecimal": FLOAT_INFINITY}, + expected=DECIMAL128_INFINITY, + ), + ExpressionTestCase( + "double_neg_inf", + msg="double -Infinity converts to Decimal128 -Infinity", + expression={"$toDecimal": FLOAT_NEGATIVE_INFINITY}, + expected=DECIMAL128_NEGATIVE_INFINITY, + ), + ExpressionTestCase( + "double_round_up_power_of_10", + msg="Double rounds up to next power of 10 at 15th digit boundary", + expression={"$toDecimal": 9.999999999999998e15}, + expected=Decimal128("1.00000000000000E+16"), + ), + ExpressionTestCase( + "double_round_up", + msg="Double rounds up at 15th significant digit", + expression={"$toDecimal": 1.234567890123456}, + expected=Decimal128("1.23456789012346"), + ), + ExpressionTestCase( + "double_round_misleading_literal", + msg="Double rounds based on stored imprecise value, not source literal", + expression={"$toDecimal": 1.234567890123455}, + expected=Decimal128("1.23456789012345"), + ), + ExpressionTestCase( + "double_round_down", + msg="Double rounds down at 15th significant digit", + expression={"$toDecimal": 1.234567890123451}, + expected=Decimal128("1.23456789012345"), + ), + ExpressionTestCase( + "double_round_to_one", + msg="Double 0.9999999999999996 rounds to 1.00000000000000", + expression={"$toDecimal": 0.9999999999999996}, + expected=Decimal128("1.00000000000000"), + ), + ExpressionTestCase( + "double_max_safe_int", + msg="Max safe integer (2^53) converts with 15 significant digits", + expression={"$toDecimal": float(DOUBLE_MAX_SAFE_INTEGER)}, + expected=Decimal128("9.00719925474099E+15"), + ), + ExpressionTestCase( + "double_subnormal", + msg="Minimum positive subnormal double converts with 15 significant digits", + expression={"$toDecimal": DOUBLE_MIN_SUBNORMAL}, + expected=Decimal128("4.94065645841247E-324"), + ), + ExpressionTestCase( + "double_negative_subnormal", + msg="Minimum negative subnormal double converts with 15 significant digits", + expression={"$toDecimal": DOUBLE_MIN_NEGATIVE_SUBNORMAL}, + expected=Decimal128("-4.94065645841247E-324"), + ), + ExpressionTestCase( + "double_near_max", + msg="Near-max double converts with 15 significant digits", + expression={"$toDecimal": DOUBLE_MAX}, + expected=Decimal128("1.79769313486232E+308"), + ), + ExpressionTestCase( + "double_near_min", + msg="Near-min double converts with 15 significant digits", + expression={"$toDecimal": DOUBLE_MIN}, + expected=Decimal128("-1.79769313486232E+308"), + ), +] + + +@pytest.mark.parametrize("test", pytest_params(TODECIMAL_DOUBLE_TESTS)) +def test_toDecimal_double(collection, test: ExpressionTestCase): + """$toDecimal converts double inputs to Decimal128 with 15 significant digits.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_field_ref.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_field_ref.py new file mode 100644 index 000000000..bd9e63e78 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_field_ref.py @@ -0,0 +1,148 @@ +"""$toDecimal field reference and expression-as-input tests.""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import CONVERSION_FAILURE_ERROR +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_TWO_AND_HALF, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_TWO_AND_HALF, + INT32_ZERO, +) + +# Property [Field Reference]: $toDecimal resolves field paths and nested dot-notation paths; +# missing fields and missing nested fields return null. +TODECIMAL_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int_field", + msg="int32 field converts to Decimal128", + expression={"$toDecimal": "$v"}, + doc={"v": 42}, + expected=Decimal128("42"), + ), + ExpressionTestCase( + "int64_field", + msg="int64 field converts to Decimal128", + expression={"$toDecimal": "$v"}, + doc={"v": Int64(86400000)}, + expected=Decimal128("86400000"), + ), + ExpressionTestCase( + "decimal_field", + msg="Decimal128 field passes through unchanged", + expression={"$toDecimal": "$v"}, + doc={"v": Decimal128("3.14")}, + expected=Decimal128("3.14"), + ), + ExpressionTestCase( + "string_field", + msg="Numeric string field converts to Decimal128", + expression={"$toDecimal": "$v"}, + doc={"v": "2.5"}, + expected=DECIMAL128_TWO_AND_HALF, + ), + ExpressionTestCase( + "bool_field", + msg="Bool field converts to Decimal128", + expression={"$toDecimal": "$v"}, + doc={"v": True}, + expected=Decimal128("1"), + ), + ExpressionTestCase( + "double_field", + msg="Double field converts to Decimal128 with 15 significant digits", + expression={"$toDecimal": "$v"}, + doc={"v": DOUBLE_TWO_AND_HALF}, + expected=Decimal128("2.50000000000000"), + ), + ExpressionTestCase( + "nested_field", + msg="Nested field path converts to Decimal128", + expression={"$toDecimal": "$doc.v"}, + doc={"doc": {"v": 100}}, + expected=Decimal128("100"), + ), + ExpressionTestCase( + "missing_nested", + msg="Missing nested field returns null", + expression={"$toDecimal": "$doc.missing"}, + doc={"doc": {"x": 1}}, + expected=None, + ), + ExpressionTestCase( + "missing_field", + msg="Missing top-level field returns null", + expression={"$toDecimal": "$v"}, + doc={}, + expected=None, + ), + ExpressionTestCase( + "neg_zero_double_field", + msg="$toDecimal preserves -0.0 sign when reading double from a document field", + expression={"$toDecimal": "$v"}, + doc={"v": DOUBLE_NEGATIVE_ZERO}, + expected=DECIMAL128_NEGATIVE_ZERO, + ), + ExpressionTestCase( + "zero_field", + msg="$toDecimal converts zero from a document field", + expression={"$toDecimal": "$v"}, + doc={"v": INT32_ZERO}, + expected=DECIMAL128_ZERO, + ), + ExpressionTestCase( + "composite_array_path", + msg="$toDecimal fails when field path resolves to a composite array", + expression={"$toDecimal": "$a.b"}, + doc={"a": [{"b": 1}, {"b": 2}]}, + error_code=CONVERSION_FAILURE_ERROR, + ), +] + +# Property [Expression Input]: $toDecimal evaluates a nested expression before converting. +TODECIMAL_EXPRESSION_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "add", + msg="$toDecimal converts an arithmetic expression result", + expression={"$toDecimal": {"$add": [10, 20]}}, + expected=Decimal128("30"), + ), + ExpressionTestCase( + "concat", + msg="$toDecimal converts a string concatenation expression result", + expression={"$toDecimal": {"$concat": ["3", ".", "14"]}}, + expected=Decimal128("3.14"), + ), + ExpressionTestCase( + "nested_conversion", + msg="$toDecimal converts a nested type-conversion expression result", + expression={"$toDecimal": {"$toDouble": 42}}, + expected=Decimal128("4.20000000000000E+1"), + ), +] + + +@pytest.mark.parametrize( + "test", pytest_params(TODECIMAL_FIELD_REF_TESTS + TODECIMAL_EXPRESSION_INPUT_TESTS) +) +def test_toDecimal_field_ref(collection, test: ExpressionTestCase): + """$toDecimal resolves field paths and nested paths from inserted documents.""" + if test.doc is not None: + result = execute_expression_with_insert(collection, test.expression, test.doc) + else: + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_numeric.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_numeric.py new file mode 100644 index 000000000..0a5624b30 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_numeric.py @@ -0,0 +1,182 @@ +"""$toDecimal numeric type tests: null/missing, boolean, int32, and int64.""" + +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, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INT64_MAX, + DECIMAL128_ZERO, + 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, + INT64_ZERO, + MISSING, +) + +# Property [Null and Missing]: $toDecimal returns null for null and missing inputs. +TODECIMAL_NULL_MISSING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null", + msg="Should return null for null", + expression={"$toDecimal": None}, + expected=None, + ), + ExpressionTestCase( + "missing", + msg="Should return null for missing", + expression={"$toDecimal": MISSING}, + expected=None, + ), +] + +# Property [Boolean]: $toDecimal converts true to Decimal128("1") and false to Decimal128("0"). +TODECIMAL_BOOL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "bool_true", + msg="True converts to Decimal128('1')", + expression={"$toDecimal": True}, + expected=Decimal128("1"), + ), + ExpressionTestCase( + "bool_false", + msg="False converts to Decimal128('0')", + expression={"$toDecimal": False}, + expected=DECIMAL128_ZERO, + ), +] + +# Property [Int32]: $toDecimal converts int32 values to exact Decimal128 with no trailing zeros. +TODECIMAL_INT32_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_zero", + msg="int32 zero converts to Decimal128('0')", + expression={"$toDecimal": INT32_ZERO}, + expected=DECIMAL128_ZERO, + ), + ExpressionTestCase( + "int32_one", + msg="int32 1 converts to Decimal128('1')", + expression={"$toDecimal": 1}, + expected=Decimal128("1"), + ), + ExpressionTestCase( + "int32_neg_one", + msg="int32 -1 converts to Decimal128('-1')", + expression={"$toDecimal": -1}, + expected=Decimal128("-1"), + ), + ExpressionTestCase( + "int32_max", + msg="int32 max converts exactly", + expression={"$toDecimal": INT32_MAX}, + expected=Decimal128(str(INT32_MAX)), + ), + ExpressionTestCase( + "int32_min", + msg="int32 min converts exactly", + expression={"$toDecimal": INT32_MIN}, + expected=Decimal128(str(INT32_MIN)), + ), + ExpressionTestCase( + "int32_max_minus_1", + msg="int32 max-1 converts exactly", + expression={"$toDecimal": INT32_MAX_MINUS_1}, + expected=Decimal128(str(INT32_MAX_MINUS_1)), + ), + ExpressionTestCase( + "int32_min_plus_1", + msg="int32 min+1 converts exactly", + expression={"$toDecimal": INT32_MIN_PLUS_1}, + expected=Decimal128(str(INT32_MIN_PLUS_1)), + ), +] + +# Property [Int64]: $toDecimal converts int64 values to exact Decimal128 with no trailing zeros. +TODECIMAL_INT64_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int64_zero", + msg="int64 zero converts to Decimal128('0')", + expression={"$toDecimal": INT64_ZERO}, + expected=DECIMAL128_ZERO, + ), + ExpressionTestCase( + "int64_one", + msg="int64 1 converts to Decimal128('1')", + expression={"$toDecimal": Int64(1)}, + expected=Decimal128("1"), + ), + ExpressionTestCase( + "int64_neg_one", + msg="int64 -1 converts to Decimal128('-1')", + expression={"$toDecimal": Int64(-1)}, + expected=Decimal128("-1"), + ), + ExpressionTestCase( + "int64_beyond_int32_pos", + msg="int64 just beyond int32 max converts exactly", + expression={"$toDecimal": INT32_OVERFLOW}, + expected=Decimal128(str(INT32_OVERFLOW)), + ), + ExpressionTestCase( + "int64_beyond_int32_neg", + msg="int64 just beyond int32 min converts exactly", + expression={"$toDecimal": INT32_UNDERFLOW}, + expected=Decimal128(str(INT32_UNDERFLOW)), + ), + ExpressionTestCase( + "int64_max", + msg="int64 max converts exactly to Decimal128", + expression={"$toDecimal": INT64_MAX}, + expected=DECIMAL128_INT64_MAX, + ), + ExpressionTestCase( + "int64_min", + msg="int64 min converts exactly to Decimal128", + expression={"$toDecimal": INT64_MIN}, + expected=Decimal128(str(INT64_MIN)), + ), + ExpressionTestCase( + "int64_max_minus_1", + msg="int64 max-1 converts exactly", + expression={"$toDecimal": INT64_MAX_MINUS_1}, + expected=Decimal128(str(INT64_MAX_MINUS_1)), + ), + ExpressionTestCase( + "int64_min_plus_1", + msg="int64 min+1 converts exactly", + expression={"$toDecimal": INT64_MIN_PLUS_1}, + expected=Decimal128(str(INT64_MIN_PLUS_1)), + ), +] + +TODECIMAL_NUMERIC_TESTS = ( + TODECIMAL_NULL_MISSING_TESTS + + TODECIMAL_BOOL_TESTS + + TODECIMAL_INT32_TESTS + + TODECIMAL_INT64_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(TODECIMAL_NUMERIC_TESTS)) +def test_toDecimal_numeric(collection, test: ExpressionTestCase): + """$toDecimal converts null, missing, bool, int32, and int64 inputs.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_return_type.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_return_type.py new file mode 100644 index 000000000..83a60d359 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_return_type.py @@ -0,0 +1,131 @@ +"""$toDecimal return-type invariant and idempotency tests.""" + +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, +) +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_HALF, + DECIMAL128_ZERO, + DOUBLE_TWO_AND_HALF, + MISSING, +) + +# Property [Return Type]: $toDecimal always returns BSON type 'decimal' for successful +# conversions and null for null or missing inputs. +RETURN_TYPE_DECIMAL_SPEC = [ + BsonTypeTestCase( + id="toDecimal_return_type", + msg="$toDecimal always returns BSON type decimal for a successful conversion", + valid_types=[ + BsonType.BOOL, + BsonType.DOUBLE, + BsonType.INT, + BsonType.LONG, + BsonType.DECIMAL, + BsonType.STRING, + BsonType.DATE, + ], + valid_inputs={ + BsonType.STRING: "7.5", + }, + ), +] + +RETURN_TYPE_DECIMAL_CASES = generate_bson_acceptance_test_cases(RETURN_TYPE_DECIMAL_SPEC) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", RETURN_TYPE_DECIMAL_CASES) +def test_toDecimal_return_type_is_decimal(collection, bson_type, sample_value, spec): + """$toDecimal always returns BSON type 'decimal' for a successful conversion.""" + result = execute_expression(collection, {"$type": {"$toDecimal": sample_value}}) + assert_expression_result( + result, expected="decimal", msg=f"{spec.msg} ({bson_type.value} input)" + ) + + +# Property [Return Type — Null]: $toDecimal returns BSON type null for null or missing inputs. +TODECIMAL_RETURN_TYPE_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type_null", + msg="$toDecimal of null returns null type", + expression={"$type": {"$toDecimal": None}}, + expected="null", + ), + ExpressionTestCase( + "return_type_missing", + msg="$toDecimal of missing returns null type", + expression={"$type": {"$toDecimal": MISSING}}, + expected="null", + ), +] + + +# Property [Idempotency]: applying $toDecimal twice produces the same result as once. +TODECIMAL_IDEMPOTENCY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "idempotent_bool", + msg="$toDecimal is idempotent for bool", + expression={"$toDecimal": {"$toDecimal": True}}, + expected=Decimal128("1"), + ), + ExpressionTestCase( + "idempotent_int32", + msg="$toDecimal is idempotent for int32", + expression={"$toDecimal": {"$toDecimal": 42}}, + expected=Decimal128("42"), + ), + ExpressionTestCase( + "idempotent_int64", + msg="$toDecimal is idempotent for int64", + expression={"$toDecimal": {"$toDecimal": Int64(99)}}, + expected=Decimal128("99"), + ), + ExpressionTestCase( + "idempotent_decimal128", + msg="$toDecimal is idempotent for Decimal128", + expression={"$toDecimal": {"$toDecimal": DECIMAL128_HALF}}, + expected=DECIMAL128_HALF, + ), + ExpressionTestCase( + "idempotent_zero", + msg="$toDecimal is idempotent for zero", + expression={"$toDecimal": {"$toDecimal": DECIMAL128_ZERO}}, + expected=DECIMAL128_ZERO, + ), + ExpressionTestCase( + "idempotent_double", + msg="$toDecimal is idempotent for double", + expression={"$toDecimal": {"$toDecimal": DOUBLE_TWO_AND_HALF}}, + expected=Decimal128("2.50000000000000"), + ), + ExpressionTestCase( + "idempotent_string", + msg="$toDecimal is idempotent for numeric string", + expression={"$toDecimal": {"$toDecimal": "7.5"}}, + expected=Decimal128("7.5"), + ), +] + + +@pytest.mark.parametrize( + "test", pytest_params(TODECIMAL_RETURN_TYPE_NULL_TESTS + TODECIMAL_IDEMPOTENCY_TESTS) +) +def test_toDecimal_return_type_null(collection, test: ExpressionTestCase): + """$toDecimal returns BSON type 'null' for null or missing input.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_string.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_string.py new file mode 100644 index 000000000..2639dea6d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_string.py @@ -0,0 +1,225 @@ +"""$toDecimal valid string conversion tests: decimal, scientific notation, and special values.""" + +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, +) +from documentdb_tests.framework.lazy_payload import lazy +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_HALF, + DECIMAL128_INFINITY, + DECIMAL128_MIN_POSITIVE, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + STRING_SIZE_LIMIT_BYTES, +) + +# Property [String Conversion — Valid]: base-10 numeric strings, scientific notation, and +# special value strings convert to the corresponding Decimal128. +TODECIMAL_STRING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "str_negative_decimal", + msg="$toDecimal should convert negative decimal string", + expression={"$toDecimal": "-5.5"}, + expected=Decimal128("-5.5"), + ), + ExpressionTestCase( + "str_scientific_upper", + msg="$toDecimal should accept uppercase E in scientific notation", + expression={"$toDecimal": "1.5E+3"}, + expected=Decimal128("1.5E+3"), + ), + ExpressionTestCase( + "str_scientific_lower", + msg="$toDecimal should accept lowercase e in scientific notation", + expression={"$toDecimal": "1.5e+3"}, + expected=Decimal128("1.5E+3"), + ), + ExpressionTestCase( + "str_leading_plus", + msg="$toDecimal should strip leading plus sign", + expression={"$toDecimal": "+123"}, + expected=Decimal128("123"), + ), + ExpressionTestCase( + "str_leading_zeros", + msg="$toDecimal should strip leading zeros", + expression={"$toDecimal": "007"}, + expected=Decimal128("7"), + ), + ExpressionTestCase( + "str_leading_decimal", + msg="$toDecimal should accept leading decimal point", + expression={"$toDecimal": ".5"}, + expected=DECIMAL128_HALF, + ), + ExpressionTestCase( + "str_trailing_decimal", + msg="$toDecimal should accept trailing decimal point", + expression={"$toDecimal": "5."}, + expected=Decimal128("5"), + ), + ExpressionTestCase( + "str_trailing_zeros_preserved", + msg="$toDecimal should preserve trailing zeros in string decimal part", + expression={"$toDecimal": "1.00"}, + expected=Decimal128("1.00"), + ), + ExpressionTestCase( + "str_nan_mixed_case", + msg="$toDecimal should accept 'NaN'", + expression={"$toDecimal": "NaN"}, + expected=DECIMAL128_NAN, + ), + ExpressionTestCase( + "str_nan_lower", + msg="$toDecimal should accept 'nan' case-insensitively", + expression={"$toDecimal": "nan"}, + expected=DECIMAL128_NAN, + ), + ExpressionTestCase( + "str_nan_upper", + msg="$toDecimal should accept 'NAN' case-insensitively", + expression={"$toDecimal": "NAN"}, + expected=DECIMAL128_NAN, + ), + ExpressionTestCase( + "str_infinity", + msg="$toDecimal should accept 'Infinity'", + expression={"$toDecimal": "Infinity"}, + expected=DECIMAL128_INFINITY, + ), + ExpressionTestCase( + "str_inf_short", + msg="$toDecimal should accept 'inf' short form", + expression={"$toDecimal": "inf"}, + expected=DECIMAL128_INFINITY, + ), + ExpressionTestCase( + "str_inf_upper", + msg="$toDecimal should accept 'INF' case-insensitively", + expression={"$toDecimal": "INF"}, + expected=DECIMAL128_INFINITY, + ), + ExpressionTestCase( + "str_neg_nan", + msg="$toDecimal should convert '-NaN' string preserving sign bit", + expression={"$toDecimal": "-NaN"}, + expected=Decimal128("-NaN"), + ), + ExpressionTestCase( + "str_neg_infinity", + msg="$toDecimal should preserve sign on negative Infinity string", + expression={"$toDecimal": "-Infinity"}, + expected=DECIMAL128_NEGATIVE_INFINITY, + ), + ExpressionTestCase( + "str_plus_infinity", + msg="$toDecimal should strip plus sign from Infinity string", + expression={"$toDecimal": "+Infinity"}, + expected=DECIMAL128_INFINITY, + ), + ExpressionTestCase( + "str_max_exponent", + msg="$toDecimal should accept maximum exponent string", + expression={"$toDecimal": "1E+6144"}, + expected=Decimal128("1.000000000000000000000000000000000E+6144"), + ), + ExpressionTestCase( + "str_min_exponent", + msg="$toDecimal should accept minimum exponent string", + expression={"$toDecimal": "1E-6176"}, + expected=DECIMAL128_MIN_POSITIVE, + ), + ExpressionTestCase( + "str_exactly_34_digits", + msg="$toDecimal should convert 34-digit string without rounding", + expression={"$toDecimal": "1234567890123456789012345678901234"}, + expected=Decimal128("1234567890123456789012345678901234"), + ), + ExpressionTestCase( + "str_exceeds_34_digits", + msg="$toDecimal should round strings exceeding 34 significant digits", + expression={"$toDecimal": "12345678901234567890123456789012345"}, + expected=Decimal128("1.234567890123456789012345678901234E+34"), + ), + ExpressionTestCase( + "str_negative_zero", + msg="$toDecimal should preserve negative zero from string", + expression={"$toDecimal": "-0"}, + expected=DECIMAL128_NEGATIVE_ZERO, + ), + ExpressionTestCase( + "str_plus_zero", + msg="$toDecimal should convert '+0' to Decimal128 zero", + expression={"$toDecimal": "+0"}, + expected=DECIMAL128_ZERO, + ), + ExpressionTestCase( + "str_plus_dot_zero", + msg="$toDecimal should convert '+.0' to Decimal128 zero", + expression={"$toDecimal": "+.0"}, + expected=Decimal128("0.0"), + ), + ExpressionTestCase( + "str_neg_zero_neg_exp", + msg="$toDecimal should convert '-0E-1' preserving negative zero", + expression={"$toDecimal": "-0E-1"}, + expected=Decimal128("-0.0"), + ), + ExpressionTestCase( + "str_zero_pos_exp", + msg="$toDecimal should convert '0E+1' preserving exponent", + expression={"$toDecimal": "0E+1"}, + expected=Decimal128("0E+1"), + ), + ExpressionTestCase( + "str_shifted_decimal_large", + msg="$toDecimal should convert shifted decimal form of large value", + expression={"$toDecimal": ".1797693134862315807E+309"}, + expected=Decimal128("1.797693134862315807E+308"), + ), + ExpressionTestCase( + "str_plus_prefixed_max", + msg="$toDecimal should strip plus sign from max-range string", + expression={"$toDecimal": "+1.797693134862315807E+308"}, + expected=Decimal128("1.797693134862315807E+308"), + ), + ExpressionTestCase( + "str_rounding_34_digits", + msg="$toDecimal rounds at digit 35; digit-35 is 0 so result rounds down", + expression={"$toDecimal": "1.00000000000000000000000000000000099999"}, + expected=Decimal128("1.000000000000000000000000000000000"), + ), +] + + +# Property [String Size Limit — Valid]: a valid numeric string one byte under the limit converts. +TODECIMAL_STRING_SIZE_LIMIT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "size_one_under_valid", + msg="Valid numeric string one byte under limit converts successfully", + expression=lazy(lambda: {"$toDecimal": "0" * (STRING_SIZE_LIMIT_BYTES - 2) + "1"}), + expected=Decimal128("1"), + ), +] + + +@pytest.mark.parametrize( + "test", pytest_params(TODECIMAL_STRING_TESTS + TODECIMAL_STRING_SIZE_LIMIT_TESTS) +) +def test_toDecimal_string(collection, test: ExpressionTestCase): + """$toDecimal parses valid numeric strings including scientific notation and special values.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_string_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_string_errors.py new file mode 100644 index 000000000..efe965418 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDecimal/test_toDecimal_string_errors.py @@ -0,0 +1,228 @@ +"""$toDecimal string parsing error tests: invalid formats and size limit rejections.""" + +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, +) +from documentdb_tests.framework.error_codes import CONVERSION_FAILURE_ERROR, STRING_SIZE_LIMIT_ERROR +from documentdb_tests.framework.lazy_payload import lazy +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import STRING_SIZE_LIMIT_BYTES + +# Property [String Parsing Errors]: invalid string formats produce CONVERSION_FAILURE_ERROR. +TODECIMAL_STRING_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "str_err_empty", + msg="$toDecimal should reject empty string", + expression={"$toDecimal": ""}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_non_numeric", + msg="$toDecimal should reject non-numeric string", + expression={"$toDecimal": "h"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_bare_plus", + msg="$toDecimal should reject bare plus sign", + expression={"$toDecimal": "+"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_bare_minus", + msg="$toDecimal should reject bare minus sign", + expression={"$toDecimal": "-"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_bare_dot", + msg="$toDecimal should reject bare dot", + expression={"$toDecimal": "."}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_hex", + msg="$toDecimal should reject hexadecimal string", + expression={"$toDecimal": "0x4301"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_leading_space", + msg="$toDecimal should reject string with leading space", + expression={"$toDecimal": " 1"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_trailing_space", + msg="$toDecimal should reject string with trailing space", + expression={"$toDecimal": "1 "}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_embedded_space", + msg="$toDecimal should reject string with embedded space", + expression={"$toDecimal": "1 2"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_tab", + msg="$toDecimal should reject string with tab", + expression={"$toDecimal": "\t1"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_newline", + msg="$toDecimal should reject string with newline", + expression={"$toDecimal": "\n1"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_cr", + msg="$toDecimal should reject string with carriage return", + expression={"$toDecimal": "\r1"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_nbsp", + msg="$toDecimal should reject string with non-breaking space (U+00A0)", + expression={"$toDecimal": "\u00a01"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_en_space", + msg="$toDecimal should reject string with en space (U+2002)", + expression={"$toDecimal": "\u20021"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_bom", + msg="$toDecimal should reject string with BOM (U+FEFF)", + expression={"$toDecimal": "\ufeff1"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_zwsp", + msg="$toDecimal should reject string with zero-width space (U+200B)", + expression={"$toDecimal": "\u200b1"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_zwj", + msg="$toDecimal should reject string with zero-width joiner (U+200D)", + expression={"$toDecimal": "\u200d1"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_rtl_mark", + msg="$toDecimal should reject string with right-to-left mark (U+200F)", + expression={"$toDecimal": "\u200f1"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_incomplete_exp", + msg="$toDecimal should reject incomplete exponent", + expression={"$toDecimal": "1E"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_incomplete_exp_plus", + msg="$toDecimal should reject incomplete exponent with plus", + expression={"$toDecimal": "1E+"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_incomplete_exp_minus", + msg="$toDecimal should reject incomplete exponent with minus", + expression={"$toDecimal": "1E-"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_multi_decimal", + msg="$toDecimal should reject multiple decimal points", + expression={"$toDecimal": "1.2.3"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_multi_exponent", + msg="$toDecimal should reject multiple exponents", + expression={"$toDecimal": "1E2E3"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_underscore", + msg="$toDecimal should reject underscore digit separators", + expression={"$toDecimal": "1_000"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_comma", + msg="$toDecimal should reject comma digit separators", + expression={"$toDecimal": "1,000"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_null_byte", + msg="$toDecimal should reject string with null byte", + expression={"$toDecimal": "\x001"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_non_ascii_digit", + msg="$toDecimal should reject non-ASCII digit characters (U+0661)", + expression={"$toDecimal": "\u0661"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_overflow", + msg="$toDecimal should reject exponent overflow", + expression={"$toDecimal": "1E+6145"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "str_err_underflow", + msg="$toDecimal should reject exponent underflow", + expression={"$toDecimal": "1E-6177"}, + error_code=CONVERSION_FAILURE_ERROR, + ), +] + + +# Property [String Size Limit — Errors]: strings at or above the size limit are rejected with +# STRING_SIZE_LIMIT_ERROR; non-numeric strings just under the limit fail with CONVERSION_FAILURE. +TODECIMAL_STRING_SIZE_LIMIT_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "size_one_under_non_numeric", + msg="Non-numeric string one byte under limit passes size check but fails conversion", + expression=lazy(lambda: {"$toDecimal": "a" * (STRING_SIZE_LIMIT_BYTES - 1)}), + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "size_at_limit", + msg="String at STRING_SIZE_LIMIT_BYTES is rejected with STRING_SIZE_LIMIT_ERROR", + expression=lazy(lambda: {"$toDecimal": "0" * STRING_SIZE_LIMIT_BYTES}), + error_code=STRING_SIZE_LIMIT_ERROR, + ), + ExpressionTestCase( + "size_at_limit_four_byte", + msg="4-byte character string reaching STRING_SIZE_LIMIT_BYTES is rejected", + expression=lazy(lambda: {"$toDecimal": "\U0001f600" * (STRING_SIZE_LIMIT_BYTES // 4)}), + error_code=STRING_SIZE_LIMIT_ERROR, + ), +] + + +@pytest.mark.parametrize( + "test", pytest_params(TODECIMAL_STRING_ERROR_TESTS + TODECIMAL_STRING_SIZE_LIMIT_ERROR_TESTS) +) +def test_toDecimal_string_errors(collection, test: ExpressionTestCase): + """$toDecimal rejects malformed and out-of-range string inputs.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_arity.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_arity.py new file mode 100644 index 000000000..f076cc22b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_arity.py @@ -0,0 +1,85 @@ +"""$toDouble arity and field path syntax tests.""" + +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, +) +from documentdb_tests.framework.error_codes import ( + FAILED_TO_PARSE_ERROR, + INVALID_DOLLAR_FIELD_PATH, + TO_TYPE_ARITY_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Arity]: $toDouble unwraps single-element literal arrays and rejects empty or +# multi-element arrays. These cases are specific to $toDouble syntax (not $convert). +TODOUBLE_ARITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "single_element", + msg="Single-element literal array argument is unwrapped to a scalar", + expression={"$toDouble": [42]}, + expected=42.0, + ), + ExpressionTestCase( + "empty_array", + msg="Empty literal array argument is an arity error", + expression={"$toDouble": []}, + error_code=TO_TYPE_ARITY_ERROR, + ), + ExpressionTestCase( + "multi_element", + msg="Multi-element literal array argument is an arity error", + expression={"$toDouble": [1, 2]}, + error_code=TO_TYPE_ARITY_ERROR, + ), + ExpressionTestCase( + "single_null", + msg="Single-element literal array wrapping null unwraps and returns null", + expression={"$toDouble": [None]}, + expected=None, + ), + ExpressionTestCase( + "single_bool", + msg="Single-element literal array wrapping a bool unwraps and converts", + expression={"$toDouble": [True]}, + expected=1.0, + ), + ExpressionTestCase( + "large_array", + msg="A large literal array argument is an arity error", + expression={"$toDouble": list(range(100))}, + error_code=TO_TYPE_ARITY_ERROR, + ), +] + +# Property [Invalid Field Path]: $toDouble rejects malformed field path syntax. +TODOUBLE_INVALID_FIELD_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "bare_dollar", + msg="Bare '$' is an invalid field path", + expression={"$toDouble": "$"}, + error_code=INVALID_DOLLAR_FIELD_PATH, + ), + ExpressionTestCase( + "double_dollar", + msg="'$$' is rejected as an empty variable name", + expression={"$toDouble": "$$"}, + error_code=FAILED_TO_PARSE_ERROR, + ), +] + + +@pytest.mark.parametrize( + "test", pytest_params(TODOUBLE_ARITY_TESTS + TODOUBLE_INVALID_FIELD_PATH_TESTS) +) +def test_toDouble_arity(collection, test: ExpressionTestCase): + """$toDouble literal array arguments are unwrapped or rejected based on arity.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_datetime_binary.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_datetime_binary.py new file mode 100644 index 000000000..ef401a048 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_datetime_binary.py @@ -0,0 +1,212 @@ +"""$toDouble datetime and binary conversion tests, and unsupported BSON type errors.""" + +import struct +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, +) +from documentdb_tests.framework.error_codes import CONVERSION_FAILURE_ERROR +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_BEFORE_EPOCH, + DATE_EPOCH, + DOUBLE_ONE_AND_HALF, + DOUBLE_ZERO, +) + +# Pre-computed binary values: 4-byte little-endian float32. +_f32_zero = Binary(struct.pack("