diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/toString/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/toString/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/toString/test_toString_arity.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/toString/test_toString_arity.py new file mode 100644 index 000000000..914177fed --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/toString/test_toString_arity.py @@ -0,0 +1,163 @@ +"""$toString arity and field path syntax tests.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.error_codes import ( + CONVERSION_FAILURE_ERROR, + FAILED_TO_PARSE_ERROR, + INVALID_DOLLAR_FIELD_PATH, + TO_TYPE_ARITY_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF + +# Property [Arity]: $toString unwraps single-element literal arrays and rejects empty or +# multi-element arrays. Non-convertible types unwrapped from a single-element array are +# rejected with a conversion failure. +TOSTRING_ARITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "single_null", + msg="Single-element literal array wrapping null unwraps and returns null", + expression={"$toString": [None]}, + expected=None, + ), + ExpressionTestCase( + "single_bool", + msg="Single-element literal array wrapping bool unwraps and converts", + expression={"$toString": [True]}, + expected="true", + ), + ExpressionTestCase( + "single_int32", + msg="Single-element literal array wrapping int32 unwraps and converts", + expression={"$toString": [42]}, + expected="42", + ), + ExpressionTestCase( + "single_int64", + msg="Single-element literal array wrapping int64 unwraps and converts", + expression={"$toString": [Int64(99)]}, + expected="99", + ), + ExpressionTestCase( + "single_double", + msg="Single-element literal array wrapping double unwraps and converts", + expression={"$toString": [3.14]}, + expected="3.14", + ), + ExpressionTestCase( + "single_decimal128", + msg="Single-element literal array wrapping Decimal128 unwraps and converts", + expression={"$toString": [DECIMAL128_ONE_AND_HALF]}, + expected="1.5", + ), + ExpressionTestCase( + "single_string", + msg="Single-element literal array wrapping string unwraps and returns it", + expression={"$toString": ["hello"]}, + expected="hello", + ), + ExpressionTestCase( + "single_objectid", + msg="Single-element literal array wrapping ObjectId unwraps and converts", + expression={"$toString": [ObjectId("507f1f77bcf86cd799439011")]}, + expected="507f1f77bcf86cd799439011", + ), + ExpressionTestCase( + "single_datetime", + msg="Single-element literal array wrapping datetime unwraps and converts", + expression={"$toString": [datetime(2024, 1, 1, tzinfo=timezone.utc)]}, + expected="2024-01-01T00:00:00.000Z", + ), + ExpressionTestCase( + "single_binary", + msg="Single-element literal array wrapping Binary unwraps and converts", + expression={"$toString": [Binary(b"hi", 0)]}, + expected="aGk=", + ), + ExpressionTestCase( + "single_minkey", + msg="Single-element array with MinKey unwraps then rejects with conversion failure", + expression={"$toString": [MinKey()]}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "single_maxkey", + msg="Single-element array with MaxKey unwraps then rejects with conversion failure", + expression={"$toString": [MaxKey()]}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "single_timestamp", + msg="Single-element array with Timestamp unwraps then rejects with conversion failure", + expression={"$toString": [Timestamp(1, 1)]}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "single_regex", + msg="Single-element array with Regex unwraps then rejects with conversion failure", + expression={"$toString": [Regex("abc")]}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "single_code", + msg="Single-element array with Code unwraps then rejects with conversion failure", + expression={"$toString": [Code("x")]}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "empty_array", + msg="Empty literal array is an arity error", + expression={"$toString": []}, + error_code=TO_TYPE_ARITY_ERROR, + ), + ExpressionTestCase( + "two_elements", + msg="Two-element literal array is an arity error", + expression={"$toString": ["a", "b"]}, + error_code=TO_TYPE_ARITY_ERROR, + ), + ExpressionTestCase( + "large_array", + msg="Large literal array is an arity error", + expression={"$toString": list(range(100))}, + error_code=TO_TYPE_ARITY_ERROR, + ), +] + +# Property [Invalid Field Path]: $toString rejects malformed field path syntax. +TOSTRING_INVALID_FIELD_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "bare_dollar", + msg="Bare '$' is an invalid field path", + expression={"$toString": "$"}, + error_code=INVALID_DOLLAR_FIELD_PATH, + ), + ExpressionTestCase( + "double_dollar", + msg="'$$' is rejected as an empty variable name", + expression={"$toString": "$$"}, + error_code=FAILED_TO_PARSE_ERROR, + ), +] + + +@pytest.mark.parametrize( + "test", pytest_params(TOSTRING_ARITY_TESTS + TOSTRING_INVALID_FIELD_PATH_TESTS) +) +def test_toString_arity(collection, test: ExpressionTestCase): + """$toString 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/string/toString/test_toString_binary.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/toString/test_toString_binary.py new file mode 100644 index 000000000..572119001 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/toString/test_toString_binary.py @@ -0,0 +1,176 @@ +"""$toString Binary conversion tests: base64 encoding, UUID format, and subtype handling.""" + +import pytest +from bson import Binary + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) +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 + +# Property [Binary Conversion]: non-UUID Binary values are base64 encoded; subtype 4 with +# exactly 16 bytes converts to UUID string format; subtype 2 (old binary) includes the +# inner 4-byte length prefix in the base64 encoding. +TOSTRING_BINARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "binary_empty_sub0", + msg="Empty Binary (subtype 0) converts to empty string", + expression={"$toString": Binary(b"", 0)}, + expected="", + ), + ExpressionTestCase( + "binary_sub0", + msg="Subtype 0 Binary is base64 encoded", + expression={"$toString": Binary(b"hello", 0)}, + expected="aGVsbG8=", + ), + ExpressionTestCase( + "binary_sub1", + msg="Subtype 1 Binary is base64 encoded", + expression={"$toString": Binary(b"hello", 1)}, + expected="aGVsbG8=", + ), + ExpressionTestCase( + "binary_sub2_old_binary", + msg="Subtype 2 (old binary) includes the inner 4-byte length prefix in base64", + expression={"$toString": Binary(b"hello", 2)}, + expected="BQAAAGhlbGxv", + ), + ExpressionTestCase( + "binary_sub3", + msg="Subtype 3 Binary is base64 encoded", + expression={"$toString": Binary(b"hello", 3)}, + expected="aGVsbG8=", + ), + ExpressionTestCase( + "binary_sub5", + msg="Subtype 5 Binary is base64 encoded", + expression={"$toString": Binary(b"hello", 5)}, + expected="aGVsbG8=", + ), + ExpressionTestCase( + "binary_sub6", + msg="Subtype 6 (encrypted) Binary is base64 encoded", + expression={"$toString": Binary(b"hello", 6)}, + expected="aGVsbG8=", + ), + ExpressionTestCase( + "binary_sub8", + msg="Subtype 8 (sensitive) Binary is base64 encoded", + expression={"$toString": Binary(b"hello", 8)}, + expected="aGVsbG8=", + ), + ExpressionTestCase( + "binary_sub9", + msg="Subtype 9 (vector) Binary is base64 encoded", + expression={"$toString": Binary(b"hello", 9)}, + expected="aGVsbG8=", + ), + ExpressionTestCase( + "binary_sub128", + msg="Subtype 128 Binary is base64 encoded", + expression={"$toString": Binary(b"hello", 128)}, + expected="aGVsbG8=", + ), + ExpressionTestCase( + "binary_sub4_uuid", + msg="Subtype 4 Binary with exactly 16 bytes converts to UUID format", + expression={ + "$toString": Binary( + b"\x12\x34\x56\x78\x12\x34\x12\x34\x12\x34\x12\x34\x56\x78\x90\x12", + 4, + ) + }, + expected="12345678-1234-1234-1234-123456789012", + ), + ExpressionTestCase( + "binary_sub4_15bytes", + msg="Subtype 4 with 15 bytes (not 16) falls back to base64", + expression={ + "$toString": Binary( + b"\x12\x34\x56\x78\x12\x34\x12\x34\x12\x34\x12\x34\x56\x78\x90", + 4, + ) + }, + expected="EjRWeBI0EjQSNBI0VniQ", + ), + ExpressionTestCase( + "binary_sub4_17bytes", + msg="Subtype 4 with 17 bytes (not 16) falls back to base64", + expression={ + "$toString": Binary( + b"\x12\x34\x56\x78\x12\x34\x12\x34\x12\x34\x12\x34\x56\x78\x90\x12\x99", + 4, + ) + }, + expected="EjRWeBI0EjQSNBI0VniQEpk=", + ), + ExpressionTestCase( + "binary_sub4_8bytes", + msg="Subtype 4 with 8 bytes (half of 16) falls back to base64", + expression={"$toString": Binary(b"\x12\x34\x56\x78\x12\x34\x56\x78", 4)}, + expected="EjRWeBI0Vng=", + ), + ExpressionTestCase( + "binary_sub4_32bytes", + msg="Subtype 4 with 32 bytes (double of 16) falls back to base64", + expression={"$toString": Binary(b"\x12\x34\x56\x78" * 8, 4)}, + expected="EjRWeBI0VngSNFZ4EjRWeBI0VngSNFZ4EjRWeBI0Vng=", + ), + ExpressionTestCase( + "binary_16bytes_sub0", + msg="16-byte subtype 0 Binary uses base64, not UUID format", + expression={ + "$toString": Binary( + b"\x12\x34\x56\x78\x12\x34\x12\x34\x12\x34\x12\x34\x56\x78\x90\x12", + 0, + ) + }, + expected="EjRWeBI0EjQSNBI0VniQEg==", + ), + ExpressionTestCase( + "binary_16bytes_sub3", + msg="16-byte subtype 3 Binary uses base64, not UUID format", + expression={ + "$toString": Binary( + b"\x12\x34\x56\x78\x12\x34\x12\x34\x12\x34\x12\x34\x56\x78\x90\x12", + 3, + ) + }, + expected="EjRWeBI0EjQSNBI0VniQEg==", + ), + ExpressionTestCase( + "binary_16bytes_sub5", + msg="16-byte subtype 5 Binary uses base64, not UUID format", + expression={ + "$toString": Binary( + b"\x12\x34\x56\x78\x12\x34\x12\x34\x12\x34\x12\x34\x56\x78\x90\x12", + 5, + ) + }, + expected="EjRWeBI0EjQSNBI0VniQEg==", + ), +] + + +@pytest.mark.parametrize( + "test", + pytest_params( + with_convert_variants( + TOSTRING_BINARY_TESTS, "$toString", "string", extra_convert_fields={"format": "auto"} + ) + ), +) +def test_toString_binary(collection, test: ExpressionTestCase): + """$toString converts Binary to base64 or UUID strings depending on subtype and length.""" + 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/string/toString/test_toString_datetime.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/toString/test_toString_datetime.py new file mode 100644 index 000000000..ea9ec037e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/toString/test_toString_datetime.py @@ -0,0 +1,109 @@ +"""$toString datetime conversion tests: ISO 8601 formatting, timezone, and precision.""" + +from datetime import datetime, timedelta, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) +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 + +# Property [Datetime Conversion]: datetime values are formatted as ISO 8601 +# (YYYY-MM-DDTHH:MM:SS.mmmZ) in UTC with exactly 3 millisecond digits; sub-millisecond +# precision is truncated, not rounded. +TOSTRING_DATETIME_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "datetime_with_ms", + msg="Datetime with milliseconds formats correctly", + expression={"$toString": datetime(2024, 6, 15, 12, 30, 45, 123000, tzinfo=timezone.utc)}, + expected="2024-06-15T12:30:45.123Z", + ), + ExpressionTestCase( + "datetime_no_ms", + msg="Datetime with zero microseconds emits .000Z", + expression={"$toString": datetime(2024, 6, 15, 12, 30, 45, tzinfo=timezone.utc)}, + expected="2024-06-15T12:30:45.000Z", + ), + ExpressionTestCase( + "datetime_epoch", + msg="Unix epoch formats correctly", + expression={"$toString": datetime(1970, 1, 1, tzinfo=timezone.utc)}, + expected="1970-01-01T00:00:00.000Z", + ), + ExpressionTestCase( + "datetime_pre_epoch", + msg="Pre-epoch datetime formats correctly", + expression={"$toString": datetime(1969, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expected="1969-12-31T23:59:59.000Z", + ), + ExpressionTestCase( + "datetime_far_future", + msg="Far-future datetime formats correctly", + expression={"$toString": datetime(9999, 12, 31, 23, 59, 59, 999000, tzinfo=timezone.utc)}, + expected="9999-12-31T23:59:59.999Z", + ), + ExpressionTestCase( + "datetime_year_0001", + msg="Year 0001 formats with zero-padded year", + expression={"$toString": datetime(1, 1, 1, tzinfo=timezone.utc)}, + expected="0001-01-01T00:00:00.000Z", + ), + ExpressionTestCase( + "datetime_leap_day", + msg="Leap day (Feb 29) formats correctly", + expression={"$toString": datetime(2024, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, + expected="2024-02-29T12:00:00.000Z", + ), + ExpressionTestCase( + "datetime_non_utc", + msg="Non-UTC timezone is converted to UTC before formatting", + expression={ + "$toString": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone(timedelta(hours=5))) + }, + expected="2024-06-15T07:00:00.000Z", + ), + ExpressionTestCase( + "datetime_naive", + msg="Naive datetime (no timezone) is treated as UTC", + expression={"$toString": datetime(2024, 1, 1)}, + expected="2024-01-01T00:00:00.000Z", + ), + ExpressionTestCase( + "datetime_sub_ms_truncated", + msg="Sub-millisecond precision is truncated, not rounded", + expression={"$toString": datetime(2024, 1, 1, 0, 0, 0, 123999, tzinfo=timezone.utc)}, + expected="2024-01-01T00:00:00.123Z", + ), + ExpressionTestCase( + "datetime_sub_ms_half", + msg="Sub-millisecond truncation at 0.5ms boundary does not round up", + expression={"$toString": datetime(2024, 1, 1, 0, 0, 0, 500500, tzinfo=timezone.utc)}, + expected="2024-01-01T00:00:00.500Z", + ), + ExpressionTestCase( + "datetime_end_of_day", + msg="End-of-day datetime formats correctly", + expression={"$toString": datetime(2024, 1, 1, 23, 59, 59, 999000, tzinfo=timezone.utc)}, + expected="2024-01-01T23:59:59.999Z", + ), +] + + +@pytest.mark.parametrize( + "test", + pytest_params(with_convert_variants(TOSTRING_DATETIME_TESTS, "$toString", "string")), +) +def test_toString_datetime(collection, test: ExpressionTestCase): + """$toString formats datetime values as ISO 8601 strings in UTC.""" + 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/string/toString/test_toString_decimal128.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/toString/test_toString_decimal128.py new file mode 100644 index 000000000..6d6e45961 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/toString/test_toString_decimal128.py @@ -0,0 +1,219 @@ +"""$toString Decimal128 conversion tests: precision, trailing zeros, exponents, and specials.""" + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) +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_MAX, + DECIMAL128_MAX_NEGATIVE, + DECIMAL128_MIN_POSITIVE, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ONE_AND_HALF, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_TRAILING_ZERO, + DECIMAL128_ZERO, +) + +# Property [Decimal128 Conversion]: Decimal128 values convert to their canonical string +# representation, preserving trailing zeros, signed zeros, and exponent form. +TOSTRING_DECIMAL128_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal_plain_one", + msg="Decimal128 one converts to '1'", + expression={"$toString": Decimal128("1")}, + expected="1", + ), + ExpressionTestCase( + "decimal_plain_zero", + msg="Decimal128 zero converts to '0'", + expression={"$toString": DECIMAL128_ZERO}, + expected="0", + ), + ExpressionTestCase( + "decimal_negative", + msg="Negative Decimal128 converts correctly", + expression={"$toString": DECIMAL128_NEGATIVE_ONE_AND_HALF}, + expected="-1.5", + ), + ExpressionTestCase( + "decimal_trailing_one_zero", + msg="Decimal128 with one trailing zero preserves it", + expression={"$toString": DECIMAL128_TRAILING_ZERO}, + expected="1.0", + ), + ExpressionTestCase( + "decimal_trailing_two_zeros", + msg="Decimal128 with two trailing zeros preserves them", + expression={"$toString": Decimal128("1.00")}, + expected="1.00", + ), + ExpressionTestCase( + "decimal_trailing_max_zeros", + msg="Decimal128 with maximum trailing zeros preserves all 33", + expression={"$toString": Decimal128("1.000000000000000000000000000000000")}, + expected="1.000000000000000000000000000000000", + ), + ExpressionTestCase( + "decimal_neg_zero", + msg="Decimal128 negative zero converts to '-0'", + expression={"$toString": DECIMAL128_NEGATIVE_ZERO}, + expected="-0", + ), + ExpressionTestCase( + "decimal_neg_zero_trailing", + msg="Decimal128 negative zero with trailing zero preserves form", + expression={"$toString": Decimal128("-0.0")}, + expected="-0.0", + ), + ExpressionTestCase( + "decimal_neg_zero_two_trailing", + msg="Decimal128 negative zero with two trailing zeros preserves form", + expression={"$toString": Decimal128("-0.00")}, + expected="-0.00", + ), + ExpressionTestCase( + "decimal_neg_zero_exp", + msg="Decimal128 negative zero with exponent preserves form", + expression={"$toString": Decimal128("-0E+10")}, + expected="-0E+10", + ), + ExpressionTestCase( + "decimal_sci_pos_exp", + msg="Decimal128 uses uppercase E for positive exponent", + expression={"$toString": Decimal128("1E+10")}, + expected="1E+10", + ), + ExpressionTestCase( + "decimal_sci_neg_exp", + msg="Decimal128 uses uppercase E for negative exponent", + expression={"$toString": Decimal128("1E-10")}, + expected="1E-10", + ), + ExpressionTestCase( + "decimal_nan", + msg="Decimal128 NaN converts to 'NaN'", + expression={"$toString": Decimal128("NaN")}, + expected="NaN", + ), + ExpressionTestCase( + "decimal_snan", + msg="Decimal128 sNaN normalizes to 'NaN'", + expression={"$toString": Decimal128("sNaN")}, + expected="NaN", + ), + ExpressionTestCase( + "decimal_neg_nan", + msg="Decimal128 -NaN normalizes to 'NaN'", + expression={"$toString": Decimal128("-NaN")}, + expected="NaN", + ), + ExpressionTestCase( + "decimal_neg_snan", + msg="Decimal128 -sNaN normalizes to 'NaN'", + expression={"$toString": Decimal128("-sNaN")}, + expected="NaN", + ), + ExpressionTestCase( + "decimal_pos_infinity", + msg="Decimal128 Infinity converts to 'Infinity'", + expression={"$toString": DECIMAL128_INFINITY}, + expected="Infinity", + ), + ExpressionTestCase( + "decimal_neg_infinity", + msg="Decimal128 -Infinity converts to '-Infinity'", + expression={"$toString": DECIMAL128_NEGATIVE_INFINITY}, + expected="-Infinity", + ), + ExpressionTestCase( + "decimal_max_precision", + msg="Decimal128 with 34 significant digits preserves full precision", + expression={"$toString": Decimal128("1234567890123456789012345678901234")}, + expected="1234567890123456789012345678901234", + ), + ExpressionTestCase( + "decimal_max_precision_fractional", + msg="Decimal128 with 34 significant digits and decimal point preserves all", + expression={"$toString": Decimal128("9.876543210987654321098765432109876")}, + expected="9.876543210987654321098765432109876", + ), + ExpressionTestCase( + "decimal_max_value", + msg="Decimal128 max value converts to its canonical form", + expression={"$toString": DECIMAL128_MAX}, + expected="9.999999999999999999999999999999999E+6144", + ), + ExpressionTestCase( + "decimal_min_exp", + msg="Decimal128 minimum positive exponent converts correctly", + expression={"$toString": DECIMAL128_MIN_POSITIVE}, + expected="1E-6176", + ), + ExpressionTestCase( + "decimal_max_negative", + msg="Decimal128 most-negative-exponent value converts correctly", + expression={"$toString": DECIMAL128_MAX_NEGATIVE}, + expected="-1E-6176", + ), + ExpressionTestCase( + "decimal_max_exp", + msg="Decimal128 large positive exponent expands to full 34-digit precision", + expression={"$toString": DECIMAL128_LARGE_EXPONENT}, + expected="1.000000000000000000000000000000000E+6144", + ), + ExpressionTestCase( + "decimal_zero_pos_exp", + msg="Decimal128 zero with positive exponent preserves form", + expression={"$toString": Decimal128("0E+10")}, + expected="0E+10", + ), + ExpressionTestCase( + "decimal_zero_neg_exp", + msg="Decimal128 zero with negative exponent preserves form", + expression={"$toString": Decimal128("0E-10")}, + expected="0E-10", + ), + ExpressionTestCase( + "decimal_normalized_100", + msg="Decimal128 1.00E+2 normalizes to '100'", + expression={"$toString": Decimal128("1.00E+2")}, + expected="100", + ), + ExpressionTestCase( + "decimal_normalized_10", + msg="Decimal128 1.0E+1 normalizes to '10'", + expression={"$toString": Decimal128("1.0E+1")}, + expected="10", + ), + ExpressionTestCase( + "decimal_small_exp_normalized", + msg="Decimal128 3E-5 normalizes to decimal notation '0.00003'", + expression={"$toString": Decimal128("3E-5")}, + expected="0.00003", + ), +] + + +@pytest.mark.parametrize( + "test", + pytest_params(with_convert_variants(TOSTRING_DECIMAL128_TESTS, "$toString", "string")), +) +def test_toString_decimal128(collection, test: ExpressionTestCase): + """$toString converts Decimal128 values to their canonical string representation.""" + 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/string/toString/test_toString_double.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/toString/test_toString_double.py new file mode 100644 index 000000000..c1f05cf9a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/toString/test_toString_double.py @@ -0,0 +1,182 @@ +"""$toString double conversion tests: special values, scientific notation, and precision.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) +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 ( + DOUBLE_MAX, + DOUBLE_MIN, + DOUBLE_MIN_NEGATIVE_SUBNORMAL, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Property [Double Conversion]: double values convert to their string representation, +# using scientific notation for magnitudes >= 1e16 or < 1e-4, and preserving special +# values like NaN, Infinity, and negative zero. +TOSTRING_DOUBLE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "double_fractional", + msg="Fractional double converts to its string representation", + expression={"$toString": 3.14}, + expected="3.14", + ), + ExpressionTestCase( + "double_negative_fractional", + msg="Negative fractional double converts correctly", + expression={"$toString": -3.14}, + expected="-3.14", + ), + ExpressionTestCase( + "double_whole", + msg="Whole double converts without trailing .0", + expression={"$toString": 3.0}, + expected="3", + ), + ExpressionTestCase( + "double_negative_whole", + msg="Negative whole double converts correctly", + expression={"$toString": -3.0}, + expected="-3", + ), + ExpressionTestCase( + "double_zero", + msg="Double zero converts to '0'", + expression={"$toString": DOUBLE_ZERO}, + expected="0", + ), + ExpressionTestCase( + "double_negative_zero", + msg="Double negative zero converts to '-0'", + expression={"$toString": DOUBLE_NEGATIVE_ZERO}, + expected="-0", + ), + ExpressionTestCase( + "double_nan", + msg="Double NaN converts to 'NaN'", + expression={"$toString": FLOAT_NAN}, + expected="NaN", + ), + ExpressionTestCase( + "double_pos_infinity", + msg="Infinity converts to 'Infinity'", + expression={"$toString": FLOAT_INFINITY}, + expected="Infinity", + ), + ExpressionTestCase( + "double_neg_infinity", + msg="-Infinity converts to '-Infinity'", + expression={"$toString": FLOAT_NEGATIVE_INFINITY}, + expected="-Infinity", + ), + ExpressionTestCase( + "double_sci_lower_bound", + msg="1e16 crosses the scientific notation threshold", + expression={"$toString": 1e16}, + expected="1e+16", + ), + ExpressionTestCase( + "double_sci_lower_bound_neg", + msg="-1e16 crosses the scientific notation threshold", + expression={"$toString": -1e16}, + expected="-1e+16", + ), + ExpressionTestCase( + "double_just_below_sci_threshold", + msg="Value just below 1e16 uses decimal notation", + expression={"$toString": 9.999999999999998e15}, + expected="9999999999999998", + ), + ExpressionTestCase( + "double_decimal_large", + msg="1e15 uses decimal notation", + expression={"$toString": 1e15}, + expected="1000000000000000", + ), + ExpressionTestCase( + "double_sci_small", + msg="9e-05 is below 1e-4 and uses scientific notation", + expression={"$toString": 9e-05}, + expected="9e-05", + ), + ExpressionTestCase( + "double_sci_small_neg", + msg="-9e-05 uses scientific notation", + expression={"$toString": -9e-05}, + expected="-9e-05", + ), + ExpressionTestCase( + "double_decimal_small_boundary", + msg="1e-4 is the inclusive lower boundary for decimal notation", + expression={"$toString": 1e-4}, + expected="0.0001", + ), + ExpressionTestCase( + "double_17_sig_digits", + msg="17 significant digits of precision are preserved", + expression={"$toString": 1.0000000000000002}, + expected="1.0000000000000002", + ), + ExpressionTestCase( + "double_subnormal_min", + msg="Minimum positive subnormal double converts correctly", + expression={"$toString": DOUBLE_MIN_SUBNORMAL}, + expected="5e-324", + ), + ExpressionTestCase( + "double_subnormal_min_neg", + msg="Minimum negative subnormal double converts correctly", + expression={"$toString": DOUBLE_MIN_NEGATIVE_SUBNORMAL}, + expected="-5e-324", + ), + ExpressionTestCase( + "double_subnormal_boundary", + msg="Subnormal boundary double converts correctly", + expression={"$toString": 2.2250738585072009e-308}, + expected="2.225073858507201e-308", + ), + ExpressionTestCase( + "double_near_max", + msg="Near-max double converts correctly", + expression={"$toString": DOUBLE_MAX}, + expected="1.7976931348623157e+308", + ), + ExpressionTestCase( + "double_near_max_neg", + msg="Near-max negative double converts correctly", + expression={"$toString": DOUBLE_MIN}, + expected="-1.7976931348623157e+308", + ), + ExpressionTestCase( + "double_ieee754_artifact", + msg="IEEE 754 precision artifact from 0.1 + 0.2 is faithfully represented", + expression={"$toString": {"$add": [0.1, 0.2]}}, + expected="0.30000000000000004", + ), +] + + +@pytest.mark.parametrize( + "test", + pytest_params(with_convert_variants(TOSTRING_DOUBLE_TESTS, "$toString", "string")), +) +def test_toString_double(collection, test: ExpressionTestCase): + """$toString converts double inputs to their string representation.""" + 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/string/toString/test_toString_numeric.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/toString/test_toString_numeric.py new file mode 100644 index 000000000..05c64a778 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/toString/test_toString_numeric.py @@ -0,0 +1,183 @@ +"""$toString null, boolean, int32, and int64 conversion tests.""" + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) +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 ( + 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]: $toString returns null for null and missing inputs. +TOSTRING_NULL_MISSING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null", + msg="$toString returns null for null input", + expression={"$toString": None}, + expected=None, + ), + ExpressionTestCase( + "missing", + msg="$toString returns null for missing field", + expression={"$toString": MISSING}, + expected=None, + ), +] + +# Property [Boolean Conversion]: true converts to "true", false converts to "false". +TOSTRING_BOOL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "bool_true", + msg="true converts to 'true'", + expression={"$toString": True}, + expected="true", + ), + ExpressionTestCase( + "bool_false", + msg="false converts to 'false'", + expression={"$toString": False}, + expected="false", + ), +] + +# Property [Int32 Conversion]: int32 values convert to their decimal string representation. +TOSTRING_INT32_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_positive", + msg="Positive int32 converts to its decimal string", + expression={"$toString": 42}, + expected="42", + ), + ExpressionTestCase( + "int32_zero", + msg="int32 zero converts to '0'", + expression={"$toString": INT32_ZERO}, + expected="0", + ), + ExpressionTestCase( + "int32_negative", + msg="Negative int32 converts to its decimal string", + expression={"$toString": -1}, + expected="-1", + ), + ExpressionTestCase( + "int32_min", + msg="INT32_MIN converts to its decimal string", + expression={"$toString": INT32_MIN}, + expected=str(INT32_MIN), + ), + ExpressionTestCase( + "int32_min_plus_one", + msg="INT32_MIN + 1 converts to its decimal string", + expression={"$toString": INT32_MIN_PLUS_1}, + expected=str(INT32_MIN_PLUS_1), + ), + ExpressionTestCase( + "int32_max", + msg="INT32_MAX converts to its decimal string", + expression={"$toString": INT32_MAX}, + expected=str(INT32_MAX), + ), + ExpressionTestCase( + "int32_max_minus_one", + msg="INT32_MAX - 1 converts to its decimal string", + expression={"$toString": INT32_MAX_MINUS_1}, + expected=str(INT32_MAX_MINUS_1), + ), +] + +# Property [Int64 Conversion]: int64 values convert to their decimal string representation; +# int32 and int64 produce identical strings for the same numeric value. +TOSTRING_INT64_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int64_positive", + msg="Positive int64 converts to its decimal string", + expression={"$toString": Int64(42)}, + expected="42", + ), + ExpressionTestCase( + "int64_zero", + msg="int64 zero converts to '0'", + expression={"$toString": INT64_ZERO}, + expected="0", + ), + ExpressionTestCase( + "int64_negative", + msg="Negative int64 converts to its decimal string", + expression={"$toString": Int64(-1)}, + expected="-1", + ), + ExpressionTestCase( + "int64_beyond_int32_max", + msg="int64 just beyond INT32_MAX converts to '2147483648'", + expression={"$toString": INT32_OVERFLOW}, + expected="2147483648", + ), + ExpressionTestCase( + "int64_beyond_int32_min", + msg="int64 just beyond INT32_MIN converts to '-2147483649'", + expression={"$toString": INT32_UNDERFLOW}, + expected="-2147483649", + ), + ExpressionTestCase( + "int64_min", + msg="INT64_MIN converts to its decimal string", + expression={"$toString": INT64_MIN}, + expected=str(INT64_MIN), + ), + ExpressionTestCase( + "int64_min_plus_one", + msg="INT64_MIN + 1 converts to its decimal string", + expression={"$toString": INT64_MIN_PLUS_1}, + expected=str(INT64_MIN_PLUS_1), + ), + ExpressionTestCase( + "int64_max", + msg="INT64_MAX converts to its decimal string", + expression={"$toString": INT64_MAX}, + expected=str(INT64_MAX), + ), + ExpressionTestCase( + "int64_max_minus_one", + msg="INT64_MAX - 1 converts to its decimal string", + expression={"$toString": INT64_MAX_MINUS_1}, + expected=str(INT64_MAX_MINUS_1), + ), +] + +TOSTRING_NUMERIC_TESTS = with_convert_variants( + TOSTRING_NULL_MISSING_TESTS + TOSTRING_BOOL_TESTS + TOSTRING_INT32_TESTS + TOSTRING_INT64_TESTS, + "$toString", + "string", +) + + +@pytest.mark.parametrize("test", pytest_params(TOSTRING_NUMERIC_TESTS)) +def test_toString_numeric(collection, test: ExpressionTestCase): + """$toString converts null, bool, int32, and int64 inputs to strings.""" + 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/string/toString/test_toString_objectid.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/toString/test_toString_objectid.py new file mode 100644 index 000000000..565f83083 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/toString/test_toString_objectid.py @@ -0,0 +1,57 @@ +"""$toString ObjectId conversion tests: hex string output and case normalization.""" + +import pytest +from bson import ObjectId + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) +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 + +# Property [ObjectId Conversion]: ObjectId values convert to 24-character lowercase +# hexadecimal strings; mixed-case input to the ObjectId constructor is normalized. +TOSTRING_OBJECTID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "objectid_normal", + msg="ObjectId converts to its 24-character lowercase hex string", + expression={"$toString": ObjectId("507f1f77bcf86cd799439011")}, + expected="507f1f77bcf86cd799439011", + ), + ExpressionTestCase( + "objectid_all_zeros", + msg="All-zero ObjectId converts to 24 zero hex characters", + expression={"$toString": ObjectId("000000000000000000000000")}, + expected="000000000000000000000000", + ), + ExpressionTestCase( + "objectid_all_fs", + msg="All-f ObjectId converts to 24 'f' hex characters", + expression={"$toString": ObjectId("ffffffffffffffffffffffff")}, + expected="ffffffffffffffffffffffff", + ), + ExpressionTestCase( + "objectid_mixed_case_normalized", + msg="Mixed-case hex in the ObjectId constructor is normalized to lowercase", + expression={"$toString": ObjectId("507F1F77BCF86CD799439011")}, + expected="507f1f77bcf86cd799439011", + ), +] + + +@pytest.mark.parametrize( + "test", + pytest_params(with_convert_variants(TOSTRING_OBJECTID_TESTS, "$toString", "string")), +) +def test_toString_objectid(collection, test: ExpressionTestCase): + """$toString converts ObjectId values to 24-character lowercase hex strings.""" + 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/string/toString/test_toString_return_type.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/toString/test_toString_return_type.py new file mode 100644 index 000000000..332cd26f7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/toString/test_toString_return_type.py @@ -0,0 +1,217 @@ +"""$toString return type, type rejection, idempotency, and field reference tests.""" + +import pytest +from bson import Decimal128, Int64, ObjectId + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) +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.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) +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 DOUBLE_HALF, MISSING + +# Property [Return Type / Type Rejection]: BOOL, DOUBLE, INT, LONG, DECIMAL, STRING, DATE, +# OBJECT_ID, and BIN_DATA are the accepted types; all others produce a conversion failure. +# NULL is skipped from rejection because it returns null (not an error). +# ARRAY is skipped because it triggers arity semantics tested in test_toString_arity.py. +TOSTRING_BSON_TYPE_SPEC = [ + BsonTypeTestCase( + id="toString", + msg="$toString BSON type", + valid_types=[ + BsonType.BOOL, + BsonType.DOUBLE, + BsonType.INT, + BsonType.LONG, + BsonType.DECIMAL, + BsonType.STRING, + BsonType.DATE, + BsonType.OBJECT_ID, + BsonType.BIN_DATA, + ], + skip_rejection_types=[BsonType.NULL, BsonType.ARRAY], + default_error_code=CONVERSION_FAILURE_ERROR, + ), +] + +TOSTRING_ACCEPTANCE_CASES = generate_bson_acceptance_test_cases(TOSTRING_BSON_TYPE_SPEC) +TOSTRING_REJECTION_CASES = generate_bson_rejection_test_cases(TOSTRING_BSON_TYPE_SPEC) + +_STRING_EXPR_FORMS = [ + pytest.param(lambda v: {"$toString": v}, id="toString"), + pytest.param( + lambda v: {"$convert": {"input": v, "to": "string", "format": "auto"}}, id="convert" + ), +] + + +@pytest.mark.parametrize("expr_fn", _STRING_EXPR_FORMS) +@pytest.mark.parametrize("bson_type,sample_value,spec", TOSTRING_ACCEPTANCE_CASES) +def test_toString_return_type_is_string(collection, bson_type, sample_value, spec, expr_fn): + """$toString and $convert to string always return BSON type 'string'.""" + result = execute_expression(collection, {"$type": expr_fn(sample_value)}) + assert_expression_result(result, expected="string", msg=f"{spec.msg} ({bson_type.value} input)") + + +@pytest.mark.parametrize("expr_fn", _STRING_EXPR_FORMS) +@pytest.mark.parametrize("bson_type,sample_value,spec", TOSTRING_REJECTION_CASES) +def test_toString_type_rejection(collection, bson_type, sample_value, spec, expr_fn): + """$toString and $convert to string reject unsupported BSON types with a conversion failure.""" + result = execute_expression(collection, expr_fn(sample_value)) + assert_expression_result( + result, + error_code=spec.expected_code(bson_type), + msg=f"{spec.msg} ({bson_type.value} rejected)", + ) + + +# Property [Return Type — Null]: $toString returns BSON type null for null or missing inputs. +TOSTRING_RETURN_TYPE_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null", + msg="$toString of null returns BSON type 'null'", + expression={"$type": {"$toString": None}}, + expected="null", + ), + ExpressionTestCase( + "missing", + msg="$toString of a missing field returns BSON type 'null'", + expression={"$type": {"$toString": MISSING}}, + expected="null", + ), +] + +# Property [Idempotency]: applying $toString twice produces the same result as once, +# since the output of $toString is always a string, which $toString passes through unchanged. +TOSTRING_IDEMPOTENCY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "idempotent_int32", + msg="$toString is idempotent for int32", + expression={"$toString": {"$toString": -1}}, + expected="-1", + ), + ExpressionTestCase( + "idempotent_int64", + msg="$toString is idempotent for int64", + expression={"$toString": {"$toString": Int64(99)}}, + expected="99", + ), + ExpressionTestCase( + "idempotent_double", + msg="$toString is idempotent for double", + expression={"$toString": {"$toString": DOUBLE_HALF}}, + expected="0.5", + ), + ExpressionTestCase( + "idempotent_bool", + msg="$toString is idempotent for bool", + expression={"$toString": {"$toString": False}}, + expected="false", + ), + ExpressionTestCase( + "idempotent_decimal128", + msg="$toString is idempotent for Decimal128", + expression={"$toString": {"$toString": Decimal128("1.50")}}, + expected="1.50", + ), + ExpressionTestCase( + "idempotent_objectid", + msg="$toString is idempotent for ObjectId", + expression={"$toString": {"$toString": ObjectId("aaaaaaaaaaaaaaaaaaaaaaaa")}}, + expected="aaaaaaaaaaaaaaaaaaaaaaaa", + ), +] + +# Property [Field Reference]: $toString resolves field paths from inserted documents; +# missing fields return null. +TOSTRING_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_field", + msg="int32 document field converts to its decimal string", + expression={"$toString": "$v"}, + doc={"v": 42}, + expected="42", + ), + ExpressionTestCase( + "string_field", + msg="String document field passes through unchanged", + expression={"$toString": "$v"}, + doc={"v": "hello"}, + expected="hello", + ), + ExpressionTestCase( + "double_field", + msg="double document field converts to its string representation", + expression={"$toString": "$v"}, + doc={"v": 3.14}, + expected="3.14", + ), + ExpressionTestCase( + "bool_field", + msg="bool document field converts to 'true' or 'false'", + expression={"$toString": "$v"}, + doc={"v": True}, + expected="true", + ), + ExpressionTestCase( + "nested_field", + msg="Nested dot-notation field path resolves and converts", + expression={"$toString": "$doc.v"}, + doc={"doc": {"v": 42}}, + expected="42", + ), + ExpressionTestCase( + "missing_field", + msg="Missing top-level field returns null", + expression={"$toString": "$v"}, + doc={}, + expected=None, + ), + ExpressionTestCase( + "missing_nested_field", + msg="Missing nested field returns null", + expression={"$toString": "$doc.missing"}, + doc={"doc": {"x": 1}}, + expected=None, + ), + ExpressionTestCase( + "composite_array_path", + msg="Field path resolving to a composite array is a conversion failure", + expression={"$toString": "$a.b"}, + doc={"a": [{"b": 1}, {"b": 2}]}, + error_code=CONVERSION_FAILURE_ERROR, + ), +] + + +@pytest.mark.parametrize( + "test", + pytest_params( + TOSTRING_RETURN_TYPE_NULL_TESTS + + TOSTRING_IDEMPOTENCY_TESTS + + with_convert_variants(TOSTRING_FIELD_REF_TESTS, "$toString", "string") + ), +) +def test_toString_return_type_null_idempotency_and_field_ref(collection, test: ExpressionTestCase): + """$toString returns null for null/missing, is idempotent, and resolves field paths.""" + 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/string/toString/test_toString_size_limit.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/toString/test_toString_size_limit.py new file mode 100644 index 000000000..09d8a7ef0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/toString/test_toString_size_limit.py @@ -0,0 +1,78 @@ +"""$toString size limit tests: string and binary inputs at and near the 16 MB limit.""" + +import pytest +from bson import Binary + +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 Size Limit Success]: input strings just under the size limit are accepted. +TOSTRING_STRING_SIZE_SUCCESS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_one_under", + msg="String one byte under the 16 MB limit passes through", + expression=lazy(lambda: {"$toString": "a" * (STRING_SIZE_LIMIT_BYTES - 1)}), + expected=lazy(lambda: "a" * (STRING_SIZE_LIMIT_BYTES - 1)), + ), +] + +# Property [String Size Limit]: input strings at or above the size limit produce an error. +TOSTRING_STRING_SIZE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_at_limit", + msg="String at the 16 MB byte limit is rejected", + expression=lazy(lambda: {"$toString": "a" * STRING_SIZE_LIMIT_BYTES}), + error_code=STRING_SIZE_LIMIT_ERROR, + ), +] + +# Binary base64 encoding: every 3 input bytes produce 4 output characters. +_BINARY_UNDER_LIMIT_BYTES = ((STRING_SIZE_LIMIT_BYTES - 1) // 4) * 3 +_BINARY_AT_LIMIT_BYTES = (STRING_SIZE_LIMIT_BYTES // 4) * 3 + +# Property [Binary Size Limit Success]: binary values whose base64 output fits within the +# string size limit are accepted. +TOSTRING_BINARY_SIZE_SUCCESS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "binary_under_limit", + msg="Binary whose base64 output is just under the 16 MB limit is accepted", + expression=lazy(lambda: {"$toString": Binary(b"\x00" * _BINARY_UNDER_LIMIT_BYTES)}), + expected=lazy(lambda: "A" * (_BINARY_UNDER_LIMIT_BYTES // 3 * 4)), + ), +] + +# Property [Binary Size Limit]: binary values whose base64 output reaches the string size +# limit produce a conversion failure. +TOSTRING_BINARY_SIZE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "binary_at_limit", + msg="Binary whose base64 output reaches the 16 MB limit is rejected", + expression=lazy(lambda: {"$toString": Binary(b"\x00" * _BINARY_AT_LIMIT_BYTES)}), + error_code=CONVERSION_FAILURE_ERROR, + ), +] + +TOSTRING_SIZE_LIMIT_TESTS = ( + TOSTRING_STRING_SIZE_SUCCESS_TESTS + + TOSTRING_STRING_SIZE_ERROR_TESTS + + TOSTRING_BINARY_SIZE_SUCCESS_TESTS + + TOSTRING_BINARY_SIZE_ERROR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(TOSTRING_SIZE_LIMIT_TESTS)) +def test_toString_size_limit(collection, test: ExpressionTestCase): + """$toString enforces the 16 MB string size limit for both string and binary 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/string/toString/test_toString_string_values.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/toString/test_toString_string_values.py new file mode 100644 index 000000000..f045db4f9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/toString/test_toString_string_values.py @@ -0,0 +1,166 @@ +"""$toString string passthrough, encoding, and expression argument tests.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) +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 + +# Property [String Passthrough]: string input is returned unchanged, preserving whitespace, +# control characters, BSON-meaningful characters, and dollar-prefixed strings via $literal. +TOSTRING_STRING_PASSTHROUGH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_plain", + msg="Plain string passes through unchanged", + expression={"$toString": "hello"}, + expected="hello", + ), + ExpressionTestCase( + "string_empty", + msg="Empty string passes through unchanged", + expression={"$toString": ""}, + expected="", + ), + ExpressionTestCase( + "string_whitespace", + msg="Whitespace characters are preserved", + expression={"$toString": " \t\n\r\u00a0\u2003"}, + expected=" \t\n\r\u00a0\u2003", + ), + ExpressionTestCase( + "string_control_chars", + msg="Null bytes and control characters are preserved", + expression={"$toString": "\x00\x01\x1f"}, + expected="\x00\x01\x1f", + ), + ExpressionTestCase( + "string_bson_meaningful_chars", + msg="BSON-meaningful characters are preserved", + expression={"$toString": '{}"\\ [],:'}, + expected='{}"\\ [],:', + ), + ExpressionTestCase( + "string_dollar_prefix_literal", + msg="Dollar-prefixed string via $literal is not treated as a field reference", + expression={"$toString": {"$literal": "$fieldName"}}, + expected="$fieldName", + ), +] + +# Property [Encoding and Character Handling]: multi-byte UTF-8 characters, Unicode +# normalization forms, zero-width and invisible characters, ZWJ emoji sequences, and +# combining marks are all preserved unchanged through $toString. +TOSTRING_ENCODING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "encoding_2byte_latin", + msg="2-byte Latin extended character (é) is preserved", + expression={"$toString": "café"}, + expected="café", + ), + ExpressionTestCase( + "encoding_3byte_cjk", + msg="3-byte CJK characters are preserved", + expression={"$toString": "日本語"}, + expected="日本語", + ), + ExpressionTestCase( + "encoding_4byte_emoji", + msg="4-byte emoji are preserved", + expression={"$toString": "🚀🎉"}, + expected="🚀🎉", + ), + ExpressionTestCase( + "encoding_4byte_deseret", + msg="4-byte Deseret character (U+10400) is preserved", + expression={"$toString": "\U00010400"}, + expected="\U00010400", + ), + ExpressionTestCase( + "encoding_precomposed", + msg="Precomposed character (U+00E9) is preserved as-is", + expression={"$toString": "\u00e9"}, + expected="\u00e9", + ), + ExpressionTestCase( + "encoding_decomposed", + msg="Decomposed character (U+0065 U+0301) is preserved as-is", + expression={"$toString": "e\u0301"}, + expected="e\u0301", + ), + ExpressionTestCase( + "encoding_bom", + msg="BOM (U+FEFF) is preserved", + expression={"$toString": "\ufeff"}, + expected="\ufeff", + ), + ExpressionTestCase( + "encoding_zwsp", + msg="Zero-width space (U+200B) is preserved", + expression={"$toString": "\u200b"}, + expected="\u200b", + ), + ExpressionTestCase( + "encoding_zwj", + msg="Zero-width joiner (U+200D) is preserved", + expression={"$toString": "\u200d"}, + expected="\u200d", + ), + ExpressionTestCase( + "encoding_directional_marks", + msg="Directional marks (RTL U+200F, LTR U+200E) are preserved", + expression={"$toString": "\u200f\u200e"}, + expected="\u200f\u200e", + ), + ExpressionTestCase( + "encoding_zwj_emoji_sequence", + msg="ZWJ emoji sequence (👨‍💻) is preserved", + expression={"$toString": "👨\u200d💻"}, + expected="👨\u200d💻", + ), + ExpressionTestCase( + "encoding_combining_mark", + msg="Combining tilde (U+0303) on 'n' is preserved", + expression={"$toString": "n\u0303"}, + expected="n\u0303", + ), +] + +# Property [Expression Arguments]: $toString accepts any expression that resolves to a +# convertible type and converts the result. +TOSTRING_EXPR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "expr_int_result", + msg="$toString converts the result of an integer expression", + expression={"$toString": {"$add": [10, 20]}}, + expected="30", + ), + ExpressionTestCase( + "expr_string_result", + msg="$toString passes through the result of a string expression", + expression={"$toString": {"$concat": ["hello", " ", "world"]}}, + expected="hello world", + ), +] + +TOSTRING_STRING_VALUES_TESTS = with_convert_variants( + TOSTRING_STRING_PASSTHROUGH_TESTS + TOSTRING_ENCODING_TESTS + TOSTRING_EXPR_TESTS, + "$toString", + "string", +) + + +@pytest.mark.parametrize("test", pytest_params(TOSTRING_STRING_VALUES_TESTS)) +def test_toString_string_values(collection, test: ExpressionTestCase): + """$toString passes strings through unchanged and converts expression results.""" + 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/string/toString/test_toString_type_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/toString/test_toString_type_errors.py new file mode 100644 index 000000000..15ee095b8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/toString/test_toString_type_errors.py @@ -0,0 +1,53 @@ +"""$toString type rejection tests: special-form inputs not covered by bson_type_validator.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) +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 + +# Property [Type Rejection — Special Forms]: cases not generated by bson_type_validator in +# test_toString_return_type.py because they require specific expression forms or subtypes. +# The sample BSON type sweep (MinKey, MaxKey, Timestamp, Regex, Code, Object) is covered +# by test_toString_type_rejection in test_toString_return_type.py. +TOSTRING_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "type_nested_array", + msg="Nested array via $literal is a conversion failure", + expression={"$toString": {"$literal": [[1, 2], [3, 4]]}}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "type_runtime_array", + msg="Runtime array produced by $split is a conversion failure", + expression={"$toString": {"$split": ["a,b", ","]}}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "type_runtime_object", + msg="Runtime object produced by $mergeObjects is a conversion failure", + expression={"$toString": {"$mergeObjects": [{"a": 1}]}}, + error_code=CONVERSION_FAILURE_ERROR, + ), +] + + +@pytest.mark.parametrize( + "test", + pytest_params(with_convert_variants(TOSTRING_TYPE_ERROR_TESTS, "$toString", "string")), +) +def test_toString_type_errors(collection, test: ExpressionTestCase): + """$toString rejects unsupported BSON types with a conversion failure.""" + 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/toBool/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toBool/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toBool/test_toBool_arity.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toBool/test_toBool_arity.py new file mode 100644 index 000000000..c086558cd --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toBool/test_toBool_arity.py @@ -0,0 +1,86 @@ +"""$toBool 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 +from documentdb_tests.framework.test_constants import INT32_ZERO + +# Property [Arity]: $toBool unwraps single-element literal arrays and rejects empty or +# multi-element arrays. These cases are specific to $toBool syntax (not $convert). +TOBOOL_ARITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "single_element", + msg="Single-element literal array wrapping a falsy value unwraps and converts to false", + expression={"$toBool": [False]}, + expected=False, + ), + ExpressionTestCase( + "single_null", + msg="Single-element literal array wrapping null unwraps and returns null", + expression={"$toBool": [None]}, + expected=None, + ), + ExpressionTestCase( + "single_nested_array", + msg="Single-element array wrapping an inner array unwraps; the inner array is truthy", + expression={"$toBool": [[INT32_ZERO]]}, + expected=True, + ), + ExpressionTestCase( + "empty_array", + msg="Empty literal array argument is an arity error", + expression={"$toBool": []}, + error_code=TO_TYPE_ARITY_ERROR, + ), + ExpressionTestCase( + "two_elements", + msg="Two-element literal array argument is an arity error", + expression={"$toBool": [1, 2]}, + error_code=TO_TYPE_ARITY_ERROR, + ), + ExpressionTestCase( + "large_array", + msg="Large literal array argument is an arity error", + expression={"$toBool": list(range(100))}, + error_code=TO_TYPE_ARITY_ERROR, + ), +] + +# Property [Invalid Field Path]: $toBool rejects malformed field path syntax. +TOBOOL_INVALID_FIELD_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "bare_dollar", + msg="Bare '$' is an invalid field path", + expression={"$toBool": "$"}, + error_code=INVALID_DOLLAR_FIELD_PATH, + ), + ExpressionTestCase( + "double_dollar", + msg="'$$' is rejected as an empty variable name", + expression={"$toBool": "$$"}, + error_code=FAILED_TO_PARSE_ERROR, + ), +] + + +@pytest.mark.parametrize( + "test", pytest_params(TOBOOL_ARITY_TESTS + TOBOOL_INVALID_FIELD_PATH_TESTS) +) +def test_toBool_arity(collection, test: ExpressionTestCase): + """$toBool 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/toBool/test_toBool_field_ref.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toBool/test_toBool_field_ref.py new file mode 100644 index 000000000..ef5ef0e63 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toBool/test_toBool_field_ref.py @@ -0,0 +1,139 @@ +"""$toBool field reference and expression-as-input tests.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import INT32_ZERO + +# Property [Field Reference]: $toBool resolves field paths and nested dot-notation paths; +# missing fields return null. +TOBOOL_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "truthy_string_field", + msg="String field value converts to true", + expression={"$toBool": "$v"}, + doc={"v": "hello"}, + expected=True, + ), + ExpressionTestCase( + "falsy_int_field", + msg="Zero integer field value converts to false", + expression={"$toBool": "$v"}, + doc={"v": INT32_ZERO}, + expected=False, + ), + ExpressionTestCase( + "truthy_int_field", + msg="Non-zero integer field value converts to true", + expression={"$toBool": "$v"}, + doc={"v": 1}, + expected=True, + ), + ExpressionTestCase( + "bool_true_field", + msg="true boolean field passes through unchanged", + expression={"$toBool": "$v"}, + doc={"v": True}, + expected=True, + ), + ExpressionTestCase( + "bool_false_field", + msg="false boolean field passes through unchanged", + expression={"$toBool": "$v"}, + doc={"v": False}, + expected=False, + ), + ExpressionTestCase( + "null_field", + msg="Null document field returns null", + expression={"$toBool": "$v"}, + doc={"v": None}, + expected=None, + ), + ExpressionTestCase( + "missing_field", + msg="Missing top-level field returns null", + expression={"$toBool": "$v"}, + doc={}, + expected=None, + ), + ExpressionTestCase( + "nested_field", + msg="Nested dot-notation field path resolves and converts", + expression={"$toBool": "$doc.v"}, + doc={"doc": {"v": "hello"}}, + expected=True, + ), + ExpressionTestCase( + "missing_nested_field", + msg="Missing nested field returns null", + expression={"$toBool": "$doc.missing"}, + doc={"doc": {"x": 1}}, + expected=None, + ), + ExpressionTestCase( + "composite_array_path", + msg="Field path resolving to a composite array (array-of-objects) is truthy", + expression={"$toBool": "$a.b"}, + doc={"a": [{"b": 1}, {"b": 2}]}, + expected=True, + ), +] + +# Property [Expression Input]: $toBool evaluates a nested expression before converting. +TOBOOL_EXPRESSION_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "add_result", + msg="$toBool converts an arithmetic expression result", + expression={"$toBool": {"$add": [1, 2]}}, + expected=True, + ), + ExpressionTestCase( + "empty_object_argument", + msg="An empty object argument evaluates to an empty document, which is truthy", + expression={"$toBool": {}}, + expected=True, + ), + ExpressionTestCase( + "tostring_zero", + msg="$toBool converts $toString(0) to true because the string '0' is truthy", + expression={"$toBool": {"$toString": INT32_ZERO}}, + expected=True, + ), + ExpressionTestCase( + "todouble_zero_string", + msg="$toBool converts $toDouble('0.0') to false because double 0.0 is falsy", + expression={"$toBool": {"$toDouble": "0.0"}}, + expected=False, + ), +] + + +@pytest.mark.parametrize( + "test", + pytest_params( + with_convert_variants( + TOBOOL_FIELD_REF_TESTS + TOBOOL_EXPRESSION_INPUT_TESTS, "$toBool", "bool" + ) + ), +) +def test_toBool_field_ref(collection, test: ExpressionTestCase): + """$toBool resolves field paths from inserted documents and evaluates nested expressions.""" + 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/toBool/test_toBool_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toBool/test_toBool_null.py new file mode 100644 index 000000000..ca32756e1 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toBool/test_toBool_null.py @@ -0,0 +1,44 @@ +"""$toBool null and missing input tests.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) +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 MISSING + +# Property [Null and Missing]: $toBool returns null for null and missing inputs. +TOBOOL_NULL_MISSING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null", + msg="Should return null for null input", + expression={"$toBool": None}, + expected=None, + ), + ExpressionTestCase( + "missing", + msg="Should return null for missing field", + expression={"$toBool": MISSING}, + expected=None, + ), +] + + +@pytest.mark.parametrize( + "test", + pytest_params(with_convert_variants(TOBOOL_NULL_MISSING_TESTS, "$toBool", "bool")), +) +def test_toBool_null(collection, test: ExpressionTestCase): + """$toBool returns null for null and missing 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/toBool/test_toBool_numeric.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toBool/test_toBool_numeric.py new file mode 100644 index 000000000..9d4175c91 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toBool/test_toBool_numeric.py @@ -0,0 +1,268 @@ +"""$toBool numeric truthiness tests: falsy zeros, truthy non-zeros, and special float values.""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) +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_MANY_TRAILING_ZEROS, + DECIMAL128_MAX, + DECIMAL128_MAX_NEGATIVE, + DECIMAL128_MIN, + DECIMAL128_MIN_POSITIVE, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_HALF, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEAR_MAX, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT32_ZERO, + INT64_MAX, + INT64_MIN, + INT64_ZERO, +) + +# Property [Numeric Truthiness - Falsy Zeros]: numeric zero values produce false regardless +# of type, sign, exponent, or trailing zeros. +TOBOOL_FALSY_ZERO_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "falsy_int32_zero", + msg="int32 zero converts to false", + expression={"$toBool": INT32_ZERO}, + expected=False, + ), + ExpressionTestCase( + "falsy_int64_zero", + msg="int64 zero converts to false", + expression={"$toBool": INT64_ZERO}, + expected=False, + ), + ExpressionTestCase( + "falsy_double_zero", + msg="double 0.0 converts to false", + expression={"$toBool": DOUBLE_ZERO}, + expected=False, + ), + ExpressionTestCase( + "falsy_double_negative_zero", + msg="double -0.0 converts to false", + expression={"$toBool": DOUBLE_NEGATIVE_ZERO}, + expected=False, + ), + ExpressionTestCase( + "falsy_decimal_zero", + msg="Decimal128 zero converts to false", + expression={"$toBool": DECIMAL128_ZERO}, + expected=False, + ), + ExpressionTestCase( + "falsy_decimal_negative_zero", + msg="Decimal128 negative zero converts to false", + expression={"$toBool": DECIMAL128_NEGATIVE_ZERO}, + expected=False, + ), + ExpressionTestCase( + "falsy_decimal_zero_point_zero", + msg="Decimal128 0.0 converts to false", + expression={"$toBool": Decimal128("0.0")}, + expected=False, + ), + ExpressionTestCase( + "falsy_decimal_zero_max_exponent", + msg="Decimal128 0E+6144 converts to false", + expression={"$toBool": Decimal128("0E+6144")}, + expected=False, + ), + ExpressionTestCase( + "falsy_decimal_neg_zero_decimal", + msg="Decimal128('-0.0') converts to false", + expression={"$toBool": Decimal128("-0.0")}, + expected=False, + ), +] + +# Property [Numeric Truthiness - Truthy Non-Zeros]: any non-zero numeric value produces true +# regardless of type, sign, magnitude, or precision. +TOBOOL_TRUTHY_NONZERO_TESTS: list[ExpressionTestCase] = [ + # int32 + ExpressionTestCase( + "truthy_int32_one", + msg="int32 1 converts to true", + expression={"$toBool": 1}, + expected=True, + ), + ExpressionTestCase( + "truthy_int32_neg_one", + msg="int32 -1 converts to true", + expression={"$toBool": -1}, + expected=True, + ), + ExpressionTestCase( + "truthy_int32_max", + msg="INT32_MAX converts to true", + expression={"$toBool": INT32_MAX}, + expected=True, + ), + ExpressionTestCase( + "truthy_int32_min", + msg="INT32_MIN converts to true", + expression={"$toBool": INT32_MIN}, + expected=True, + ), + # int64 + ExpressionTestCase( + "truthy_int64_one", + msg="int64 1 converts to true", + expression={"$toBool": Int64(1)}, + expected=True, + ), + ExpressionTestCase( + "truthy_int64_neg_one", + msg="int64 -1 converts to true", + expression={"$toBool": Int64(-1)}, + expected=True, + ), + ExpressionTestCase( + "truthy_int64_max", + msg="INT64_MAX converts to true", + expression={"$toBool": INT64_MAX}, + expected=True, + ), + ExpressionTestCase( + "truthy_int64_min", + msg="INT64_MIN converts to true", + expression={"$toBool": INT64_MIN}, + expected=True, + ), + # double + ExpressionTestCase( + "truthy_double_fraction", + msg="double 0.5 converts to true", + expression={"$toBool": DOUBLE_HALF}, + expected=True, + ), + ExpressionTestCase( + "truthy_double_subnormal", + msg="double minimum positive subnormal converts to true", + expression={"$toBool": DOUBLE_MIN_SUBNORMAL}, + expected=True, + ), + ExpressionTestCase( + "truthy_double_near_max", + msg="double near-max finite value converts to true", + expression={"$toBool": DOUBLE_NEAR_MAX}, + expected=True, + ), + # Decimal128 + ExpressionTestCase( + "truthy_decimal_one", + msg="Decimal128 1 converts to true", + expression={"$toBool": Decimal128("1")}, + expected=True, + ), + ExpressionTestCase( + "truthy_decimal_many_trailing_zeros", + msg="Decimal128 with max-precision trailing zeros converts to true", + expression={"$toBool": DECIMAL128_MANY_TRAILING_ZEROS}, + expected=True, + ), + ExpressionTestCase( + "truthy_decimal_max", + msg="Decimal128 max value converts to true", + expression={"$toBool": DECIMAL128_MAX}, + expected=True, + ), + ExpressionTestCase( + "truthy_decimal_min", + msg="Decimal128 min (most negative) value converts to true", + expression={"$toBool": DECIMAL128_MIN}, + expected=True, + ), + ExpressionTestCase( + "truthy_decimal_min_positive", + msg="Decimal128 smallest positive value converts to true", + expression={"$toBool": DECIMAL128_MIN_POSITIVE}, + expected=True, + ), + ExpressionTestCase( + "truthy_decimal_max_negative", + msg="Decimal128 largest negative (closest to zero) converts to true", + expression={"$toBool": DECIMAL128_MAX_NEGATIVE}, + expected=True, + ), +] + +# Property [Numeric Truthiness - Special Float Values]: NaN and Infinity values of both +# double and Decimal128 produce true. +TOBOOL_SPECIAL_FLOAT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "special_double_nan", + msg="double NaN converts to true", + expression={"$toBool": FLOAT_NAN}, + expected=True, + ), + ExpressionTestCase( + "special_decimal_nan", + msg="Decimal128 NaN converts to true", + expression={"$toBool": DECIMAL128_NAN}, + expected=True, + ), + ExpressionTestCase( + "special_double_pos_infinity", + msg="double +Infinity converts to true", + expression={"$toBool": FLOAT_INFINITY}, + expected=True, + ), + ExpressionTestCase( + "special_double_neg_infinity", + msg="double -Infinity converts to true", + expression={"$toBool": FLOAT_NEGATIVE_INFINITY}, + expected=True, + ), + ExpressionTestCase( + "special_decimal_pos_infinity", + msg="Decimal128 +Infinity converts to true", + expression={"$toBool": DECIMAL128_INFINITY}, + expected=True, + ), + ExpressionTestCase( + "special_decimal_neg_infinity", + msg="Decimal128 -Infinity converts to true", + expression={"$toBool": DECIMAL128_NEGATIVE_INFINITY}, + expected=True, + ), +] + +TOBOOL_NUMERIC_TESTS = with_convert_variants( + TOBOOL_FALSY_ZERO_TESTS + TOBOOL_TRUTHY_NONZERO_TESTS + TOBOOL_SPECIAL_FLOAT_TESTS, + "$toBool", + "bool", +) + + +@pytest.mark.parametrize("test", pytest_params(TOBOOL_NUMERIC_TESTS)) +def test_toBool_numeric(collection, test: ExpressionTestCase): + """$toBool converts numeric inputs: zeros are falsy, non-zeros and special values are truthy.""" + 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/toBool/test_toBool_return_type.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toBool/test_toBool_return_type.py new file mode 100644 index 000000000..66c8df800 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toBool/test_toBool_return_type.py @@ -0,0 +1,134 @@ +"""$toBool return-type invariant and idempotency 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.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 INT32_ZERO, MISSING + +# Property [Return Type]: $toBool always returns BSON type "bool" for any non-null, non-missing +# input. ARRAY and OBJECT samples are wrapped with $literal to pass as plain values rather than +# being parsed as expressions. +RETURN_TYPE_BOOL_SPEC = [ + BsonTypeTestCase( + id="toBool_return_type", + msg="$toBool always returns BSON type bool for a successful conversion", + valid_types=[ + BsonType.BOOL, + BsonType.DOUBLE, + BsonType.INT, + BsonType.LONG, + BsonType.DECIMAL, + BsonType.STRING, + BsonType.DATE, + BsonType.BIN_DATA, + BsonType.OBJECT_ID, + BsonType.REGEX, + BsonType.JAVASCRIPT, + BsonType.TIMESTAMP, + BsonType.MIN_KEY, + BsonType.MAX_KEY, + BsonType.ARRAY, + BsonType.OBJECT, + ], + valid_inputs={ + BsonType.ARRAY: {"$literal": ["a", "b", "c"]}, + BsonType.OBJECT: {"$literal": {"key": "value"}}, + }, + ), +] + +RETURN_TYPE_BOOL_CASES = generate_bson_acceptance_test_cases(RETURN_TYPE_BOOL_SPEC) + +_BOOL_EXPR_FORMS = [ + pytest.param(lambda v: {"$toBool": v}, id="toBool"), + pytest.param(lambda v: {"$convert": {"input": v, "to": "bool"}}, id="convert"), +] + + +@pytest.mark.parametrize("expr_fn", _BOOL_EXPR_FORMS) +@pytest.mark.parametrize("bson_type,sample_value,spec", RETURN_TYPE_BOOL_CASES) +def test_toBool_return_type_is_bool(collection, bson_type, sample_value, spec, expr_fn): + """$toBool and $convert to bool always return BSON type 'bool' for a successful conversion.""" + result = execute_expression(collection, {"$type": expr_fn(sample_value)}) + assert_expression_result(result, expected="bool", msg=f"{spec.msg} ({bson_type.value} input)") + + +# Property [Return Type - Null]: $toBool returns BSON type "null" for null or missing inputs. +TOBOOL_RETURN_TYPE_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null", + msg="$toBool of null returns null type", + expression={"$type": {"$toBool": None}}, + expected="null", + ), + ExpressionTestCase( + "missing", + msg="$toBool of missing field returns null type", + expression={"$type": {"$toBool": MISSING}}, + expected="null", + ), +] + +# Property [Idempotency]: applying $toBool twice produces the same result as applying it once +# for representative truthy, falsy, and null inputs. +TOBOOL_IDEMPOTENCY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "idempotent_true", + msg="$toBool is idempotent for true", + expression={"$toBool": {"$toBool": True}}, + expected=True, + ), + ExpressionTestCase( + "idempotent_false", + msg="$toBool is idempotent for false", + expression={"$toBool": {"$toBool": False}}, + expected=False, + ), + ExpressionTestCase( + "idempotent_null", + msg="$toBool is idempotent for null", + expression={"$toBool": {"$toBool": None}}, + expected=None, + ), + ExpressionTestCase( + "idempotent_int_truthy", + msg="$toBool is idempotent for a truthy int32 (once converts to bool, twice is same)", + expression={"$toBool": {"$toBool": 1}}, + expected=True, + ), + ExpressionTestCase( + "idempotent_int_falsy", + msg="$toBool is idempotent for a falsy int32 (once converts to bool, twice is same)", + expression={"$toBool": {"$toBool": INT32_ZERO}}, + expected=False, + ), + ExpressionTestCase( + "idempotent_string", + msg="$toBool is idempotent for a string (truthy string converts to bool, then stays)", + expression={"$toBool": {"$toBool": "hello"}}, + expected=True, + ), +] + + +@pytest.mark.parametrize( + "test", pytest_params(TOBOOL_RETURN_TYPE_NULL_TESTS + TOBOOL_IDEMPOTENCY_TESTS) +) +def test_toBool_return_type_null_and_idempotency(collection, test: ExpressionTestCase): + """$toBool returns BSON type 'null' for null or missing input and is idempotent.""" + 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/toBool/test_toBool_string_and_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toBool/test_toBool_string_and_types.py new file mode 100644 index 000000000..1b8f85601 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toBool/test_toBool_string_and_types.py @@ -0,0 +1,209 @@ +"""$toBool boolean passthrough, string truthiness, always-truthy type, and string error tests.""" + +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.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) +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 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 [Boolean Passthrough]: boolean inputs are returned unchanged. +TOBOOL_PASSTHROUGH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "passthrough_true", + msg="true input passes through unchanged", + expression={"$toBool": True}, + expected=True, + ), + ExpressionTestCase( + "passthrough_false", + msg="false input passes through unchanged", + expression={"$toBool": False}, + expected=False, + ), +] + +# Property [String Truthiness]: all strings produce true regardless of content, including the +# empty string, the string "false", and the string "0". +TOBOOL_STRING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_nonempty", + msg="Non-empty string converts to true", + expression={"$toBool": "hello"}, + expected=True, + ), + ExpressionTestCase( + "string_empty", + msg="Empty string converts to true", + expression={"$toBool": ""}, + expected=True, + ), + ExpressionTestCase( + "string_false", + msg="The string 'false' converts to true", + expression={"$toBool": "false"}, + expected=True, + ), + ExpressionTestCase( + "string_zero", + msg="The string '0' converts to true", + expression={"$toBool": "0"}, + expected=True, + ), +] + +# Property [String Size Limit]: strings at or above the 16 MB byte limit are rejected. +TOBOOL_STRING_SIZE_LIMIT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "size_at_limit_single_byte", + msg="Single-byte string at the 16 MB byte limit is rejected", + expression=lazy(lambda: {"$toBool": "a" * STRING_SIZE_LIMIT_BYTES}), + error_code=STRING_SIZE_LIMIT_ERROR, + ), + ExpressionTestCase( + "size_at_limit_four_byte", + msg="Four-byte character string reaching 16 MB bytes is rejected", + expression=lazy(lambda: {"$toBool": "\U0001f600" * (STRING_SIZE_LIMIT_BYTES // 4)}), + error_code=STRING_SIZE_LIMIT_ERROR, + ), +] + +# Property [Always-Truthy Types]: non-numeric, non-string, non-boolean, non-null types always +# produce true regardless of their value, including empty arrays and empty objects. +TOBOOL_TRUTHY_TYPES_TESTS: list[ExpressionTestCase] = [ + # array (must use $literal to avoid arity interpretation) + ExpressionTestCase( + "truthy_array_empty", + msg="Empty array is truthy", + expression={"$toBool": {"$literal": []}}, + expected=True, + ), + ExpressionTestCase( + "truthy_array_nonempty", + msg="Non-empty array is truthy", + expression={"$toBool": {"$literal": [1, 2]}}, + expected=True, + ), + ExpressionTestCase( + "truthy_array_containing_null", + msg="Array containing null is truthy", + expression={"$toBool": {"$literal": [None]}}, + expected=True, + ), + # Binary + ExpressionTestCase( + "truthy_binary_empty", + msg="Empty Binary is truthy", + expression={"$toBool": Binary(b"")}, + expected=True, + ), + ExpressionTestCase( + "truthy_binary_nonempty", + msg="Non-empty Binary is truthy", + expression={"$toBool": Binary(b"\x01\x02")}, + expected=True, + ), + # ObjectId + ExpressionTestCase( + "truthy_objectid", + msg="ObjectId is truthy", + expression={"$toBool": ObjectId("000000000000000000000000")}, + expected=True, + ), + # datetime + ExpressionTestCase( + "truthy_datetime_epoch", + msg="Epoch datetime is truthy", + expression={"$toBool": datetime(1970, 1, 1, tzinfo=timezone.utc)}, + expected=True, + ), + ExpressionTestCase( + "truthy_datetime_pre_epoch", + msg="Pre-epoch datetime is truthy", + expression={"$toBool": datetime(1969, 12, 31, tzinfo=timezone.utc)}, + expected=True, + ), + ExpressionTestCase( + "truthy_timestamp_zero", + msg="Timestamp (0, 0) is truthy", + expression={"$toBool": Timestamp(0, 0)}, + expected=True, + ), + ExpressionTestCase( + "truthy_timestamp_nonzero", + msg="Timestamp (1, 1) is truthy", + expression={"$toBool": Timestamp(1, 1)}, + expected=True, + ), + # object (must use $literal to avoid expression interpretation) + ExpressionTestCase( + "truthy_object_empty", + msg="Empty object is truthy", + expression={"$toBool": {"$literal": {}}}, + expected=True, + ), + ExpressionTestCase( + "truthy_object_nonempty", + msg="Non-empty object is truthy", + expression={"$toBool": {"$literal": {"a": 1}}}, + expected=True, + ), + # Regex + ExpressionTestCase( + "truthy_regex", + msg="Regex is truthy", + expression={"$toBool": Regex("abc")}, + expected=True, + ), + # Code + ExpressionTestCase( + "truthy_code", + msg="Code is truthy", + expression={"$toBool": Code("x")}, + expected=True, + ), + # MinKey / MaxKey + ExpressionTestCase( + "truthy_minkey", + msg="MinKey is truthy", + expression={"$toBool": MinKey()}, + expected=True, + ), + ExpressionTestCase( + "truthy_maxkey", + msg="MaxKey is truthy", + expression={"$toBool": MaxKey()}, + expected=True, + ), +] + +TOBOOL_STRING_AND_TYPES_TESTS = with_convert_variants( + TOBOOL_PASSTHROUGH_TESTS + + TOBOOL_STRING_TESTS + + TOBOOL_STRING_SIZE_LIMIT_TESTS + + TOBOOL_TRUTHY_TYPES_TESTS, + "$toBool", + "bool", +) + + +@pytest.mark.parametrize("test", pytest_params(TOBOOL_STRING_AND_TYPES_TESTS)) +def test_toBool_string_and_types(collection, test: ExpressionTestCase): + """$toBool boolean passthrough, string truthiness, always-truthy types, and string errors.""" + 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 index b65c98f5e..fc3f29e12 100644 --- 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 @@ -5,6 +5,9 @@ import pytest from bson import Binary, Code, Decimal128, MaxKey, MinKey, ObjectId, Regex, Timestamp +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 ExpressionTestCase, ) @@ -160,7 +163,10 @@ TODECIMAL_DATETIME_TESTS = TODECIMAL_DATETIME_TESTS + TODECIMAL_UNSUPPORTED_TYPE_TESTS -@pytest.mark.parametrize("test", pytest_params(TODECIMAL_DATETIME_TESTS)) +@pytest.mark.parametrize( + "test", + pytest_params(with_convert_variants(TODECIMAL_DATETIME_TESTS, "$toDecimal", "decimal")), +) def test_toDecimal_datetime(collection, test: ExpressionTestCase): """$toDecimal converts datetime to ms-since-epoch Decimal128; rejects unsupported types.""" result = execute_expression(collection, test.expression) 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 index 3c9fbf975..9de01a0d1 100644 --- 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 @@ -3,6 +3,9 @@ import pytest from bson import Decimal128 +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 ExpressionTestCase, ) @@ -131,7 +134,10 @@ ] -@pytest.mark.parametrize("test", pytest_params(TODECIMAL_DECIMAL128_TESTS)) +@pytest.mark.parametrize( + "test", + pytest_params(with_convert_variants(TODECIMAL_DECIMAL128_TESTS, "$toDecimal", "decimal")), +) def test_toDecimal_decimal128(collection, test: ExpressionTestCase): """$toDecimal is the identity function for Decimal128 inputs.""" result = execute_expression(collection, test.expression) 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 index 6705b327b..f29ffae75 100644 --- 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 @@ -3,6 +3,9 @@ import pytest from bson import Decimal128 +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 ExpressionTestCase, ) @@ -138,7 +141,10 @@ ] -@pytest.mark.parametrize("test", pytest_params(TODECIMAL_DOUBLE_TESTS)) +@pytest.mark.parametrize( + "test", + pytest_params(with_convert_variants(TODECIMAL_DOUBLE_TESTS, "$toDecimal", "decimal")), +) def test_toDecimal_double(collection, test: ExpressionTestCase): """$toDecimal converts double inputs to Decimal128 with 15 significant digits.""" result = execute_expression(collection, test.expression) 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 index bd9e63e78..fd96a7efc 100644 --- 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 @@ -3,6 +3,9 @@ import pytest from bson import Decimal128, Int64 +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 ExpressionTestCase, ) @@ -135,7 +138,12 @@ @pytest.mark.parametrize( - "test", pytest_params(TODECIMAL_FIELD_REF_TESTS + TODECIMAL_EXPRESSION_INPUT_TESTS) + "test", + pytest_params( + with_convert_variants( + TODECIMAL_FIELD_REF_TESTS + TODECIMAL_EXPRESSION_INPUT_TESTS, "$toDecimal", "decimal" + ) + ), ) def test_toDecimal_field_ref(collection, test: ExpressionTestCase): """$toDecimal resolves field paths and nested paths from inserted documents.""" 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 index 0a5624b30..136f61ef4 100644 --- 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 @@ -3,6 +3,9 @@ import pytest from bson import Decimal128, Int64 +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 ExpressionTestCase, ) @@ -165,11 +168,13 @@ ), ] -TODECIMAL_NUMERIC_TESTS = ( +TODECIMAL_NUMERIC_TESTS = with_convert_variants( TODECIMAL_NULL_MISSING_TESTS + TODECIMAL_BOOL_TESTS + TODECIMAL_INT32_TESTS - + TODECIMAL_INT64_TESTS + + TODECIMAL_INT64_TESTS, + "$toDecimal", + "decimal", ) 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 index 83a60d359..b6d911146 100644 --- 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 @@ -46,11 +46,17 @@ RETURN_TYPE_DECIMAL_CASES = generate_bson_acceptance_test_cases(RETURN_TYPE_DECIMAL_SPEC) +_DECIMAL_EXPR_FORMS = [ + pytest.param(lambda v: {"$toDecimal": v}, id="toDecimal"), + pytest.param(lambda v: {"$convert": {"input": v, "to": "decimal"}}, id="convert"), +] + +@pytest.mark.parametrize("expr_fn", _DECIMAL_EXPR_FORMS) @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}}) +def test_toDecimal_return_type_is_decimal(collection, bson_type, sample_value, spec, expr_fn): + """$toDecimal and $convert to decimal always return BSON type 'decimal'.""" + result = execute_expression(collection, {"$type": expr_fn(sample_value)}) assert_expression_result( result, expected="decimal", msg=f"{spec.msg} ({bson_type.value} input)" ) 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 index 2639dea6d..a9093bc7d 100644 --- 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 @@ -3,6 +3,9 @@ import pytest from bson import Decimal128 +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 ExpressionTestCase, ) @@ -215,7 +218,12 @@ @pytest.mark.parametrize( - "test", pytest_params(TODECIMAL_STRING_TESTS + TODECIMAL_STRING_SIZE_LIMIT_TESTS) + "test", + pytest_params( + with_convert_variants( + TODECIMAL_STRING_TESTS + TODECIMAL_STRING_SIZE_LIMIT_TESTS, "$toDecimal", "decimal" + ) + ), ) def test_toDecimal_string(collection, test: ExpressionTestCase): """$toDecimal parses valid numeric strings including scientific notation and special values.""" 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 index efe965418..6214716f0 100644 --- 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 @@ -2,6 +2,9 @@ import pytest +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 ExpressionTestCase, ) @@ -218,7 +221,14 @@ @pytest.mark.parametrize( - "test", pytest_params(TODECIMAL_STRING_ERROR_TESTS + TODECIMAL_STRING_SIZE_LIMIT_ERROR_TESTS) + "test", + pytest_params( + with_convert_variants( + TODECIMAL_STRING_ERROR_TESTS + TODECIMAL_STRING_SIZE_LIMIT_ERROR_TESTS, + "$toDecimal", + "decimal", + ) + ), ) def test_toDecimal_string_errors(collection, test: ExpressionTestCase): """$toDecimal rejects malformed and out-of-range string inputs.""" 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 index ef401a048..8275c4b4b 100644 --- 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 @@ -6,6 +6,9 @@ import pytest from bson import Binary, Code, MaxKey, MinKey, ObjectId, Regex, Timestamp +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 ExpressionTestCase, ) @@ -203,7 +206,10 @@ ) -@pytest.mark.parametrize("test", pytest_params(TODOUBLE_DATETIME_BINARY_TESTS)) +@pytest.mark.parametrize( + "test", + pytest_params(with_convert_variants(TODOUBLE_DATETIME_BINARY_TESTS, "$toDouble", "double")), +) def test_toDouble_datetime_binary(collection, test: ExpressionTestCase): """$toDouble converts datetime and binary inputs; rejects unsupported BSON types.""" result = execute_expression(collection, test.expression) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_decimal128.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_decimal128.py index 498ee7c4d..9ebdfc228 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_decimal128.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_decimal128.py @@ -3,6 +3,9 @@ import pytest from bson import Decimal128 +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 ExpressionTestCase, ) @@ -95,7 +98,10 @@ ] -@pytest.mark.parametrize("test", pytest_params(TODOUBLE_DECIMAL128_TESTS)) +@pytest.mark.parametrize( + "test", + pytest_params(with_convert_variants(TODOUBLE_DECIMAL128_TESTS, "$toDouble", "double")), +) def test_toDouble_decimal128(collection, test: ExpressionTestCase): """$toDouble converts Decimal128 values including infinity, signed zero, and overflow.""" result = execute_expression(collection, test.expression) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_field_ref.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_field_ref.py index ca013570a..939f88ec7 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_field_ref.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_field_ref.py @@ -3,6 +3,9 @@ import pytest from bson import Int64 +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 ExpressionTestCase, ) @@ -117,7 +120,12 @@ @pytest.mark.parametrize( - "test", pytest_params(TODOUBLE_FIELD_REF_TESTS + TODOUBLE_EXPRESSION_INPUT_TESTS) + "test", + pytest_params( + with_convert_variants( + TODOUBLE_FIELD_REF_TESTS + TODOUBLE_EXPRESSION_INPUT_TESTS, "$toDouble", "double" + ) + ), ) def test_toDouble_field_ref(collection, test: ExpressionTestCase): """$toDouble resolves field paths and nested paths from inserted documents.""" diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_numeric.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_numeric.py index 3dd78cca3..5e46dabc4 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_numeric.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_numeric.py @@ -3,6 +3,9 @@ import pytest from bson import Int64 +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 ExpressionTestCase, ) @@ -229,13 +232,15 @@ ), ] -TODOUBLE_NUMERIC_TESTS = ( +TODOUBLE_NUMERIC_TESTS = with_convert_variants( TODOUBLE_NULL_MISSING_TESTS + TODOUBLE_BOOL_TESTS + TODOUBLE_INT32_TESTS + TODOUBLE_INT64_TESTS + TODOUBLE_DOUBLE_IDENTITY_TESTS - + TODOUBLE_NAN_TESTS + + TODOUBLE_NAN_TESTS, + "$toDouble", + "double", ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_return_type.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_return_type.py index 2b93ca534..36b182953 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_return_type.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_return_type.py @@ -50,11 +50,17 @@ RETURN_TYPE_DOUBLE_CASES = generate_bson_acceptance_test_cases(RETURN_TYPE_DOUBLE_SPEC) +_DOUBLE_EXPR_FORMS = [ + pytest.param(lambda v: {"$toDouble": v}, id="toDouble"), + pytest.param(lambda v: {"$convert": {"input": v, "to": "double"}}, id="convert"), +] + +@pytest.mark.parametrize("expr_fn", _DOUBLE_EXPR_FORMS) @pytest.mark.parametrize("bson_type,sample_value,spec", RETURN_TYPE_DOUBLE_CASES) -def test_toDouble_return_type_is_double(collection, bson_type, sample_value, spec): - """$toDouble always returns BSON type 'double' for a successful conversion.""" - result = execute_expression(collection, {"$type": {"$toDouble": sample_value}}) +def test_toDouble_return_type_is_double(collection, bson_type, sample_value, spec, expr_fn): + """$toDouble and $convert to double always return BSON type 'double'.""" + result = execute_expression(collection, {"$type": expr_fn(sample_value)}) assert_expression_result(result, expected="double", msg=f"{spec.msg} ({bson_type.value} input)") diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_string.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_string.py index ba1c69f3b..53ccfd56d 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_string.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toDouble/test_toDouble_string.py @@ -2,6 +2,9 @@ import pytest +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 ExpressionTestCase, ) @@ -294,7 +297,12 @@ @pytest.mark.parametrize( - "test", pytest_params(TODOUBLE_STRING_TESTS + TODOUBLE_STRING_SIZE_LIMIT_TESTS) + "test", + pytest_params( + with_convert_variants( + TODOUBLE_STRING_TESTS + TODOUBLE_STRING_SIZE_LIMIT_TESTS, "$toDouble", "double" + ) + ), ) def test_toDouble_string(collection, test: ExpressionTestCase): """$toDouble parses valid numeric strings and rejects malformed ones.""" diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_binary.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_binary.py index dd05b2ec7..8fb821ce2 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_binary.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_binary.py @@ -5,6 +5,9 @@ import pytest from bson import Binary, Code, MaxKey, MinKey, ObjectId, Regex, Timestamp +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 ExpressionTestCase, ) @@ -211,7 +214,10 @@ ) -@pytest.mark.parametrize("test", pytest_params(TOINT_BINARY_TESTS)) +@pytest.mark.parametrize( + "test", + pytest_params(with_convert_variants(TOINT_BINARY_TESTS, "$toInt", "int")), +) def test_toInt_binary(collection, test: ExpressionTestCase): """$toInt converts 1/2/4-byte binary; rejects other lengths and unsupported BSON types.""" result = execute_expression(collection, test.expression) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_decimal128.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_decimal128.py index 7c68962c2..d47e1255e 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_decimal128.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_decimal128.py @@ -3,6 +3,9 @@ import pytest from bson import Decimal128 +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 ExpressionTestCase, ) @@ -120,7 +123,10 @@ TOINT_DECIMAL128_TESTS = TOINT_DECIMAL128_TRUNCATION_TESTS + TOINT_DECIMAL128_BOUNDARY_TESTS -@pytest.mark.parametrize("test", pytest_params(TOINT_DECIMAL128_TESTS)) +@pytest.mark.parametrize( + "test", + pytest_params(with_convert_variants(TOINT_DECIMAL128_TESTS, "$toInt", "int")), +) def test_toInt_decimal128(collection, test: ExpressionTestCase): """$toInt converts Decimal128 values within int32 range; rejects NaN, infinity, and overflow.""" result = execute_expression(collection, test.expression) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_double.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_double.py index 4ff45c875..a35b5ab1b 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_double.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_double.py @@ -2,6 +2,9 @@ import pytest +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 ExpressionTestCase, ) @@ -138,7 +141,10 @@ TOINT_DOUBLE_TESTS = TOINT_DOUBLE_TRUNCATION_TESTS + TOINT_DOUBLE_BOUNDARY_TESTS -@pytest.mark.parametrize("test", pytest_params(TOINT_DOUBLE_TESTS)) +@pytest.mark.parametrize( + "test", + pytest_params(with_convert_variants(TOINT_DOUBLE_TESTS, "$toInt", "int")), +) def test_toInt_double(collection, test: ExpressionTestCase): """$toInt truncates doubles toward zero; rejects NaN, infinity, and out-of-range values.""" result = execute_expression(collection, test.expression) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_field_ref.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_field_ref.py index 427e9f12d..f3ed2abfe 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_field_ref.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_field_ref.py @@ -3,6 +3,9 @@ import pytest from bson import Binary, Decimal128, Int64 +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 ExpressionTestCase, ) @@ -121,7 +124,10 @@ @pytest.mark.parametrize( - "test", pytest_params(TOINT_FIELD_REF_TESTS + TOINT_EXPRESSION_INPUT_TESTS) + "test", + pytest_params( + with_convert_variants(TOINT_FIELD_REF_TESTS + TOINT_EXPRESSION_INPUT_TESTS, "$toInt", "int") + ), ) def test_toInt_field_ref(collection, test: ExpressionTestCase): """$toInt resolves field paths and nested paths from inserted documents.""" diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_numeric.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_numeric.py index ebb8aee4c..417554f9d 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_numeric.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_numeric.py @@ -3,6 +3,9 @@ import pytest from bson import Int64 +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 ExpressionTestCase, ) @@ -137,8 +140,10 @@ ), ] -TOINT_NUMERIC_TESTS = ( - TOINT_NULL_MISSING_TESTS + TOINT_BOOL_TESTS + TOINT_INT32_TESTS + TOINT_INT64_TESTS +TOINT_NUMERIC_TESTS = with_convert_variants( + TOINT_NULL_MISSING_TESTS + TOINT_BOOL_TESTS + TOINT_INT32_TESTS + TOINT_INT64_TESTS, + "$toInt", + "int", ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_return_type.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_return_type.py index 398c2e946..b53b0b0f1 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_return_type.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_return_type.py @@ -43,11 +43,17 @@ RETURN_TYPE_INT_CASES = generate_bson_acceptance_test_cases(RETURN_TYPE_INT_SPEC) +_INT_EXPR_FORMS = [ + pytest.param(lambda v: {"$toInt": v}, id="toInt"), + pytest.param(lambda v: {"$convert": {"input": v, "to": "int"}}, id="convert"), +] + +@pytest.mark.parametrize("expr_fn", _INT_EXPR_FORMS) @pytest.mark.parametrize("bson_type,sample_value,spec", RETURN_TYPE_INT_CASES) -def test_toInt_return_type_is_int(collection, bson_type, sample_value, spec): - """$toInt always returns BSON type 'int' for a successful conversion.""" - result = execute_expression(collection, {"$type": {"$toInt": sample_value}}) +def test_toInt_return_type_is_int(collection, bson_type, sample_value, spec, expr_fn): + """$toInt and $convert to int always return BSON type 'int'.""" + result = execute_expression(collection, {"$type": expr_fn(sample_value)}) assert_expression_result(result, expected="int", msg=f"{spec.msg} ({bson_type.value} input)") diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_string.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_string.py index ef7bc888b..5facf7908 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_string.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toInt/test_toInt_string.py @@ -2,6 +2,9 @@ import pytest +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 ExpressionTestCase, ) @@ -464,7 +467,12 @@ ] -@pytest.mark.parametrize("test", pytest_params(TOINT_STRING_TESTS + TOINT_STRING_SIZE_LIMIT_TESTS)) +@pytest.mark.parametrize( + "test", + pytest_params( + with_convert_variants(TOINT_STRING_TESTS + TOINT_STRING_SIZE_LIMIT_TESTS, "$toInt", "int") + ), +) def test_toInt_string(collection, test: ExpressionTestCase): """$toInt parses valid base-10 integer strings and rejects malformed or out-of-range ones.""" result = execute_expression(collection, test.expression) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_datetime_binary.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_datetime_binary.py index 92335059e..e1fb49e62 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_datetime_binary.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_datetime_binary.py @@ -5,6 +5,9 @@ import pytest from bson import Binary, Int64 +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 ExpressionTestCase, ) @@ -228,7 +231,10 @@ ) -@pytest.mark.parametrize("test", pytest_params(TOLONG_DATETIME_BINARY_TESTS)) +@pytest.mark.parametrize( + "test", + pytest_params(with_convert_variants(TOLONG_DATETIME_BINARY_TESTS, "$toLong", "long")), +) def test_toLong_datetime_binary(collection, test: ExpressionTestCase): """$toLong converts datetime and binary inputs; rejects unsupported BSON types.""" result = execute_expression(collection, test.expression) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_decimal128.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_decimal128.py index f47954c84..4e8ca0fe3 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_decimal128.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_decimal128.py @@ -3,6 +3,9 @@ import pytest from bson import Decimal128, Int64 +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 ExpressionTestCase, ) @@ -165,7 +168,10 @@ TOLONG_DECIMAL128_TESTS = TOLONG_DECIMAL128_TESTS + TOLONG_DECIMAL128_ERROR_TESTS -@pytest.mark.parametrize("test", pytest_params(TOLONG_DECIMAL128_TESTS)) +@pytest.mark.parametrize( + "test", + pytest_params(with_convert_variants(TOLONG_DECIMAL128_TESTS, "$toLong", "long")), +) def test_toLong_decimal128(collection, test: ExpressionTestCase): """$toLong converts Decimal128 values including truncation, boundary values, and overflow.""" result = execute_expression(collection, test.expression) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_field_ref.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_field_ref.py index 95a96cdbf..42c3f6ea5 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_field_ref.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_field_ref.py @@ -3,6 +3,9 @@ import pytest from bson import Binary, Decimal128, Int64 +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 ExpressionTestCase, ) @@ -130,7 +133,12 @@ @pytest.mark.parametrize( - "test", pytest_params(TOLONG_FIELD_REF_TESTS + TOLONG_EXPRESSION_INPUT_TESTS) + "test", + pytest_params( + with_convert_variants( + TOLONG_FIELD_REF_TESTS + TOLONG_EXPRESSION_INPUT_TESTS, "$toLong", "long" + ) + ), ) def test_toLong_field_ref(collection, test: ExpressionTestCase): """$toLong resolves field paths and nested paths from inserted documents.""" diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_numeric.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_numeric.py index 7f75408db..815953b10 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_numeric.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_numeric.py @@ -5,6 +5,9 @@ import pytest from bson import Int64 +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 ExpressionTestCase, ) @@ -253,13 +256,15 @@ ), ] -TOLONG_NUMERIC_TESTS = ( +TOLONG_NUMERIC_TESTS = with_convert_variants( TOLONG_NULL_MISSING_TESTS + TOLONG_BOOL_TESTS + TOLONG_INT32_TESTS + TOLONG_INT64_IDENTITY_TESTS + TOLONG_DOUBLE_TESTS - + TOLONG_DOUBLE_ERROR_TESTS + + TOLONG_DOUBLE_ERROR_TESTS, + "$toLong", + "long", ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_return_type.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_return_type.py index 4a5071d96..eecbc1097 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_return_type.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_return_type.py @@ -59,18 +59,25 @@ RETURN_TYPE_LONG_CASES = generate_bson_acceptance_test_cases(RETURN_TYPE_LONG_SPEC) REJECTION_LONG_CASES = generate_bson_rejection_test_cases(RETURN_TYPE_LONG_SPEC) +_LONG_EXPR_FORMS = [ + pytest.param(lambda v: {"$toLong": v}, id="toLong"), + pytest.param(lambda v: {"$convert": {"input": v, "to": "long"}}, id="convert"), +] + +@pytest.mark.parametrize("expr_fn", _LONG_EXPR_FORMS) @pytest.mark.parametrize("bson_type,sample_value,spec", RETURN_TYPE_LONG_CASES) -def test_toLong_return_type_is_long(collection, bson_type, sample_value, spec): - """$toLong always returns BSON type 'long' for a successful conversion.""" - result = execute_expression(collection, {"$type": {"$toLong": sample_value}}) +def test_toLong_return_type_is_long(collection, bson_type, sample_value, spec, expr_fn): + """$toLong and $convert to long always return BSON type 'long'.""" + result = execute_expression(collection, {"$type": expr_fn(sample_value)}) assert_expression_result(result, expected="long", msg=f"{spec.msg} ({bson_type.value} input)") +@pytest.mark.parametrize("expr_fn", _LONG_EXPR_FORMS) @pytest.mark.parametrize("bson_type,sample_value,spec", REJECTION_LONG_CASES) -def test_toLong_rejects_unsupported_type(collection, bson_type, sample_value, spec): - """$toLong rejects BSON types it cannot convert with a conversion failure.""" - result = execute_expression(collection, {"$toLong": sample_value}) +def test_toLong_rejects_unsupported_type(collection, bson_type, sample_value, spec, expr_fn): + """$toLong and $convert to long reject BSON types they cannot convert.""" + result = execute_expression(collection, expr_fn(sample_value)) assert_expression_result( result, error_code=spec.expected_code(bson_type), diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_string.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_string.py index dbfbc27f9..660da7cfc 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_string.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toLong/test_toLong_string.py @@ -3,6 +3,9 @@ import pytest from bson import Int64 +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 ExpressionTestCase, ) @@ -328,7 +331,12 @@ @pytest.mark.parametrize( - "test", pytest_params(TOLONG_STRING_TESTS + TOLONG_STRING_SIZE_LIMIT_TESTS) + "test", + pytest_params( + with_convert_variants( + TOLONG_STRING_TESTS + TOLONG_STRING_SIZE_LIMIT_TESTS, "$toLong", "long" + ) + ), ) def test_toLong_string(collection, test: ExpressionTestCase): """$toLong parses valid integer strings and rejects non-integer or malformed ones.""" diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toObjectId/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toObjectId/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/toObjectId/test_toObjectId_arity.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toObjectId/test_toObjectId_arity.py new file mode 100644 index 000000000..e3d6399e8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toObjectId/test_toObjectId_arity.py @@ -0,0 +1,93 @@ +"""$toObjectId arity and field path syntax tests.""" + +import pytest +from bson import ObjectId + +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, + FAILED_TO_PARSE_ERROR, + INVALID_DOLLAR_FIELD_PATH, + TO_TYPE_ARITY_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Arity]: $toObjectId unwraps single-element literal arrays and rejects empty or +# multi-element arrays. These cases are specific to $toObjectId syntax (not $convert). +TOOBJECTID_ARITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "single_element_string", + msg="Single-element literal array containing a valid hex string is unwrapped and converts", + expression={"$toObjectId": ["507f1f77bcf86cd799439011"]}, + expected=ObjectId("507f1f77bcf86cd799439011"), + ), + ExpressionTestCase( + "single_null", + msg="Single-element literal array wrapping null unwraps and returns null", + expression={"$toObjectId": [None]}, + expected=None, + ), + ExpressionTestCase( + "empty_array", + msg="Empty literal array argument is an arity error", + expression={"$toObjectId": []}, + error_code=TO_TYPE_ARITY_ERROR, + ), + ExpressionTestCase( + "multi_element", + msg="Multi-element literal array argument is an arity error", + expression={"$toObjectId": ["507f1f77bcf86cd799439011", "507f1f77bcf86cd799439011"]}, + error_code=TO_TYPE_ARITY_ERROR, + ), + ExpressionTestCase( + "large_array", + msg="A large literal array argument is an arity error", + expression={"$toObjectId": ["0" * 24] * 10}, + error_code=TO_TYPE_ARITY_ERROR, + ), + ExpressionTestCase( + "single_invalid_type", + msg="Single-element array wrapping an int unwraps, then fails as invalid type", + expression={"$toObjectId": [42]}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "single_nested_array", + msg="Single-element array wrapping another array unwraps one level, then fails", + expression={"$toObjectId": [["507f1f77bcf86cd799439011"]]}, + error_code=CONVERSION_FAILURE_ERROR, + ), +] + +# Property [Invalid Field Path]: $toObjectId rejects malformed field path syntax. +TOOBJECTID_INVALID_FIELD_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "bare_dollar", + msg="Bare '$' is an invalid field path", + expression={"$toObjectId": "$"}, + error_code=INVALID_DOLLAR_FIELD_PATH, + ), + ExpressionTestCase( + "double_dollar", + msg="'$$' is rejected as an empty variable name", + expression={"$toObjectId": "$$"}, + error_code=FAILED_TO_PARSE_ERROR, + ), +] + +TOOBJECTID_ARITY_AND_PATH_TESTS = TOOBJECTID_ARITY_TESTS + TOOBJECTID_INVALID_FIELD_PATH_TESTS + + +@pytest.mark.parametrize("test", pytest_params(TOOBJECTID_ARITY_AND_PATH_TESTS)) +def test_toObjectId_arity(collection, test: ExpressionTestCase): + """$toObjectId 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/toObjectId/test_toObjectId_core.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toObjectId/test_toObjectId_core.py new file mode 100644 index 000000000..6264a58bf --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toObjectId/test_toObjectId_core.py @@ -0,0 +1,154 @@ +"""$toObjectId core conversion tests: valid hex strings, ObjectId identity, expression inputs.""" + +import pytest +from bson import ObjectId + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) +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 + +# Property [Core Conversions]: a 24-character hex string is converted to the corresponding +# ObjectId, hex parsing is case-insensitive, output is normalized to lowercase, and ObjectId +# input is returned unchanged. +TOOBJECTID_HEX_STRING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "lowercase_hex", + msg="Lowercase hex string converts to the corresponding ObjectId", + expression={"$toObjectId": "507f1f77bcf86cd799439011"}, + expected=ObjectId("507f1f77bcf86cd799439011"), + ), + ExpressionTestCase( + "uppercase_hex", + msg="Uppercase hex string converts to the same ObjectId as its lowercase equivalent", + expression={"$toObjectId": "507F1F77BCF86CD799439011"}, + expected=ObjectId("507f1f77bcf86cd799439011"), + ), + ExpressionTestCase( + "mixed_case_hex", + msg="Mixed-case hex string converts to the same ObjectId as its lowercase equivalent", + expression={"$toObjectId": "507f1F77bcF86cD799439011"}, + expected=ObjectId("507f1f77bcf86cd799439011"), + ), + ExpressionTestCase( + "all_zeros", + msg="All-zero hex string converts to the epoch ObjectId", + expression={"$toObjectId": "000000000000000000000000"}, + expected=ObjectId("000000000000000000000000"), + ), + ExpressionTestCase( + "all_f", + msg="All-f hex string converts to the max ObjectId", + expression={"$toObjectId": "ffffffffffffffffffffffff"}, + expected=ObjectId("ffffffffffffffffffffffff"), + ), +] + +# Property [ObjectId Identity]: ObjectId input is returned unchanged. +TOOBJECTID_IDENTITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "objectid_identity", + msg="ObjectId input passes through unchanged", + expression={"$toObjectId": ObjectId("507f1f77bcf86cd799439011")}, + expected=ObjectId("507f1f77bcf86cd799439011"), + ), + ExpressionTestCase( + "objectid_identity_zeros", + msg="All-zero ObjectId passes through unchanged", + expression={"$toObjectId": ObjectId("000000000000000000000000")}, + expected=ObjectId("000000000000000000000000"), + ), +] + +# Property [Expression Input]: $toObjectId evaluates a nested expression before converting. +TOOBJECTID_EXPRESSION_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "concat_expression", + msg="$toObjectId converts a string produced by a $concat expression", + expression={"$toObjectId": {"$concat": ["507f1f77bcf8", "6cd799439011"]}}, + expected=ObjectId("507f1f77bcf86cd799439011"), + ), + ExpressionTestCase( + "toobjectid_of_toobjectid", + msg="$toObjectId of a $toObjectId expression returns the same ObjectId", + expression={"$toObjectId": {"$toObjectId": "507f1f77bcf86cd799439011"}}, + expected=ObjectId("507f1f77bcf86cd799439011"), + ), +] + +# Property [Field Reference]: $toObjectId resolves field paths from inserted documents. +TOOBJECTID_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_field", + msg="Valid hex string field converts to ObjectId", + expression={"$toObjectId": "$v"}, + doc={"v": "507f1f77bcf86cd799439011"}, + expected=ObjectId("507f1f77bcf86cd799439011"), + ), + ExpressionTestCase( + "objectid_field", + msg="ObjectId field passes through unchanged", + expression={"$toObjectId": "$v"}, + doc={"v": ObjectId("507f1f77bcf86cd799439011")}, + expected=ObjectId("507f1f77bcf86cd799439011"), + ), + ExpressionTestCase( + "missing_field", + msg="Missing top-level field returns null", + expression={"$toObjectId": "$v"}, + doc={}, + expected=None, + ), + ExpressionTestCase( + "nested_field", + msg="Nested field path resolves to ObjectId", + expression={"$toObjectId": "$doc.v"}, + doc={"doc": {"v": "507f1f77bcf86cd799439011"}}, + expected=ObjectId("507f1f77bcf86cd799439011"), + ), + ExpressionTestCase( + "missing_nested_field", + msg="Missing nested field returns null", + expression={"$toObjectId": "$doc.missing"}, + doc={"doc": {"x": 1}}, + expected=None, + ), + ExpressionTestCase( + "composite_array_path", + msg="Field path resolving to a composite array is a conversion failure", + expression={"$toObjectId": "$a.b"}, + doc={"a": [{"b": "507f1f77bcf86cd799439011"}, {"b": "aaaaaaaaaaaaaaaaaaaaaaaa"}]}, + error_code=CONVERSION_FAILURE_ERROR, + ), +] + + +TOOBJECTID_CORE_TESTS = with_convert_variants( + TOOBJECTID_HEX_STRING_TESTS + + TOOBJECTID_IDENTITY_TESTS + + TOOBJECTID_EXPRESSION_INPUT_TESTS + + TOOBJECTID_FIELD_REF_TESTS, + "$toObjectId", + "objectId", +) + + +@pytest.mark.parametrize("test", pytest_params(TOOBJECTID_CORE_TESTS)) +def test_toObjectId_core(collection, test: ExpressionTestCase): + """$toObjectId converts hex strings, passes ObjectId through, and resolves field paths.""" + 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/toObjectId/test_toObjectId_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toObjectId/test_toObjectId_null.py new file mode 100644 index 000000000..3cd90b291 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toObjectId/test_toObjectId_null.py @@ -0,0 +1,44 @@ +"""$toObjectId null and missing input tests.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) +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 MISSING + +# Property [Null and Missing Behavior]: if the input is null or missing, the result is null. +TOOBJECTID_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_input", + msg="null input returns null", + expression={"$toObjectId": None}, + expected=None, + ), + ExpressionTestCase( + "missing_field", + msg="Missing field reference returns null", + expression={"$toObjectId": MISSING}, + expected=None, + ), +] + + +@pytest.mark.parametrize( + "test", + pytest_params(with_convert_variants(TOOBJECTID_NULL_TESTS, "$toObjectId", "objectId")), +) +def test_toObjectId_null(collection, test: ExpressionTestCase): + """$toObjectId returns 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/toObjectId/test_toObjectId_return_type.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toObjectId/test_toObjectId_return_type.py new file mode 100644 index 000000000..4e2c3cecd --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toObjectId/test_toObjectId_return_type.py @@ -0,0 +1,110 @@ +"""$toObjectId return-type invariant, idempotency, and type rejection tests.""" + +import pytest +from bson import ObjectId + +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, + generate_bson_rejection_test_cases, +) +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 MISSING + +# Property [Return Type / Type Rejection]: STRING (valid 24-char hex) and OBJECT_ID are the +# only accepted types; all others produce a conversion failure. STRING rejection is tested by +# content in test_toObjectId_string.py; ARRAY triggers arity semantics in +# test_toObjectId_arity.py — both are skipped here. +TOOBJECTID_BSON_TYPE_SPEC = [ + BsonTypeTestCase( + id="toObjectId", + msg="$toObjectId BSON type", + valid_types=[BsonType.OBJECT_ID, BsonType.STRING], + valid_inputs={BsonType.STRING: "507f1f77bcf86cd799439011"}, + skip_rejection_types=[BsonType.STRING, BsonType.NULL, BsonType.ARRAY], + default_error_code=CONVERSION_FAILURE_ERROR, + ), +] + +RETURN_TYPE_OBJECTID_CASES = generate_bson_acceptance_test_cases(TOOBJECTID_BSON_TYPE_SPEC) +TOOBJECTID_REJECTION_CASES = generate_bson_rejection_test_cases(TOOBJECTID_BSON_TYPE_SPEC) + +_OBJECTID_EXPR_FORMS = [ + pytest.param(lambda v: {"$toObjectId": v}, id="toObjectId"), + pytest.param(lambda v: {"$convert": {"input": v, "to": "objectId"}}, id="convert"), +] + + +@pytest.mark.parametrize("expr_fn", _OBJECTID_EXPR_FORMS) +@pytest.mark.parametrize("bson_type,sample_value,spec", RETURN_TYPE_OBJECTID_CASES) +def test_toObjectId_return_type_is_objectId(collection, bson_type, sample_value, spec, expr_fn): + """$toObjectId and $convert to objectId always return BSON type 'objectId'.""" + result = execute_expression(collection, {"$type": expr_fn(sample_value)}) + assert_expression_result( + result, expected="objectId", msg=f"{spec.msg} ({bson_type.value} input)" + ) + + +@pytest.mark.parametrize("expr_fn", _OBJECTID_EXPR_FORMS) +@pytest.mark.parametrize("bson_type,sample_value,spec", TOOBJECTID_REJECTION_CASES) +def test_toObjectId_type_rejection(collection, bson_type, sample_value, spec, expr_fn): + """$toObjectId and $convert to objectId reject invalid BSON types with conversion failure.""" + result = execute_expression(collection, expr_fn(sample_value)) + assert_expression_result( + result, + error_code=spec.expected_code(bson_type), + msg=f"{spec.msg} ({bson_type.value} rejected)", + ) + + +# Property [Return Type - Null]: $toObjectId returns BSON type null for null or missing inputs. +TOOBJECTID_RETURN_TYPE_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null", + msg="$toObjectId of null returns null type", + expression={"$type": {"$toObjectId": None}}, + expected="null", + ), + ExpressionTestCase( + "missing", + msg="$toObjectId of a missing field returns null type", + expression={"$type": {"$toObjectId": MISSING}}, + expected="null", + ), +] + +# Property [Idempotency]: applying $toObjectId twice produces the same result as once. +TOOBJECTID_IDEMPOTENCY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "idempotent_string", + msg="$toObjectId is idempotent for a valid hex string (string → ObjectId → ObjectId)", + expression={"$toObjectId": {"$toObjectId": "507f1f77bcf86cd799439011"}}, + expected=ObjectId("507f1f77bcf86cd799439011"), + ), + ExpressionTestCase( + "idempotent_objectid", + msg="$toObjectId is idempotent for ObjectId input", + expression={"$toObjectId": {"$toObjectId": ObjectId("aaaaaaaaaaaaaaaaaaaaaaaa")}}, + expected=ObjectId("aaaaaaaaaaaaaaaaaaaaaaaa"), + ), +] + + +@pytest.mark.parametrize( + "test", pytest_params(TOOBJECTID_RETURN_TYPE_NULL_TESTS + TOOBJECTID_IDEMPOTENCY_TESTS) +) +def test_toObjectId_return_type_null_and_idempotency(collection, test: ExpressionTestCase): + """$toObjectId returns null type for null/missing input and is idempotent.""" + 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/toObjectId/test_toObjectId_string.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toObjectId/test_toObjectId_string.py new file mode 100644 index 000000000..ee5f1868b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/toObjectId/test_toObjectId_string.py @@ -0,0 +1,289 @@ +"""$toObjectId string validation tests: invalid length, non-hex characters, whitespace, and size limit.""" # noqa: E501 + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.utils.convert_variants import ( # noqa: E501 + with_convert_variants, +) +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 [Invalid String Length]: strings with byte length other than 24 produce an error. +TOOBJECTID_LENGTH_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "length_empty", + msg="Empty string is rejected", + expression={"$toObjectId": ""}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "length_1_byte", + msg="1-byte string is rejected", + expression={"$toObjectId": "a"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "length_12_bytes", + msg="12-byte string is rejected", + expression={"$toObjectId": "a" * 12}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "length_23_bytes", + msg="23-byte string is rejected", + expression={"$toObjectId": "a" * 23}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "length_25_bytes", + msg="25-byte string is rejected", + expression={"$toObjectId": "a" * 25}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "length_24cp_2byte", + msg="24 codepoints with 2-byte UTF-8 chars are rejected (byte length is 48, not 24)", + expression={"$toObjectId": "\u00e9" * 24}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "length_24cp_4byte", + msg="24 codepoints with 4-byte UTF-8 emoji are rejected (byte length is 96, not 24)", + expression={"$toObjectId": "\U0001f600" * 24}, + error_code=CONVERSION_FAILURE_ERROR, + ), +] + +# Property [Invalid Hex Characters]: a 24-byte string containing non-hex characters is rejected. +# Tests cover ASCII boundary characters around the valid hex ranges [0-9], [A-F], [a-f]. +TOOBJECTID_HEX_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "hex_slash_start", + msg="'/' (before '0' in ASCII) at position 0 is rejected", + expression={"$toObjectId": "/" + "0" * 23}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "hex_colon_start", + msg="':' (after '9' in ASCII) at position 0 is rejected", + expression={"$toObjectId": ":" + "0" * 23}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "hex_at_sign_start", + msg="'@' (before 'A' in ASCII) at position 0 is rejected", + expression={"$toObjectId": "@" + "0" * 23}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "hex_upper_g_start", + msg="'G' (after 'F' in ASCII) at position 0 is rejected", + expression={"$toObjectId": "G" + "0" * 23}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "hex_backtick_start", + msg="'`' (before 'a' in ASCII) at position 0 is rejected", + expression={"$toObjectId": "`" + "0" * 23}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "hex_lower_g_start", + msg="'g' (after 'f' in ASCII) at position 0 is rejected", + expression={"$toObjectId": "g" + "0" * 23}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "hex_slash_middle", + msg="'/' at position 12 is rejected", + expression={"$toObjectId": "0" * 12 + "/" + "0" * 11}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "hex_colon_middle", + msg="':' at position 12 is rejected", + expression={"$toObjectId": "0" * 12 + ":" + "0" * 11}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "hex_upper_g_middle", + msg="'G' at position 12 is rejected", + expression={"$toObjectId": "0" * 12 + "G" + "0" * 11}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "hex_lower_g_middle", + msg="'g' at position 12 is rejected", + expression={"$toObjectId": "0" * 12 + "g" + "0" * 11}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "hex_slash_end", + msg="'/' at position 23 is rejected", + expression={"$toObjectId": "0" * 23 + "/"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "hex_colon_end", + msg="':' at position 23 is rejected", + expression={"$toObjectId": "0" * 23 + ":"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "hex_upper_g_end", + msg="'G' at position 23 is rejected", + expression={"$toObjectId": "0" * 23 + "G"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "hex_lower_g_end", + msg="'g' at position 23 is rejected", + expression={"$toObjectId": "0" * 23 + "g"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "hex_null_byte_start", + msg="Null byte (U+0000) at position 0 is rejected", + expression={"$toObjectId": "\x00" + "0" * 23}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "hex_null_byte_middle", + msg="Null byte (U+0000) at position 12 is rejected", + expression={"$toObjectId": "0" * 12 + "\x00" + "0" * 11}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "hex_null_byte_end", + msg="Null byte (U+0000) at position 23 is rejected", + expression={"$toObjectId": "0" * 23 + "\x00"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "hex_fullwidth_A", + msg="Fullwidth 'A' (U+FF21, lookalike of 'A') is rejected", + expression={"$toObjectId": "\uff21" + "0" * 23}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "hex_fullwidth_a", + msg="Fullwidth 'a' (U+FF41, lookalike of 'a') is rejected", + expression={"$toObjectId": "\uff41" + "0" * 23}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "hex_cyrillic_a", + msg="Cyrillic 'а' (U+0430, homoglyph of 'a') is rejected", + expression={"$toObjectId": "\u0430" + "0" * 23}, + error_code=CONVERSION_FAILURE_ERROR, + ), +] + +# Property [Whitespace]: no whitespace trimming or Unicode normalization is performed on the +# input string; any whitespace or invisible character causes a conversion failure. +TOOBJECTID_WHITESPACE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "ws_leading_space", + msg="Leading ASCII space is not trimmed and causes rejection", + expression={"$toObjectId": " 507f1f77bcf86cd79943901"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "ws_trailing_space", + msg="Trailing ASCII space is not trimmed and causes rejection", + expression={"$toObjectId": "507f1f77bcf86cd79943901 "}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "ws_middle_space", + msg="Embedded ASCII space causes rejection", + expression={"$toObjectId": "507f1f77bcf8 cd799439011"}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "ws_tab_start", + msg="Tab (U+0009) at position 0 causes rejection", + expression={"$toObjectId": "\t" + "0" * 23}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "ws_tab_middle", + msg="Tab (U+0009) at position 12 causes rejection", + expression={"$toObjectId": "0" * 12 + "\t" + "0" * 11}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "ws_newline_start", + msg="Newline (U+000A) at position 0 causes rejection", + expression={"$toObjectId": "\n" + "0" * 23}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "ws_newline_middle", + msg="Newline (U+000A) at position 12 causes rejection", + expression={"$toObjectId": "0" * 12 + "\n" + "0" * 11}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "ws_bom_start", + msg="BOM (U+FEFF) at position 0 causes rejection", + expression={"$toObjectId": "\ufeff" + "0" * 23}, + error_code=CONVERSION_FAILURE_ERROR, + ), + ExpressionTestCase( + "ws_zwsp_middle", + msg="Zero-width space (U+200B) at position 12 causes rejection", + expression={"$toObjectId": "0" * 12 + "\u200b" + "0" * 11}, + error_code=CONVERSION_FAILURE_ERROR, + ), +] + +# Property [String Size Limit]: strings at or above the 16 MB byte limit are rejected with a +# string size limit error before hex validation is attempted. +TOOBJECTID_STRING_SIZE_LIMIT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "size_one_under", + msg="Non-hex string one byte under the limit passes the size check but fails conversion", + expression=lazy(lambda: {"$toObjectId": "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: {"$toObjectId": "a" * STRING_SIZE_LIMIT_BYTES}), + error_code=STRING_SIZE_LIMIT_ERROR, + ), + ExpressionTestCase( + "size_at_limit_four_byte", + msg="Four-byte character string reaching 16 MB bytes is rejected with STRING_SIZE_LIMIT_ERROR", # noqa: E501 + expression=lazy(lambda: {"$toObjectId": "\U0001f600" * (STRING_SIZE_LIMIT_BYTES // 4)}), + error_code=STRING_SIZE_LIMIT_ERROR, + ), +] + +TOOBJECTID_STRING_TESTS = with_convert_variants( + TOOBJECTID_LENGTH_ERROR_TESTS + + TOOBJECTID_HEX_ERROR_TESTS + + TOOBJECTID_WHITESPACE_ERROR_TESTS + + TOOBJECTID_STRING_SIZE_LIMIT_TESTS, + "$toObjectId", + "objectId", +) + + +@pytest.mark.parametrize("test", pytest_params(TOOBJECTID_STRING_TESTS)) +def test_toObjectId_string(collection, test: ExpressionTestCase): + """$toObjectId rejects invalid strings: wrong length, non-hex chars, whitespace, size limit.""" + 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/utils/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/utils/convert_variants.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/utils/convert_variants.py new file mode 100644 index 000000000..72b2ae2ac --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/utils/convert_variants.py @@ -0,0 +1,69 @@ +"""Utility for expanding ExpressionTestCase lists with $convert parity variants. + +$convert is the general form behind $toInt, $toBool, $toString, etc. +Use ``with_convert_variants`` to add a $convert-equivalent test case alongside +each native-operator test case, without changing ExpressionTestCase or test +functions. +""" + +from __future__ import annotations + +from dataclasses import replace + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.framework.lazy_payload import Lazy + + +def with_convert_variants( + tests: list[ExpressionTestCase], + operator_key: str, + target_type: str, + extra_convert_fields: dict | None = None, +) -> list[ExpressionTestCase]: + """Return *tests* interleaved with a $convert-equivalent for each eligible case. + + For each test case whose ``expression`` is a plain dict containing + ``operator_key`` (e.g. ``"$toBool"``), a sibling case is appended + immediately after it with:: + + expression={"$convert": {"input": , "to": target_type}} + + The sibling inherits all other fields (``expected``, ``error_code``, + ``msg``, ``marks``) unchanged, and its ``id`` is prefixed with + ``"convert_"``. + + ``extra_convert_fields`` is merged into the ``$convert`` document after + ``input`` and ``to``. Use this when the operator requires additional + options (e.g. ``{"format": "auto"}`` for binary-to-string conversions). + + Cases are skipped when: + + - ``expression`` is a ``Lazy`` payload (size-limit tests — the large value + is the point, not type-conversion parity). + - ``expression`` is not a dict or does not contain ``operator_key`` (e.g. + arity tests that pass an array as the whole argument). + + Callers should not pass arity test lists to this function, since arity is + operator-specific behaviour that $convert does not share. + """ + result: list[ExpressionTestCase] = [] + for t in tests: + result.append(t) + expr = t.expression + if isinstance(expr, Lazy) or not isinstance(expr, dict): + continue + if operator_key not in expr: + continue + convert_doc: dict = {"input": expr[operator_key], "to": target_type} + if extra_convert_fields: + convert_doc.update(extra_convert_fields) + result.append( + replace( + t, + id=f"convert_{t.id}", + expression={"$convert": convert_doc}, + ) + ) + return result