diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_bindata.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_bindata.py new file mode 100644 index 00000000..bb042596 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_bindata.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import struct + +import pytest +from bson import Binary, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.convert.utils.convert_common import ( # noqa: E501 + ConvertTest, + _expr, +) +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 BinData Formats]: BinData-to-string conversion requires the +# format parameter and produces format-specific output. +CONVERT_STRING_BINDATA_FORMAT_TESTS: list[ConvertTest] = [ + ConvertTest( + "string_bindata_base64", + input=Binary(b">>>>"), + to="string", + format="base64", + expected="Pj4+Pg==", + msg="$convert should encode BinData as base64 with padding", + ), + ConvertTest( + "string_bindata_base64url", + input=Binary(b">>>>"), + to="string", + format="base64url", + expected="Pj4-Pg", + msg="$convert should encode BinData as base64url with URL-safe chars and no padding", + ), + ConvertTest( + "string_bindata_utf8", + input=Binary(b"hello"), + to="string", + format="utf8", + expected="hello", + msg="$convert should decode BinData as UTF-8 string", + ), + ConvertTest( + "string_bindata_hex", + input=Binary(b"hello"), + to="string", + format="hex", + expected="68656C6C6F", + msg="$convert should encode BinData as uppercase hex", + ), + ConvertTest( + "string_bindata_uuid", + input=Binary( + b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10", + 4, + ), + to="string", + format="uuid", + expected="01020304-0506-0708-090a-0b0c0d0e0f10", + msg="$convert should format subtype 4 BinData as UUID string", + ), +] + +# Property [BinData Core Conversions]: each supported numeric source type +# converts to binData with the correct byte representation. +CONVERT_BINDATA_CORE_TESTS: list[ConvertTest] = [ + ConvertTest( + "bindata_from_int", + input=42, + to="binData", + expected=struct.pack(">>>", + msg="$convert should decode base64 string to binData", + ), + ConvertTest( + "bindata_string_base64url", + input="Pj4-Pg", + to="binData", + format="base64url", + expected=b">>>>", + msg="$convert should decode base64url string with URL-safe chars and no padding to binData", + ), + ConvertTest( + "bindata_string_utf8", + input="hello", + to="binData", + format="utf8", + expected=b"hello", + msg="$convert should encode UTF-8 string to binData", + ), + ConvertTest( + "bindata_string_hex", + input="68656C6C6F", + to="binData", + format="hex", + expected=b"hello", + msg="$convert should decode hex string to binData", + ), + ConvertTest( + "bindata_string_uuid", + input="01020304-0506-0708-090a-0b0c0d0e0f10", + to={"type": "binData", "subtype": 4}, + format="uuid", + expected=Binary( + b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10", + 4, + ), + msg="$convert should decode UUID string to subtype 4 binData", + ), +] + +CONVERT_BINDATA_TESTS = ( + CONVERT_STRING_BINDATA_FORMAT_TESTS + + CONVERT_BINDATA_CORE_TESTS + + CONVERT_BINDATA_STRING_FORMAT_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(CONVERT_BINDATA_TESTS)) +def test_convert_bindata(collection, test_case: ConvertTest): + """Test $convert BinData conversions.""" + result = execute_expression(collection, _expr(test_case)) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_bindata_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_bindata_errors.py new file mode 100644 index 00000000..c190aa2d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_bindata_errors.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.convert.utils.convert_common import ( # noqa: E501 + ConvertTest, + _expr, +) +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 [BinData Conversion Errors]: input types not in the accepted set for +# binData produce a conversion failure error. +CONVERT_BINDATA_ERROR_TESTS: list[ConvertTest] = [ + ConvertTest( + "bindata_err_bool", + input=True, + to="binData", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject bool to binData", + ), + ConvertTest( + "bindata_err_decimal", + input=Decimal128("42"), + to="binData", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject Decimal128 to binData", + ), + ConvertTest( + "bindata_err_date", + input=datetime(2024, 1, 1, tzinfo=timezone.utc), + to="binData", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject date to binData", + ), + ConvertTest( + "bindata_err_objectid", + input=ObjectId("507f1f77bcf86cd799439011"), + to="binData", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject ObjectId to binData", + ), + ConvertTest( + "bindata_err_regex", + input=Regex("abc"), + to="binData", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject Regex to binData", + ), + ConvertTest( + "bindata_err_array", + input=[1, 2], + to="binData", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject array to binData", + ), + ConvertTest( + "bindata_err_object", + input={"key": "val"}, + to="binData", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject object to binData", + ), + ConvertTest( + "bindata_err_code", + input=Code("function(){}"), + to="binData", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject Code to binData", + ), + ConvertTest( + "bindata_err_code_with_scope", + input=Code("function(){}", {}), + to="binData", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject Code with scope to binData", + ), + ConvertTest( + "bindata_err_minkey", + input=MinKey(), + to="binData", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject MinKey to binData", + ), + ConvertTest( + "bindata_err_maxkey", + input=MaxKey(), + to="binData", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject MaxKey to binData", + ), + ConvertTest( + "bindata_err_timestamp", + input=Timestamp(1, 1), + to="binData", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject Timestamp to binData", + ), +] + +# Property [Cross-Subtype BinData Errors]: converting BinData to BinData with a +# different subtype produces a conversion failure error. +CONVERT_CROSS_SUBTYPE_ERROR_TESTS: list[ConvertTest] = [ + ConvertTest( + "cross_subtype_err_3_to_0", + input=Binary(b"\x01\x02", 3), + to={"type": "binData", "subtype": 0}, + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject BinData subtype 3 to subtype 0", + ), + ConvertTest( + "cross_subtype_err_0_to_4", + input=Binary(b"\x01\x02", 0), + to={"type": "binData", "subtype": 4}, + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject BinData subtype 0 to subtype 4", + ), + ConvertTest( + "cross_subtype_err_128_to_0", + input=Binary(b"\x01\x02", 128), + to={"type": "binData", "subtype": 0}, + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject BinData subtype 128 to subtype 0", + ), +] + +# Property [BinData Format Conversion Errors]: invalid string inputs for format +# conversions produce a conversion failure error. +CONVERT_BINDATA_FORMAT_ERROR_TESTS: list[ConvertTest] = [ + ConvertTest( + "bindata_fmt_err_auto_string_to_bindata", + input="aGVsbG8=", + to="binData", + format="auto", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject auto format for string to binData", + ), + ConvertTest( + "bindata_fmt_err_base64_no_padding", + input="aGVsbG8", + to="binData", + format="base64", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject base64 string without padding", + ), + ConvertTest( + "bindata_fmt_err_base64url_chars_in_base64", + input="aGVs-G8=", + to="binData", + format="base64", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject base64url characters in base64 input", + ), + ConvertTest( + "bindata_fmt_err_base64_chars_in_base64url", + input="aGVs+G8", + to="binData", + format="base64url", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject base64 characters in base64url input", + ), + ConvertTest( + "bindata_fmt_err_hex_odd_length", + input="abc", + to="binData", + format="hex", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject odd-length hex string", + ), + ConvertTest( + "bindata_fmt_err_hex_invalid_char", + input="GG", + to="binData", + format="hex", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject invalid hex character", + ), + ConvertTest( + "bindata_fmt_err_uuid_non_subtype_4", + input="550e8400-e29b-41d4-a716-446655440000", + to={"type": "binData", "subtype": 0}, + format="uuid", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject uuid format with non-4 subtype", + ), + ConvertTest( + "bindata_fmt_err_subtype_4_non_uuid_format", + input="aGVsbG8=", + to={"type": "binData", "subtype": 4}, + format="base64", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject non-uuid format with subtype 4", + ), + ConvertTest( + "bindata_fmt_err_invalid_uuid_string", + input="not-a-uuid", + to={"type": "binData", "subtype": 4}, + format="uuid", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject invalid uuid string", + ), + ConvertTest( + "bindata_fmt_err_base64_invalid", + input="not-base64!", + to="binData", + format="base64", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject invalid base64 string", + ), + ConvertTest( + "bindata_fmt_err_base64url_invalid", + input="not!valid", + to="binData", + format="base64url", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject invalid base64url string", + ), + ConvertTest( + "bindata_fmt_err_uuid_truncated", + input="550e8400-e29b-41d4-a716", + to={"type": "binData", "subtype": 4}, + format="uuid", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject truncated uuid string", + ), + ConvertTest( + "bindata_fmt_err_uuid_no_dashes", + input="550e8400e29b41d4a716446655440000", + to={"type": "binData", "subtype": 4}, + format="uuid", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject uuid string without dashes", + ), + # 0x80 is an invalid UTF-8 start byte. + ConvertTest( + "bindata_fmt_err_utf8_invalid_bytes", + input=Binary(b"\x80\xc0\x00"), + to="string", + format="utf8", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject BinData with invalid UTF-8 byte sequence", + ), +] + +CONVERT_BINDATA_ALL_ERROR_TESTS = ( + CONVERT_BINDATA_ERROR_TESTS + + CONVERT_CROSS_SUBTYPE_ERROR_TESTS + + CONVERT_BINDATA_FORMAT_ERROR_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(CONVERT_BINDATA_ALL_ERROR_TESTS)) +def test_convert_bindata_errors(collection, test_case: ConvertTest): + """Test $convert BinData conversion errors.""" + result = execute_expression(collection, _expr(test_case)) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_byte_order.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_byte_order.py new file mode 100644 index 00000000..cddac25c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_byte_order.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +import struct +from datetime import datetime + +import pytest +from bson import Binary, Decimal128, Int64, ObjectId + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.convert.utils.convert_common import ( # noqa: E501 + ConvertTest, + _expr, +) +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 [byteOrder Default]: omitting byteOrder or specifying "little" +# produces identical little-endian byte ordering for numeric-to-binData and +# binData-to-numeric conversions. +CONVERT_BYTE_ORDER_DEFAULT_TESTS: list[ConvertTest] = [ + ConvertTest( + "byte_order_omitted_int", + input=99, + to="binData", + expected=struct.pack("i", 42), + msg="$convert should produce big-endian binData from int", + ), + ConvertTest( + "byte_order_big_long_to_bindata", + input=Int64(42), + to="binData", + byte_order="big", + expected=struct.pack(">q", 42), + msg="$convert should produce big-endian binData from long", + ), + ConvertTest( + "byte_order_big_double_to_bindata", + input=42.0, + to="binData", + byte_order="big", + expected=struct.pack(">d", 42.0), + msg="$convert should produce big-endian binData from double", + ), + ConvertTest( + "byte_order_big_bindata_to_int", + input=Binary(struct.pack(">i", 42)), + to="int", + byte_order="big", + expected=42, + msg="$convert should interpret big-endian binData as int", + ), + ConvertTest( + "byte_order_big_bindata_to_long", + input=Binary(struct.pack(">q", 42)), + to="long", + byte_order="big", + expected=Int64(42), + msg="$convert should interpret big-endian binData as long", + ), + ConvertTest( + "byte_order_big_bindata_to_double", + input=Binary(struct.pack(">d", 42.0)), + to="double", + byte_order="big", + expected=42.0, + msg="$convert should interpret big-endian binData as double", + ), +] + +# Property [byteOrder Ignored for Non-Numeric-BinData]: byteOrder is silently +# ignored for conversions that do not involve numeric-to-binData or +# binData-to-numeric paths. +CONVERT_BYTE_ORDER_IGNORED_TESTS: list[ConvertTest] = [ + ConvertTest( + "byte_order_ignored_string_to_int", + input="42", + to="int", + byte_order="big", + expected=42, + msg="$convert should ignore byteOrder for string to int", + ), + ConvertTest( + "byte_order_ignored_string_to_long", + input="42", + to="long", + byte_order="big", + expected=Int64(42), + msg="$convert should ignore byteOrder for string to long", + ), + ConvertTest( + "byte_order_ignored_int_to_double", + input=42, + to="double", + byte_order="big", + expected=42.0, + msg="$convert should ignore byteOrder for int to double", + ), + ConvertTest( + "byte_order_ignored_int_to_decimal", + input=42, + to="decimal", + byte_order="big", + expected=Decimal128("42"), + msg="$convert should ignore byteOrder for int to decimal", + ), + ConvertTest( + "byte_order_ignored_int_to_string", + input=42, + to="string", + byte_order="big", + expected="42", + msg="$convert should ignore byteOrder for int to string", + ), + ConvertTest( + "byte_order_ignored_int_to_bool", + input=42, + to="bool", + byte_order="big", + expected=True, + msg="$convert should ignore byteOrder for int to bool", + ), + ConvertTest( + "byte_order_ignored_string_to_date", + input="2024-06-15T12:30:45Z", + to="date", + byte_order="big", + expected=datetime(2024, 6, 15, 12, 30, 45), + msg="$convert should ignore byteOrder for string to date", + ), + ConvertTest( + "byte_order_ignored_string_to_objectId", + input="507f1f77bcf86cd799439011", + to="objectId", + byte_order="big", + expected=ObjectId("507f1f77bcf86cd799439011"), + msg="$convert should ignore byteOrder for string to objectId", + ), + ConvertTest( + "byte_order_ignored_string_to_bindata_utf8", + input="hello", + to="binData", + byte_order="big", + format="utf8", + expected=b"hello", + msg="$convert should ignore byteOrder for string to binData with format", + ), +] + +CONVERT_BYTE_ORDER_SUCCESS_TESTS = ( + CONVERT_BYTE_ORDER_DEFAULT_TESTS + + CONVERT_BYTE_ORDER_BIG_TESTS + + CONVERT_BYTE_ORDER_IGNORED_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(CONVERT_BYTE_ORDER_SUCCESS_TESTS)) +def test_convert_byte_order(collection, test_case: ConvertTest): + """Test $convert byteOrder behavior.""" + result = execute_expression(collection, _expr(test_case)) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_byte_order_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_byte_order_errors.py new file mode 100644 index 00000000..ad9335d0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_byte_order_errors.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +from datetime import datetime + +import pytest +from bson import ( + Binary, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from bson.code import Code + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.convert.utils.convert_common import ( # noqa: E501 + ConvertTest, + _expr, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.error_codes import ( + CONVERT_BYTE_ORDER_TYPE_ERROR, + CONVERT_BYTE_ORDER_VALUE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [ByteOrder Errors]: invalid 'byteOrder' parameter values produce +# specific error codes for wrong types or unrecognized strings. +CONVERT_BYTE_ORDER_ERROR_TESTS: list[ConvertTest] = [ + # Unrecognized byteOrder string values. + ConvertTest( + "byte_order_value_medium", + input=42, + to="binData", + byte_order="medium", + error_code=CONVERT_BYTE_ORDER_VALUE_ERROR, + msg="$convert should reject unrecognized byteOrder 'medium'", + ), + ConvertTest( + "byte_order_value_empty", + input=42, + to="binData", + byte_order="", + error_code=CONVERT_BYTE_ORDER_VALUE_ERROR, + msg="$convert should reject empty string as byteOrder", + ), + ConvertTest( + "byte_order_value_case_BIG", + input=42, + to="binData", + byte_order="BIG", + error_code=CONVERT_BYTE_ORDER_VALUE_ERROR, + msg="$convert should reject case-mismatched byteOrder 'BIG'", + ), + # Wrong type for byteOrder. + ConvertTest( + "byte_order_type_int", + input=42, + to="binData", + byte_order=42, + error_code=CONVERT_BYTE_ORDER_TYPE_ERROR, + msg="$convert should reject int as byteOrder", + ), + ConvertTest( + "byte_order_type_bool", + input=42, + to="binData", + byte_order=True, + error_code=CONVERT_BYTE_ORDER_TYPE_ERROR, + msg="$convert should reject bool as byteOrder", + ), + ConvertTest( + "byte_order_type_array", + input=42, + to="binData", + byte_order=[], + error_code=CONVERT_BYTE_ORDER_TYPE_ERROR, + msg="$convert should reject array as byteOrder", + ), + ConvertTest( + "byte_order_type_object", + input=42, + to="binData", + byte_order={"a": 1}, + error_code=CONVERT_BYTE_ORDER_TYPE_ERROR, + msg="$convert should reject object as byteOrder", + ), + ConvertTest( + "byte_order_type_double", + input=42, + to="binData", + byte_order=42.5, + error_code=CONVERT_BYTE_ORDER_TYPE_ERROR, + msg="$convert should reject double as byteOrder", + ), + ConvertTest( + "byte_order_type_long", + input=42, + to="binData", + byte_order=Int64(42), + error_code=CONVERT_BYTE_ORDER_TYPE_ERROR, + msg="$convert should reject Int64 as byteOrder", + ), + ConvertTest( + "byte_order_type_decimal", + input=42, + to="binData", + byte_order=Decimal128("42"), + error_code=CONVERT_BYTE_ORDER_TYPE_ERROR, + msg="$convert should reject Decimal128 as byteOrder", + ), + ConvertTest( + "byte_order_type_binary", + input=42, + to="binData", + byte_order=Binary(b"x"), + error_code=CONVERT_BYTE_ORDER_TYPE_ERROR, + msg="$convert should reject Binary as byteOrder", + ), + ConvertTest( + "byte_order_type_objectid", + input=42, + to="binData", + byte_order=ObjectId("507f1f77bcf86cd799439011"), + error_code=CONVERT_BYTE_ORDER_TYPE_ERROR, + msg="$convert should reject ObjectId as byteOrder", + ), + ConvertTest( + "byte_order_type_datetime", + input=42, + to="binData", + byte_order=datetime(2024, 1, 1), + error_code=CONVERT_BYTE_ORDER_TYPE_ERROR, + msg="$convert should reject datetime as byteOrder", + ), + ConvertTest( + "byte_order_type_regex", + input=42, + to="binData", + byte_order=Regex("abc"), + error_code=CONVERT_BYTE_ORDER_TYPE_ERROR, + msg="$convert should reject Regex as byteOrder", + ), + ConvertTest( + "byte_order_type_code", + input=42, + to="binData", + byte_order=Code("function(){}"), + error_code=CONVERT_BYTE_ORDER_TYPE_ERROR, + msg="$convert should reject Code as byteOrder", + ), + ConvertTest( + "byte_order_type_timestamp", + input=42, + to="binData", + byte_order=Timestamp(1, 1), + error_code=CONVERT_BYTE_ORDER_TYPE_ERROR, + msg="$convert should reject Timestamp as byteOrder", + ), + ConvertTest( + "byte_order_type_minkey", + input=42, + to="binData", + byte_order=MinKey(), + error_code=CONVERT_BYTE_ORDER_TYPE_ERROR, + msg="$convert should reject MinKey as byteOrder", + ), + ConvertTest( + "byte_order_type_maxkey", + input=42, + to="binData", + byte_order=MaxKey(), + error_code=CONVERT_BYTE_ORDER_TYPE_ERROR, + msg="$convert should reject MaxKey as byteOrder", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(CONVERT_BYTE_ORDER_ERROR_TESTS)) +def test_convert_byte_order_errors(collection, test_case: ConvertTest): + """Test $convert byteOrder parameter errors.""" + result = execute_expression(collection, _expr(test_case)) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_conversion_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_conversion_errors.py new file mode 100644 index 00000000..3fd3f35b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_conversion_errors.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +import pytest +from bson import Binary, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.convert.utils.convert_common import ( # noqa: E501 + ConvertTest, + _expr, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + CONVERSION_FAILURE_ERROR, + CONVERT_MISSING_FORMAT_ERROR, + DATETOSTRING_YEAR_RANGE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Eager Constant Evaluation]: constant expressions in onNull and +# onError are evaluated eagerly at optimization time regardless of whether +# their path is triggered. +CONVERT_EAGER_EVAL_TESTS: list[ConvertTest] = [ + ConvertTest( + "on_null_constant_error_with_non_null_input", + input="hello", + to="string", + on_null={"$divide": [1, 0]}, + error_code=BAD_VALUE_ERROR, + msg=( + "$convert should eagerly evaluate constant onNull expression even" + " when input is non-null" + ), + ), + ConvertTest( + "on_error_constant_error_with_successful_conversion", + input="42", + to="int", + on_error={"$divide": [1, 0]}, + error_code=BAD_VALUE_ERROR, + msg=( + "$convert should eagerly evaluate constant onError expression even" + " when conversion succeeds" + ), + ), +] + +# Property [Format Parameter Required]: converting between string and binData +# without specifying the format parameter produces a missing-format error. +CONVERT_FORMAT_REQUIRED_ERROR_TESTS: list[ConvertTest] = [ + ConvertTest( + "fmt_required_err_string_to_bindata", + input="hello", + to="binData", + error_code=CONVERT_MISSING_FORMAT_ERROR, + msg="$convert should reject string to binData without format", + ), + ConvertTest( + "fmt_required_err_bindata_sub0_to_string", + input=Binary(b"hello", 0), + to="string", + error_code=CONVERT_MISSING_FORMAT_ERROR, + msg="$convert should reject subtype 0 binData to string without format", + ), + ConvertTest( + "fmt_required_err_bindata_sub4_to_string", + input=Binary(b"hello", 4), + to="string", + error_code=CONVERT_MISSING_FORMAT_ERROR, + msg="$convert should reject subtype 4 binData to string without format", + ), + ConvertTest( + "fmt_required_err_bindata_sub4_uuid_to_string", + input=Binary( + b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10", + 4, + ), + to="string", + error_code=CONVERT_MISSING_FORMAT_ERROR, + msg="$convert should reject subtype 4 UUID binData to string without format", + ), +] + +# Property [to Unsupported Target Type Errors]: recognized but unsupported +# target types produce a conversion failure error, which is caught by onError. +CONVERT_UNSUPPORTED_TARGET_ERROR_TESTS: list[ConvertTest] = [ + ConvertTest( + "unsupported_target_null", + input=42, + to="null", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject unsupported target type null", + ), + ConvertTest( + "unsupported_target_array", + input=42, + to="array", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject unsupported target type array", + ), + ConvertTest( + "unsupported_target_object", + input=42, + to="object", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject unsupported target type object", + ), + ConvertTest( + "unsupported_target_regex", + input=42, + to="regex", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject unsupported target type regex", + ), + ConvertTest( + "unsupported_target_timestamp", + input=42, + to="timestamp", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject unsupported target type timestamp", + ), + ConvertTest( + "unsupported_target_minkey", + input=42, + to="minKey", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject unsupported target type minKey", + ), + ConvertTest( + "unsupported_target_maxkey", + input=42, + to="maxKey", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject unsupported target type maxKey", + ), + ConvertTest( + "unsupported_target_javascript", + input=42, + to="javascript", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject unsupported target type javascript", + ), + ConvertTest( + "unsupported_target_javascriptWithScope", + input=42, + to="javascriptWithScope", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject unsupported target type jsWithScope", + ), + ConvertTest( + "unsupported_target_symbol", + input=42, + to="symbol", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject unsupported target type symbol", + ), + ConvertTest( + "unsupported_target_undefined", + input=42, + to="undefined", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject unsupported target type undefined", + ), + ConvertTest( + "unsupported_target_dbPointer", + input=42, + to="dbPointer", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject unsupported target type dbPointer", + ), +] + +# Property [Date to String Year Range Error]: converting a date with year +# outside 0-9999 to string produces a year range error, which is caught by +# onError. +# Note: $toDate is used because Python's datetime cannot represent years +# outside 1-9999, so the Date must be constructed server-side. +CONVERT_DATE_STRING_YEAR_RANGE_ERROR_TESTS: list[ConvertTest] = [ + ConvertTest( + "date_str_year_0_success", + input={"$toDate": Int64(-62167219200000)}, + to="string", + expected="0000-01-01T00:00:00.000Z", + msg="$convert should succeed for date-to-string at year 0", + ), + ConvertTest( + "date_str_year_err_negative", + input={"$toDate": Int64(-62167219200001)}, + to="string", + error_code=DATETOSTRING_YEAR_RANGE_ERROR, + msg="$convert should reject date-to-string when year is before 0", + ), + ConvertTest( + "date_str_year_err_above_9999", + input={"$toDate": Int64(253402300800000)}, + to="string", + error_code=DATETOSTRING_YEAR_RANGE_ERROR, + msg="$convert should reject date-to-string when year is above 9999", + ), + ConvertTest( + "date_str_year_err_on_error_caught_negative", + input={"$toDate": Int64(-62167219200001)}, + to="string", + on_error="caught", + expected="caught", + msg="$convert onError should catch date-to-string year range error for negative year", + marks=( + pytest.mark.engine_xfail( + engine="mongodb", + reason="MongoDB evaluates year-range errors at optimize time, before onError fires", + raises=AssertionError, + ), + ), + ), + ConvertTest( + "date_str_year_err_on_error_caught_above_9999", + input={"$toDate": Int64(253402300800000)}, + to="string", + on_error="caught", + expected="caught", + msg="$convert onError should catch date-to-string year range error for year above 9999", + marks=( + pytest.mark.engine_xfail( + engine="mongodb", + reason="MongoDB evaluates year-range errors at optimize time, before onError fires", + raises=AssertionError, + ), + ), + ), +] + +CONVERT_CONVERSION_ERROR_TESTS = ( + CONVERT_EAGER_EVAL_TESTS + + CONVERT_FORMAT_REQUIRED_ERROR_TESTS + + CONVERT_UNSUPPORTED_TARGET_ERROR_TESTS + + CONVERT_DATE_STRING_YEAR_RANGE_ERROR_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(CONVERT_CONVERSION_ERROR_TESTS)) +def test_convert_conversion_errors(collection, test_case: ConvertTest): + """Test $convert conversion errors.""" + result = execute_expression(collection, _expr(test_case)) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_format.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_format.py new file mode 100644 index 00000000..a6d0d1e9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_format.py @@ -0,0 +1,333 @@ +from __future__ import annotations + +from datetime import datetime + +import pytest +from bson import Binary, Decimal128, Int64, ObjectId + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.convert.utils.convert_common import ( # noqa: E501 + ConvertTest, + _expr, +) +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 [Format Ignored]: format is silently ignored for conversions that do +# not involve string-to-binData or binData-to-string. +CONVERT_FORMAT_IGNORED_TESTS: list[ConvertTest] = [ + ConvertTest( + "format_ignored_string_to_int", + input="42", + to="int", + format="hex", + expected=42, + msg="$convert should ignore format for string to int", + ), + ConvertTest( + "format_ignored_int_to_long", + input=42, + to="long", + format="base64", + expected=Int64(42), + msg="$convert should ignore format for int to long", + ), + ConvertTest( + "format_ignored_int_to_double", + input=42, + to="double", + format="utf8", + expected=42.0, + msg="$convert should ignore format for int to double", + ), + ConvertTest( + "format_ignored_int_to_decimal", + input=42, + to="decimal", + format="base64", + expected=Decimal128("42"), + msg="$convert should ignore format for int to decimal", + ), + ConvertTest( + "format_ignored_int_to_string", + input=42, + to="string", + format="base64", + expected="42", + msg="$convert should ignore format for int to string", + ), + ConvertTest( + "format_ignored_int_to_bool", + input=42, + to="bool", + format="base64", + expected=True, + msg="$convert should ignore format for int to bool", + ), + ConvertTest( + "format_ignored_string_to_date", + input="2024-06-15T12:30:45Z", + to="date", + format="base64", + expected=datetime(2024, 6, 15, 12, 30, 45), + msg="$convert should ignore format for string to date", + ), + ConvertTest( + "format_ignored_string_to_objectId", + input="507f1f77bcf86cd799439011", + to="objectId", + format="base64", + expected=ObjectId("507f1f77bcf86cd799439011"), + msg="$convert should ignore format for string to objectId", + ), + ConvertTest( + "format_ignored_null_for_non_bindata", + input=42, + to="string", + format=None, + expected="42", + msg="$convert should ignore null format for non-binData conversion", + ), +] + +# Property [Format Encoding Details]: each format has specific encoding rules +# for character sets, padding, case sensitivity, and byte handling. +CONVERT_FORMAT_ENCODING_TESTS: list[ConvertTest] = [ + ConvertTest( + "format_base64url_with_padding", + input="aGVsbG8=", + to="binData", + format="base64url", + expected=b"hello", + msg="$convert should accept base64url input with padding", + ), + ConvertTest( + "format_base64_plus_slash", + input="a+b/cA==", + to="binData", + format="base64", + expected=b"k\xe6\xffp", + msg="$convert should accept +/ characters in base64 input", + ), + ConvertTest( + "format_base64url_dash_underscore", + input="a-b_cA", + to="binData", + format="base64url", + expected=b"k\xe6\xffp", + msg="$convert should accept -_ characters in base64url input", + ), + ConvertTest( + "format_hex_lowercase_input", + input="68656c6c6f", + to="binData", + format="hex", + expected=b"hello", + msg="$convert should accept lowercase hex input", + ), + ConvertTest( + "format_hex_mixed_case_input", + input="68656C6c6F", + to="binData", + format="hex", + expected=b"hello", + msg="$convert should accept mixed-case hex input", + ), + ConvertTest( + "format_utf8_null_bytes", + input=Binary(b"hel\x00lo"), + to="string", + format="utf8", + expected="hel\x00lo", + msg="$convert should preserve null bytes in utf8 format", + ), + ConvertTest( + "format_utf8_multibyte", + input=Binary("café".encode()), + to="string", + format="utf8", + expected="café", + msg="$convert should preserve multi-byte UTF-8 characters in binData to string", + ), + ConvertTest( + "format_utf8_multibyte_to_bindata", + input="café", + to="binData", + format="utf8", + expected="café".encode(), + msg="$convert should preserve multi-byte UTF-8 characters in string to binData", + ), + ConvertTest( + "format_base64_empty", + input="", + to="binData", + format="base64", + expected=b"", + msg="$convert should accept empty base64 string", + ), + ConvertTest( + "format_base64url_empty", + input="", + to="binData", + format="base64url", + expected=b"", + msg="$convert should accept empty base64url string", + ), + ConvertTest( + "format_hex_empty", + input="", + to="binData", + format="hex", + expected=b"", + msg="$convert should accept empty hex string", + ), + ConvertTest( + "format_utf8_empty", + input=Binary(b""), + to="string", + format="utf8", + expected="", + msg="$convert should accept empty binData for utf8 format", + ), +] + +# Property [Format UUID Flexibility]: uuid format accepts any valid UUID string +# regardless of version, and subtype 4 binData can use non-uuid formats for +# binData-to-string conversion. +CONVERT_FORMAT_UUID_FLEXIBILITY_TESTS: list[ConvertTest] = [ + ConvertTest( + "format_uuid_accepts_v1", + input="6ba7b810-9dad-11d1-80b4-00c04fd430c8", + to={"type": "binData", "subtype": 4}, + format="uuid", + expected=Binary( + b"\x6b\xa7\xb8\x10\x9d\xad\x11\xd1\x80\xb4\x00\xc0\x4f\xd4\x30\xc8", + 4, + ), + msg="$convert should accept UUIDv1 string with uuid format", + ), + ConvertTest( + "format_uuid_accepts_v2", + input="6ba7b811-9dad-21d1-80b4-00c04fd430c8", + to={"type": "binData", "subtype": 4}, + format="uuid", + expected=Binary( + b"\x6b\xa7\xb8\x11\x9d\xad\x21\xd1\x80\xb4\x00\xc0\x4f\xd4\x30\xc8", + 4, + ), + msg="$convert should accept UUIDv2 string with uuid format", + ), + ConvertTest( + "format_uuid_accepts_v3", + input="6ba7b811-9dad-31d1-80b4-00c04fd430c8", + to={"type": "binData", "subtype": 4}, + format="uuid", + expected=Binary( + b"\x6b\xa7\xb8\x11\x9d\xad\x31\xd1\x80\xb4\x00\xc0\x4f\xd4\x30\xc8", + 4, + ), + msg="$convert should accept UUIDv3 string with uuid format", + ), + ConvertTest( + "format_uuid_accepts_v5", + input="6ba7b812-9dad-51d1-80b4-00c04fd430c8", + to={"type": "binData", "subtype": 4}, + format="uuid", + expected=Binary( + b"\x6b\xa7\xb8\x12\x9d\xad\x51\xd1\x80\xb4\x00\xc0\x4f\xd4\x30\xc8", + 4, + ), + msg="$convert should accept UUIDv5 string with uuid format", + ), + ConvertTest( + "format_uuid_accepts_v6", + input="1ef21d2f-1207-6000-8000-000000000001", + to={"type": "binData", "subtype": 4}, + format="uuid", + expected=Binary( + b"\x1e\xf2\x1d\x2f\x12\x07\x60\x00\x80\x00\x00\x00\x00\x00\x00\x01", + 4, + ), + msg="$convert should accept UUIDv6 string with uuid format", + ), + ConvertTest( + "format_uuid_accepts_v7", + input="018f6b2e-7b3a-7def-8000-000000000001", + to={"type": "binData", "subtype": 4}, + format="uuid", + expected=Binary( + b"\x01\x8f\x6b\x2e\x7b\x3a\x7d\xef\x80\x00\x00\x00\x00\x00\x00\x01", + 4, + ), + msg="$convert should accept UUIDv7 string with uuid format", + ), + ConvertTest( + "format_uuid_accepts_v8", + input="018f6b2e-7b3a-8def-8000-000000000001", + to={"type": "binData", "subtype": 4}, + format="uuid", + expected=Binary( + b"\x01\x8f\x6b\x2e\x7b\x3a\x8d\xef\x80\x00\x00\x00\x00\x00\x00\x01", + 4, + ), + msg="$convert should accept UUIDv8 string with uuid format", + ), + ConvertTest( + "format_uuid_accepts_nil", + input="00000000-0000-0000-0000-000000000000", + to={"type": "binData", "subtype": 4}, + format="uuid", + expected=Binary(b"\x00" * 16, 4), + msg="$convert should accept nil UUID string with uuid format", + ), + ConvertTest( + "format_uuid_accepts_max", + input="ffffffff-ffff-ffff-ffff-ffffffffffff", + to={"type": "binData", "subtype": 4}, + format="uuid", + expected=Binary(b"\xff" * 16, 4), + msg="$convert should accept max UUID string with uuid format", + ), + ConvertTest( + "format_sub4_base64", + input=Binary( + b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10", + 4, + ), + to="string", + format="base64", + expected="AQIDBAUGBwgJCgsMDQ4PEA==", + msg="$convert should allow base64 format for subtype 4 binData to string", + ), + ConvertTest( + "format_sub4_hex", + input=Binary( + b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10", + 4, + ), + to="string", + format="hex", + expected="0102030405060708090A0B0C0D0E0F10", + msg="$convert should allow hex format for subtype 4 binData to string", + ), +] + +CONVERT_FORMAT_SUCCESS_TESTS = ( + CONVERT_FORMAT_IGNORED_TESTS + + CONVERT_FORMAT_ENCODING_TESTS + + CONVERT_FORMAT_UUID_FLEXIBILITY_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(CONVERT_FORMAT_SUCCESS_TESTS)) +def test_convert_format(collection, test_case: ConvertTest): + """Test $convert format behavior.""" + result = execute_expression(collection, _expr(test_case)) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_format_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_format_errors.py new file mode 100644 index 00000000..566466d4 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_format_errors.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +from datetime import datetime + +import pytest +from bson import ( + Binary, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from bson.code import Code + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.convert.utils.convert_common import ( # noqa: E501 + ConvertTest, + _expr, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.error_codes import ( + CONVERT_FORMAT_TYPE_ERROR, + CONVERT_FORMAT_VALUE_ERROR, + CONVERT_MISSING_FORMAT_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Format Errors]: invalid 'format' parameter values produce specific +# error codes for wrong types or unrecognized strings. +CONVERT_FORMAT_ERROR_TESTS: list[ConvertTest] = [ + # Unrecognized format string values. + ConvertTest( + "format_value_xml", + input="hello", + to="binData", + format="xml", + error_code=CONVERT_FORMAT_VALUE_ERROR, + msg="$convert should reject unrecognized format 'xml'", + ), + ConvertTest( + "format_value_empty", + input="hello", + to="binData", + format="", + error_code=CONVERT_FORMAT_VALUE_ERROR, + msg="$convert should reject empty string as format", + ), + ConvertTest( + "format_value_case_BASE64", + input="hello", + to="binData", + format="BASE64", + error_code=CONVERT_FORMAT_VALUE_ERROR, + msg="$convert should reject case-mismatched format 'BASE64'", + ), + # Wrong type for format. + ConvertTest( + "format_type_int", + input="hello", + to="binData", + format=42, + error_code=CONVERT_FORMAT_TYPE_ERROR, + msg="$convert should reject int as format", + ), + ConvertTest( + "format_type_bool", + input="hello", + to="binData", + format=True, + error_code=CONVERT_FORMAT_TYPE_ERROR, + msg="$convert should reject bool as format", + ), + ConvertTest( + "format_type_array", + input="hello", + to="binData", + format=[], + error_code=CONVERT_FORMAT_TYPE_ERROR, + msg="$convert should reject array as format", + ), + ConvertTest( + "format_type_object", + input="hello", + to="binData", + format={"a": 1}, + error_code=CONVERT_FORMAT_TYPE_ERROR, + msg="$convert should reject object as format", + ), + ConvertTest( + "format_type_binary", + input="hello", + to="binData", + format=Binary(b"x"), + error_code=CONVERT_FORMAT_TYPE_ERROR, + msg="$convert should reject Binary as format", + ), + ConvertTest( + "format_type_double", + input="hello", + to="binData", + format=42.5, + error_code=CONVERT_FORMAT_TYPE_ERROR, + msg="$convert should reject double as format", + ), + ConvertTest( + "format_type_long", + input="hello", + to="binData", + format=Int64(42), + error_code=CONVERT_FORMAT_TYPE_ERROR, + msg="$convert should reject Int64 as format", + ), + ConvertTest( + "format_type_decimal", + input="hello", + to="binData", + format=Decimal128("42"), + error_code=CONVERT_FORMAT_TYPE_ERROR, + msg="$convert should reject Decimal128 as format", + ), + ConvertTest( + "format_type_objectid", + input="hello", + to="binData", + format=ObjectId("507f1f77bcf86cd799439011"), + error_code=CONVERT_FORMAT_TYPE_ERROR, + msg="$convert should reject ObjectId as format", + ), + ConvertTest( + "format_type_datetime", + input="hello", + to="binData", + format=datetime(2024, 1, 1), + error_code=CONVERT_FORMAT_TYPE_ERROR, + msg="$convert should reject datetime as format", + ), + ConvertTest( + "format_type_regex", + input="hello", + to="binData", + format=Regex("abc"), + error_code=CONVERT_FORMAT_TYPE_ERROR, + msg="$convert should reject Regex as format", + ), + ConvertTest( + "format_type_code", + input="hello", + to="binData", + format=Code("function(){}"), + error_code=CONVERT_FORMAT_TYPE_ERROR, + msg="$convert should reject Code as format", + ), + ConvertTest( + "format_type_timestamp", + input="hello", + to="binData", + format=Timestamp(1, 1), + error_code=CONVERT_FORMAT_TYPE_ERROR, + msg="$convert should reject Timestamp as format", + ), + ConvertTest( + "format_type_minkey", + input="hello", + to="binData", + format=MinKey(), + error_code=CONVERT_FORMAT_TYPE_ERROR, + msg="$convert should reject MinKey as format", + ), + ConvertTest( + "format_type_maxkey", + input="hello", + to="binData", + format=MaxKey(), + error_code=CONVERT_FORMAT_TYPE_ERROR, + msg="$convert should reject MaxKey as format", + ), + # Null format falls through to missing-format error. + ConvertTest( + "format_null_treated_as_missing", + input="hello", + to="binData", + format=None, + error_code=CONVERT_MISSING_FORMAT_ERROR, + msg="$convert should treat null format as missing for string to binData", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(CONVERT_FORMAT_ERROR_TESTS)) +def test_convert_format_errors(collection, test_case: ConvertTest): + """Test $convert format parameter errors.""" + result = execute_expression(collection, _expr(test_case)) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_invalid_args.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_invalid_args.py new file mode 100644 index 00000000..c6ed7d4a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_invalid_args.py @@ -0,0 +1,339 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from bson.code import Code + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.convert.utils.convert_common import ( # noqa: E501 + ConvertTest, + _expr, +) +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 +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import MISSING + +# Property [Syntax Validation]: $convert requires an object argument. Non-object +# values produce a FailedToParse error. +CONVERT_SYNTAX_ERROR_TESTS: list[ConvertTest] = [ + ConvertTest( + "syntax_string", + expr={"$convert": "hello"}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject string as argument", + ), + ConvertTest( + "syntax_missing_field_ref", + expr={"$convert": MISSING}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject missing field reference as argument", + ), + ConvertTest( + "syntax_array", + expr={"$convert": ["hello"]}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject array as argument", + ), + ConvertTest( + "syntax_null", + expr={"$convert": None}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject null as argument", + ), + ConvertTest( + "syntax_int", + expr={"$convert": 42}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject int as argument", + ), + ConvertTest( + "syntax_bool", + expr={"$convert": True}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject boolean as argument", + ), + ConvertTest( + "syntax_double", + expr={"$convert": 3.14}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject double as argument", + ), + ConvertTest( + "syntax_long", + expr={"$convert": Int64(42)}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject Int64 as argument", + ), + ConvertTest( + "syntax_decimal", + expr={"$convert": Decimal128("42")}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject Decimal128 as argument", + ), + ConvertTest( + "syntax_binary", + expr={"$convert": Binary(b"data")}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject Binary as argument", + ), + ConvertTest( + "syntax_date", + expr={"$convert": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject datetime as argument", + ), + ConvertTest( + "syntax_objectid", + expr={"$convert": ObjectId("507f1f77bcf86cd799439011")}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject ObjectId as argument", + ), + ConvertTest( + "syntax_regex", + expr={"$convert": Regex("pattern")}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject Regex as argument", + ), + ConvertTest( + "syntax_timestamp", + expr={"$convert": Timestamp(1, 1)}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject Timestamp as argument", + ), + ConvertTest( + "syntax_minkey", + expr={"$convert": MinKey()}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject MinKey as argument", + ), + ConvertTest( + "syntax_maxkey", + expr={"$convert": MaxKey()}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject MaxKey as argument", + ), + ConvertTest( + "syntax_code", + expr={"$convert": Code("function() {}")}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject Code as argument", + ), + ConvertTest( + "syntax_code_scope", + expr={"$convert": Code("function() {}", {"x": 1})}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject Code with scope as argument", + ), +] + +# Property [Missing Required Fields]: omitting 'input' or 'to' produces a +# FailedToParse error. +CONVERT_MISSING_FIELD_ERROR_TESTS: list[ConvertTest] = [ + ConvertTest( + "missing_both", + expr={"$convert": {}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject empty object", + ), + ConvertTest( + "missing_input", + expr={"$convert": {"to": "int"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject missing input field", + ), + ConvertTest( + "missing_to", + expr={"$convert": {"input": 42}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject missing to field", + ), + ConvertTest( + "missing_input_and_to_with_optionals", + expr={"$convert": {"onError": "x", "onNull": "y"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject missing input and to even with optional fields", + ), +] + +# Property [Unknown Fields]: unrecognized field names in the $convert object +# produce a FailedToParse error. Field names are case-sensitive. +CONVERT_UNKNOWN_FIELD_ERROR_TESTS: list[ConvertTest] = [ + ConvertTest( + "unknown_field", + expr={"$convert": {"input": 42, "to": "int", "unknown": 1}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject unknown fields", + ), + ConvertTest( + "case_Input", + expr={"$convert": {"Input": 42, "to": "int"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject case-mismatched 'Input'", + ), + ConvertTest( + "case_To", + expr={"$convert": {"input": 42, "To": "int"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject case-mismatched 'To'", + ), + ConvertTest( + "case_OnNull", + expr={"$convert": {"input": 42, "to": "int", "OnNull": "x"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject case-mismatched 'OnNull'", + ), + ConvertTest( + "case_OnError", + expr={"$convert": {"input": 42, "to": "int", "OnError": "x"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject case-mismatched 'OnError'", + ), + ConvertTest( + "case_Format", + expr={"$convert": {"input": 42, "to": "int", "Format": "hex"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject case-mismatched 'Format'", + ), + ConvertTest( + "case_ByteOrder", + expr={"$convert": {"input": 42, "to": "int", "ByteOrder": "big"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject case-mismatched 'ByteOrder'", + ), + ConvertTest( + "case_to_obj_Type", + expr={"$convert": {"input": 42, "to": {"Type": "int"}}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject case-mismatched 'Type' in to object", + ), +] + +# Property [Dollar Sign Errors]: a bare "$" is rejected as an invalid field +# path and "$$" is rejected as an empty variable name in every string parameter. +CONVERT_DOLLAR_SIGN_ERROR_TESTS: list[ConvertTest] = [ + ConvertTest( + "dollar_bare_input", + input="$", + to="int", + error_code=INVALID_DOLLAR_FIELD_PATH, + msg="$convert should reject bare '$' as input field path", + ), + ConvertTest( + "dollar_double_input", + input="$$", + to="int", + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject '$$' as empty variable in input", + ), + ConvertTest( + "dollar_bare_to", + input=42, + to="$", + error_code=INVALID_DOLLAR_FIELD_PATH, + msg="$convert should reject bare '$' as to field path", + ), + ConvertTest( + "dollar_double_to", + input=42, + to="$$", + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject '$$' as empty variable in to", + ), + ConvertTest( + "dollar_bare_format", + input=42, + to="int", + format="$", + error_code=INVALID_DOLLAR_FIELD_PATH, + msg="$convert should reject bare '$' as format field path", + ), + ConvertTest( + "dollar_double_format", + input=42, + to="int", + format="$$", + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject '$$' as empty variable in format", + ), + ConvertTest( + "dollar_bare_byte_order", + input=42, + to="int", + byte_order="$", + error_code=INVALID_DOLLAR_FIELD_PATH, + msg="$convert should reject bare '$' as byteOrder field path", + ), + ConvertTest( + "dollar_double_byte_order", + input=42, + to="int", + byte_order="$$", + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject '$$' as empty variable in byteOrder", + ), + ConvertTest( + "dollar_bare_on_null", + input=42, + to="int", + on_null="$", + error_code=INVALID_DOLLAR_FIELD_PATH, + msg="$convert should reject bare '$' as onNull field path", + ), + ConvertTest( + "dollar_double_on_null", + input=42, + to="int", + on_null="$$", + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject '$$' as empty variable in onNull", + ), + ConvertTest( + "dollar_bare_on_error", + input=42, + to="int", + on_error="$", + error_code=INVALID_DOLLAR_FIELD_PATH, + msg="$convert should reject bare '$' as onError field path", + ), + ConvertTest( + "dollar_double_on_error", + input=42, + to="int", + on_error="$$", + error_code=FAILED_TO_PARSE_ERROR, + msg="$convert should reject '$$' as empty variable in onError", + ), +] + +CONVERT_INVALID_ARGS_TESTS = ( + CONVERT_SYNTAX_ERROR_TESTS + + CONVERT_MISSING_FIELD_ERROR_TESTS + + CONVERT_UNKNOWN_FIELD_ERROR_TESTS + + CONVERT_DOLLAR_SIGN_ERROR_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(CONVERT_INVALID_ARGS_TESTS)) +def test_convert_invalid_args(collection, test_case: ConvertTest): + """Test $convert invalid argument cases.""" + result = execute_expression(collection, _expr(test_case)) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_null_input.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_null_input.py new file mode 100644 index 00000000..00aacc94 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_null_input.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.convert.utils.convert_common import ( # noqa: E501 + _PLACEHOLDER, + ConvertTest, + _build_null_tests, + _expr, +) +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 Input]: when input is null or a value treated as null, the +# result is null for all target types. +_NULL_INPUT_PATTERNS: list[ConvertTest] = [ + ConvertTest( + "input_to_bool", + input=_PLACEHOLDER, + to="bool", + expected=None, + msg="$convert should return null when input is {prefix} for bool target", + ), + ConvertTest( + "input_to_int", + input=_PLACEHOLDER, + to="int", + expected=None, + msg="$convert should return null when input is {prefix} for int target", + ), + ConvertTest( + "input_to_long", + input=_PLACEHOLDER, + to="long", + expected=None, + msg="$convert should return null when input is {prefix} for long target", + ), + ConvertTest( + "input_to_double", + input=_PLACEHOLDER, + to="double", + expected=None, + msg="$convert should return null when input is {prefix} for double target", + ), + ConvertTest( + "input_to_decimal", + input=_PLACEHOLDER, + to="decimal", + expected=None, + msg="$convert should return null when input is {prefix} for decimal target", + ), + ConvertTest( + "input_to_date", + input=_PLACEHOLDER, + to="date", + expected=None, + msg="$convert should return null when input is {prefix} for date target", + ), + ConvertTest( + "input_to_string", + input=_PLACEHOLDER, + to="string", + expected=None, + msg="$convert should return null when input is {prefix} for string target", + ), + ConvertTest( + "input_to_objectId", + input=_PLACEHOLDER, + to="objectId", + expected=None, + msg="$convert should return null when input is {prefix} for objectId target", + ), + ConvertTest( + "input_to_binData", + input=_PLACEHOLDER, + to="binData", + expected=None, + msg="$convert should return null when input is {prefix} for binData target", + ), + ConvertTest( + "input_to_regex_unsupported", + input=_PLACEHOLDER, + to="regex", + expected=None, + msg="$convert should return null when input is {prefix} for unsupported target regex", + ), + ConvertTest( + "input_to_timestamp_unsupported", + input=_PLACEHOLDER, + to="timestamp", + expected=None, + msg="$convert should return null when input is {prefix} for unsupported target timestamp", + ), + ConvertTest( + "input_to_null_unsupported", + input=_PLACEHOLDER, + to="null", + expected=None, + msg="$convert should return null when input is {prefix} for unsupported target null", + ), + ConvertTest( + "input_to_array_unsupported", + input=_PLACEHOLDER, + to="array", + expected=None, + msg="$convert should return null when input is {prefix} for unsupported target array", + ), + ConvertTest( + "input_to_object_unsupported", + input=_PLACEHOLDER, + to="object", + expected=None, + msg="$convert should return null when input is {prefix} for unsupported target object", + ), + ConvertTest( + "input_to_minKey_unsupported", + input=_PLACEHOLDER, + to="minKey", + expected=None, + msg="$convert should return null when input is {prefix} for unsupported target minKey", + ), + ConvertTest( + "input_to_maxKey_unsupported", + input=_PLACEHOLDER, + to="maxKey", + expected=None, + msg="$convert should return null when input is {prefix} for unsupported target maxKey", + ), + ConvertTest( + "input_to_javascript_unsupported", + input=_PLACEHOLDER, + to="javascript", + expected=None, + msg="$convert should return null when input is {prefix} for unsupported target javascript", + ), + ConvertTest( + "input_to_javascriptWithScope_unsupported", + input=_PLACEHOLDER, + to="javascriptWithScope", + expected=None, + msg="$convert should return null when input is {prefix} for unsupported jsWithScope", + ), + ConvertTest( + "input_to_symbol_unsupported", + input=_PLACEHOLDER, + to="symbol", + expected=None, + msg="$convert should return null when input is {prefix} for unsupported target symbol", + ), + ConvertTest( + "input_to_undefined_unsupported", + input=_PLACEHOLDER, + to="undefined", + expected=None, + msg="$convert should return null when input is {prefix} for unsupported target undefined", + ), + ConvertTest( + "input_to_dbPointer_unsupported", + input=_PLACEHOLDER, + to="dbPointer", + expected=None, + msg="$convert should return null when input is {prefix} for unsupported target dbPointer", + ), +] + +# Property [Null Input]: $convert returns onNull (or null) when input is null. +CONVERT_NULL_INPUT_TESTS = _build_null_tests(_NULL_INPUT_PATTERNS, None, "null") +# Property [Missing Input]: $convert returns onNull (or null) when input is missing. +CONVERT_MISSING_INPUT_TESTS = _build_null_tests(_NULL_INPUT_PATTERNS, MISSING, "missing") + +CONVERT_NULL_INPUT_ALL_TESTS = CONVERT_NULL_INPUT_TESTS + CONVERT_MISSING_INPUT_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(CONVERT_NULL_INPUT_ALL_TESTS)) +def test_convert_null_input(collection, test_case: ConvertTest): + """Test $convert null/missing input returns null.""" + result = execute_expression(collection, _expr(test_case)) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_null_validation.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_null_validation.py new file mode 100644 index 00000000..52db8119 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_null_validation.py @@ -0,0 +1,280 @@ +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.convert.utils.convert_common import ( # noqa: E501 + _PLACEHOLDER, + ConvertTest, + _build_null_tests, + _expr, +) +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_ZERO, + DOUBLE_ZERO, + MISSING, +) + +# Property [Null Skips Validation]: when input is null or missing, format, +# byteOrder, and subtype validation is entirely skipped, even for invalid +# values and wrong types. +_NULL_SKIPS_VALIDATION_PATTERNS: list[ConvertTest] = [ + ConvertTest( + "skips_invalid_format_string", + input=_PLACEHOLDER, + to="string", + format="invalid_format", + expected=None, + msg="$convert should skip format validation when input is {prefix}", + ), + ConvertTest( + "skips_invalid_format_type", + input=_PLACEHOLDER, + to="string", + format=12345, + expected=None, + msg="$convert should skip format type validation when input is {prefix}", + ), + ConvertTest( + "skips_invalid_byte_order_string", + input=_PLACEHOLDER, + to="string", + byte_order="invalid_order", + expected=None, + msg="$convert should skip byteOrder validation when input is {prefix}", + ), + ConvertTest( + "skips_invalid_byte_order_type", + input=_PLACEHOLDER, + to="string", + byte_order=12345, + expected=None, + msg="$convert should skip byteOrder type validation when input is {prefix}", + ), + ConvertTest( + "skips_invalid_subtype_value", + input=_PLACEHOLDER, + to={"type": "binData", "subtype": 999}, + expected=None, + msg="$convert should skip subtype validation when input is {prefix}", + ), + ConvertTest( + "skips_invalid_subtype_type", + input=_PLACEHOLDER, + to={"type": "binData", "subtype": "invalid"}, + expected=None, + msg="$convert should skip subtype type validation when input is {prefix}", + ), +] + +# Property [Null Input Skips Validation]: null input bypasses to/format/byteOrder validation. +CONVERT_NULL_INPUT_SKIPS_VALIDATION_TESTS: list[ConvertTest] = _build_null_tests( + _NULL_SKIPS_VALIDATION_PATTERNS, None, "null" +) + +# Property [Missing Input Skips Validation]: missing input bypasses to/format/byteOrder validation. +CONVERT_MISSING_INPUT_SKIPS_VALIDATION_TESTS: list[ConvertTest] = _build_null_tests( + _NULL_SKIPS_VALIDATION_PATTERNS, MISSING, "missing" +) + +# Property [Null To Skips Validation]: when to is null or missing, format and +# byteOrder validation is skipped even for invalid values and wrong types. +_NULL_TO_SKIPS_VALIDATION_PATTERNS: list[ConvertTest] = [ + ConvertTest( + "to_skips_invalid_byte_order", + input="hello", + to=_PLACEHOLDER, + byte_order="invalid", + expected=None, + msg="$convert should skip byteOrder validation when to is {prefix}", + ), + ConvertTest( + "to_skips_wrong_type_byte_order", + input="hello", + to=_PLACEHOLDER, + byte_order=12345, + expected=None, + msg="$convert should skip byteOrder type validation when to is {prefix}", + ), + ConvertTest( + "to_skips_invalid_format", + input="hello", + to=_PLACEHOLDER, + format="invalid", + expected=None, + msg="$convert should skip format validation when to is {prefix}", + ), + ConvertTest( + "to_skips_wrong_type_format", + input="hello", + to=_PLACEHOLDER, + format=12345, + expected=None, + msg="$convert should skip format type validation when to is {prefix}", + ), +] + +# Property [Null To Skips Validation]: null to bypasses format/byteOrder validation. +CONVERT_NULL_TO_SKIPS_VALIDATION_TESTS: list[ConvertTest] = _build_null_tests( + _NULL_TO_SKIPS_VALIDATION_PATTERNS, None, "null", field="to" +) + +# Property [Missing To Skips Validation]: missing to bypasses format/byteOrder validation. +CONVERT_MISSING_TO_SKIPS_VALIDATION_TESTS: list[ConvertTest] = _build_null_tests( + _NULL_TO_SKIPS_VALIDATION_PATTERNS, MISSING, "missing", field="to" +) + +# Property [Falsy Values Do Not Trigger onNull]: falsy values proceed to normal +# conversion and do not trigger onNull. +CONVERT_FALSY_NOT_NULL_TESTS: list[ConvertTest] = [ + ConvertTest( + "falsy_false_not_null", + input=False, + to="bool", + on_null="fallback", + expected=False, + msg="$convert should not trigger onNull for false", + ), + ConvertTest( + "falsy_zero_int_not_null", + input=0, + to="bool", + on_null="fallback", + expected=False, + msg="$convert should not trigger onNull for int 0", + ), + ConvertTest( + "falsy_zero_double_not_null", + input=DOUBLE_ZERO, + to="bool", + on_null="fallback", + expected=False, + msg="$convert should not trigger onNull for double 0.0", + ), + ConvertTest( + "falsy_empty_string_not_null", + input="", + to="string", + on_null="fallback", + expected="", + msg="$convert should not trigger onNull for empty string", + ), + ConvertTest( + "falsy_decimal_zero_not_null", + input=DECIMAL128_ZERO, + to="bool", + on_null="fallback", + expected=False, + msg="$convert should not trigger onNull for Decimal128('0')", + ), + ConvertTest( + "falsy_empty_array_not_null", + input=[], + to="bool", + on_null="fallback", + expected=True, + msg="$convert should not trigger onNull for empty array", + ), +] + +# Property [Null To Precedence]: when to is null/missing and input is non-null, +# the result is null without triggering onError or onNull; when both input and +# to are null/missing, the onNull path is taken. +CONVERT_NULLISH_TO_PRECEDENCE_TESTS: list[ConvertTest] = [ + ConvertTest( + "nullish_to_null_on_error_not_triggered", + input="hello", + to=None, + on_error="error_fallback", + expected=None, + msg="$convert should not trigger onError when to is null", + ), + ConvertTest( + "nullish_to_null_on_null_not_triggered", + input="hello", + to=None, + on_null="null_fallback", + expected=None, + msg="$convert should not trigger onNull when to is null and input is non-null", + ), + ConvertTest( + "nullish_to_both_null_on_null_triggered", + input=None, + to=None, + on_null="null_fallback", + expected="null_fallback", + msg="$convert should take onNull path when both input and to are null", + ), + ConvertTest( + "nullish_to_both_missing_on_null_triggered", + input=MISSING, + to=MISSING, + on_null="null_fallback", + expected="null_fallback", + msg="$convert should take onNull path when both input and to are missing", + ), + ConvertTest( + "nullish_to_input_null_to_missing_on_null_triggered", + input=None, + to=MISSING, + on_null="null_fallback", + expected="null_fallback", + msg="$convert should take onNull path when input is null and to is missing", + ), + ConvertTest( + "nullish_to_input_missing_to_null_on_null_triggered", + input=MISSING, + to=None, + on_null="null_fallback", + expected="null_fallback", + msg="$convert should take onNull path when input is missing and to is null", + ), +] + +# Property [onNull Expression Evaluation]: onNull accepts expressions; an +# expression returning null triggers onNull, and the onNull value itself can be +# an expression that is evaluated before being returned. +CONVERT_ON_NULL_EXPRESSION_TESTS: list[ConvertTest] = [ + ConvertTest( + "on_null_with_expression_returning_null", + input={"$literal": None}, + to="bool", + on_null="fallback", + expected="fallback", + msg="$convert onNull should trigger when input expression returns null", + ), + ConvertTest( + "on_null_with_expression_value", + input=None, + to="bool", + on_null={"$add": [1, 2]}, + expected=3, + msg="$convert onNull should evaluate expression and return result", + ), +] + +CONVERT_NULL_VALIDATION_TESTS = ( + CONVERT_NULL_INPUT_SKIPS_VALIDATION_TESTS + + CONVERT_MISSING_INPUT_SKIPS_VALIDATION_TESTS + + CONVERT_NULL_TO_SKIPS_VALIDATION_TESTS + + CONVERT_MISSING_TO_SKIPS_VALIDATION_TESTS + + CONVERT_FALSY_NOT_NULL_TESTS + + CONVERT_NULLISH_TO_PRECEDENCE_TESTS + + CONVERT_ON_NULL_EXPRESSION_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(CONVERT_NULL_VALIDATION_TESTS)) +def test_convert_null_validation(collection, test_case: ConvertTest): + """Test $convert null validation edge cases.""" + result = execute_expression(collection, _expr(test_case)) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_on_error.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_on_error.py new file mode 100644 index 00000000..2a7c8280 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_on_error.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import pytest +from bson import Binary, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.convert.utils.convert_common import ( # noqa: E501 + ConvertTest, + _expr, +) +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_NAN, + FLOAT_INFINITY, + FLOAT_NAN, + INT32_OVERFLOW, + STRING_SIZE_LIMIT_BYTES, +) + +# Property [onError Catches Conversion Errors]: when a conversion error occurs +# and onError is specified, the onError value is returned as-is regardless of +# its BSON type. +CONVERT_ON_ERROR_TESTS: list[ConvertTest] = [ + ConvertTest( + "on_error_int_fallback_for_string_target", + input="abc", + to="objectId", + on_error=42, + expected=42, + msg="$convert onError should return int without converting to target type", + ), + ConvertTest( + "on_error_catches_overflow", + input=Int64(INT32_OVERFLOW), + to="int", + on_error="caught", + expected="caught", + msg="$convert onError should catch int32 overflow", + ), + ConvertTest( + "on_error_catches_nan_to_int", + input=FLOAT_NAN, + to="int", + on_error="caught", + expected="caught", + msg="$convert onError should catch NaN to int conversion", + ), + ConvertTest( + "on_error_catches_infinity_to_int", + input=FLOAT_INFINITY, + to="int", + on_error="caught", + expected="caught", + msg="$convert onError should catch Infinity to int conversion", + ), + ConvertTest( + "on_error_catches_decimal128_nan_to_int", + input=DECIMAL128_NAN, + to="int", + on_error="caught", + expected="caught", + msg="$convert onError should catch Decimal128 NaN to int conversion", + ), + ConvertTest( + "on_error_catches_unsupported_type_combo", + input=[1, 2], + to="int", + on_error="caught", + expected="caught", + msg="$convert onError should catch unsupported source type for target", + ), + ConvertTest( + "on_error_catches_bindata_wrong_length", + input=Binary(b"\x01\x02\x03"), + to="int", + on_error="caught", + expected="caught", + msg="$convert onError should catch BinData with wrong byte length", + ), + ConvertTest( + "on_error_catches_uuid_subtype_mismatch", + input="550e8400-e29b-41d4-a716-446655440000", + to={"type": "binData", "subtype": 0}, + format="uuid", + on_error="caught", + expected="caught", + msg="$convert onError should catch uuid format with non-4 subtype", + ), + ConvertTest( + "on_error_catches_invalid_date_string", + input="\u20002024-06-15T12:30:45Z", + to="date", + on_error="caught", + expected="caught", + msg="$convert onError should catch invalid string to date conversion", + ), + ConvertTest( + "on_error_catches_string_size_limit", + input=Binary(b"A" * (STRING_SIZE_LIMIT_BYTES // 2)), + to="string", + format="hex", + on_error="caught", + expected="caught", + msg="$convert onError should catch string size limit error for BinData-to-string", + ), +] + +# Property [onError Not Triggered]: onError is not triggered when conversion +# succeeds or when input is null (the onNull path takes precedence). +CONVERT_ON_ERROR_NOT_TRIGGERED_TESTS: list[ConvertTest] = [ + ConvertTest( + "on_error_not_triggered_on_success", + input="123", + to="int", + on_error="fallback", + expected=123, + msg="$convert onError should not trigger when conversion succeeds", + ), + ConvertTest( + "on_error_not_triggered_on_null_input", + input=None, + to="int", + on_error="error_fallback", + on_null="null_fallback", + expected="null_fallback", + msg="$convert should take onNull path instead of onError when input is null", + ), +] + +CONVERT_ON_ERROR_SUCCESS_TESTS = CONVERT_ON_ERROR_TESTS + CONVERT_ON_ERROR_NOT_TRIGGERED_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(CONVERT_ON_ERROR_SUCCESS_TESTS)) +def test_convert_on_error(collection, test_case: ConvertTest): + """Test $convert onError behavior.""" + result = execute_expression(collection, _expr(test_case)) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_on_null_target.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_on_null_target.py new file mode 100644 index 00000000..318f9452 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_on_null_target.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.convert.utils.convert_common import ( # noqa: E501 + _PLACEHOLDER, + ConvertTest, + _build_null_tests, + _expr, +) +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 [onNull Override]: the onNull parameter overrides the null result +# with a custom value when input is null or missing, and the value is returned +# as-is without conversion to the target type. +# Group 1: fixed onNull value, vary target type. Proves the target type does +# not affect the onNull return path. +_ON_NULL_VARY_TARGET_PATTERNS: list[ConvertTest] = [ + ConvertTest( + "on_null_target_bool", + input=_PLACEHOLDER, + to="bool", + on_null="fallback", + expected="fallback", + msg="$convert onNull should return value as-is for bool target ({prefix} input)", + ), + ConvertTest( + "on_null_target_int", + input=_PLACEHOLDER, + to="int", + on_null="fallback", + expected="fallback", + msg="$convert onNull should return value as-is for int target ({prefix} input)", + ), + ConvertTest( + "on_null_target_long", + input=_PLACEHOLDER, + to="long", + on_null="fallback", + expected="fallback", + msg="$convert onNull should return value as-is for long target ({prefix} input)", + ), + ConvertTest( + "on_null_target_double", + input=_PLACEHOLDER, + to="double", + on_null="fallback", + expected="fallback", + msg="$convert onNull should return value as-is for double target ({prefix} input)", + ), + ConvertTest( + "on_null_target_decimal", + input=_PLACEHOLDER, + to="decimal", + on_null="fallback", + expected="fallback", + msg="$convert onNull should return value as-is for decimal target ({prefix} input)", + ), + ConvertTest( + "on_null_target_date", + input=_PLACEHOLDER, + to="date", + on_null="fallback", + expected="fallback", + msg="$convert onNull should return value as-is for date target ({prefix} input)", + ), + ConvertTest( + "on_null_target_string", + input=_PLACEHOLDER, + to="string", + on_null=42, + expected=42, + msg="$convert onNull should return value as-is for string target ({prefix} input)", + ), + ConvertTest( + "on_null_target_objectId", + input=_PLACEHOLDER, + to="objectId", + on_null="fallback", + expected="fallback", + msg="$convert onNull should return value as-is for objectId target ({prefix} input)", + ), + ConvertTest( + "on_null_target_binData", + input=_PLACEHOLDER, + to="binData", + on_null="fallback", + expected="fallback", + msg="$convert onNull should return value as-is for binData target ({prefix} input)", + ), + ConvertTest( + "on_null_target_regex", + input=_PLACEHOLDER, + to="regex", + on_null="fallback", + expected="fallback", + msg="$convert onNull should return value as-is for unsupported target regex ({prefix})", + ), + ConvertTest( + "on_null_target_timestamp", + input=_PLACEHOLDER, + to="timestamp", + on_null="fallback", + expected="fallback", + msg="$convert onNull should return value as-is for unsupported target timestamp ({prefix})", + ), + ConvertTest( + "on_null_target_null", + input=_PLACEHOLDER, + to="null", + on_null="fallback", + expected="fallback", + msg="$convert onNull should return value as-is for unsupported target null ({prefix})", + ), + ConvertTest( + "on_null_target_array", + input=_PLACEHOLDER, + to="array", + on_null="fallback", + expected="fallback", + msg="$convert onNull should return value as-is for unsupported target array ({prefix})", + ), + ConvertTest( + "on_null_target_object", + input=_PLACEHOLDER, + to="object", + on_null="fallback", + expected="fallback", + msg="$convert onNull should return value as-is for unsupported target object ({prefix})", + ), + ConvertTest( + "on_null_target_minKey", + input=_PLACEHOLDER, + to="minKey", + on_null="fallback", + expected="fallback", + msg="$convert onNull should return value as-is for unsupported target minKey ({prefix})", + ), + ConvertTest( + "on_null_target_maxKey", + input=_PLACEHOLDER, + to="maxKey", + on_null="fallback", + expected="fallback", + msg="$convert onNull should return value as-is for unsupported target maxKey ({prefix})", + ), + ConvertTest( + "on_null_target_javascript", + input=_PLACEHOLDER, + to="javascript", + on_null="fallback", + expected="fallback", + msg="$convert onNull should return value as-is for unsupported target js ({prefix})", + ), + ConvertTest( + "on_null_target_javascriptWithScope", + input=_PLACEHOLDER, + to="javascriptWithScope", + on_null="fallback", + expected="fallback", + msg="$convert onNull should return value as-is for unsupported jsWithScope ({prefix})", + ), + ConvertTest( + "on_null_target_symbol", + input=_PLACEHOLDER, + to="symbol", + on_null="fallback", + expected="fallback", + msg="$convert onNull should return value as-is for unsupported target symbol ({prefix})", + ), + ConvertTest( + "on_null_target_undefined", + input=_PLACEHOLDER, + to="undefined", + on_null="fallback", + expected="fallback", + msg="$convert onNull should return value as-is for unsupported target undefined ({prefix})", + ), + ConvertTest( + "on_null_target_dbPointer", + input=_PLACEHOLDER, + to="dbPointer", + on_null="fallback", + expected="fallback", + msg="$convert onNull should return value as-is for unsupported target dbPointer ({prefix})", + ), +] + +# Property [onNull Target Null]: onNull value is returned when target type is null. +CONVERT_ON_NULL_TARGET_NULL_TESTS: list[ConvertTest] = _build_null_tests( + _ON_NULL_VARY_TARGET_PATTERNS, None, "null" +) + +# Property [onNull Target Missing]: onNull value is returned when target type is missing. +CONVERT_ON_NULL_TARGET_MISSING_TESTS: list[ConvertTest] = _build_null_tests( + _ON_NULL_VARY_TARGET_PATTERNS, MISSING, "missing" +) + +CONVERT_ON_NULL_TARGET_ALL_TESTS = ( + CONVERT_ON_NULL_TARGET_NULL_TESTS + CONVERT_ON_NULL_TARGET_MISSING_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(CONVERT_ON_NULL_TARGET_ALL_TESTS)) +def test_convert_on_null_target(collection, test_case: ConvertTest): + """Test $convert onNull with varying target types.""" + result = execute_expression(collection, _expr(test_case)) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_on_null_value.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_on_null_value.py new file mode 100644 index 00000000..125831c0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_on_null_value.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.convert.utils.convert_common import ( # noqa: E501 + _PLACEHOLDER, + ConvertTest, + _build_null_tests, + _expr, +) +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 + +# Group 2: fixed target type, vary onNull BSON type. Proves every BSON type +# round-trips through onNull unchanged. +_ON_NULL_VARY_VALUE_PATTERNS: list[ConvertTest] = [ + ConvertTest( + "on_null_value_null", + input=_PLACEHOLDER, + to="int", + on_null=None, + expected=None, + msg="$convert onNull should return null as-is ({prefix} input)", + ), + ConvertTest( + "on_null_value_object", + input=_PLACEHOLDER, + to="int", + on_null={"key": "val"}, + expected={"key": "val"}, + msg="$convert onNull should return object as-is ({prefix} input)", + ), + ConvertTest( + "on_null_value_bool", + input=_PLACEHOLDER, + to="int", + on_null=False, + expected=False, + msg="$convert onNull should return bool as-is ({prefix} input)", + ), + ConvertTest( + "on_null_value_string", + input=_PLACEHOLDER, + to="int", + on_null="fallback", + expected="fallback", + msg="$convert onNull should return string as-is ({prefix} input)", + ), + ConvertTest( + "on_null_value_regex", + input=_PLACEHOLDER, + to="int", + on_null=Regex("abc"), + expected=Regex("abc"), + msg="$convert onNull should return Regex as-is ({prefix} input)", + ), + ConvertTest( + "on_null_value_binary", + input=_PLACEHOLDER, + to="int", + on_null=Binary(b"hello"), + expected=b"hello", + msg="$convert onNull should return Binary as-is ({prefix} input)", + ), + ConvertTest( + "on_null_value_objectid", + input=_PLACEHOLDER, + to="int", + on_null=ObjectId("507f1f77bcf86cd799439011"), + expected=ObjectId("507f1f77bcf86cd799439011"), + msg="$convert onNull should return ObjectId as-is ({prefix} input)", + ), + ConvertTest( + "on_null_value_date", + input=_PLACEHOLDER, + to="int", + on_null=datetime(2024, 6, 15, tzinfo=timezone.utc), + expected=datetime(2024, 6, 15), + msg="$convert onNull should return date as-is ({prefix} input)", + ), + ConvertTest( + "on_null_value_long", + input=_PLACEHOLDER, + to="int", + on_null=Int64(42), + expected=Int64(42), + msg="$convert onNull should return long as-is ({prefix} input)", + ), + ConvertTest( + "on_null_value_decimal", + input=_PLACEHOLDER, + to="int", + on_null=Decimal128("3.14"), + expected=Decimal128("3.14"), + msg="$convert onNull should return Decimal128 as-is ({prefix} input)", + ), + ConvertTest( + "on_null_value_timestamp", + input=_PLACEHOLDER, + to="int", + on_null=Timestamp(1, 1), + expected=Timestamp(1, 1), + msg="$convert onNull should return Timestamp as-is ({prefix} input)", + ), + ConvertTest( + "on_null_value_minkey", + input=_PLACEHOLDER, + to="int", + on_null=MinKey(), + expected=MinKey(), + msg="$convert onNull should return MinKey as-is ({prefix} input)", + ), + ConvertTest( + "on_null_value_maxkey", + input=_PLACEHOLDER, + to="int", + on_null=MaxKey(), + expected=MaxKey(), + msg="$convert onNull should return MaxKey as-is ({prefix} input)", + ), + ConvertTest( + "on_null_value_code", + input=_PLACEHOLDER, + to="int", + on_null=Code("function(){}"), + expected=Code("function(){}"), + msg="$convert onNull should return Code as-is ({prefix} input)", + ), + ConvertTest( + "on_null_value_code_with_scope", + input=_PLACEHOLDER, + to="int", + on_null=Code("function(){}", {"x": 1}), + expected=Code("function(){}", {"x": 1}), + msg="$convert onNull should return Code with scope as-is ({prefix} input)", + ), + ConvertTest( + "on_null_value_array", + input=_PLACEHOLDER, + to="int", + on_null=[1, 2, 3], + expected=[1, 2, 3], + msg="$convert onNull should return array as-is ({prefix} input)", + ), + ConvertTest( + "on_null_value_int", + input=_PLACEHOLDER, + to="int", + on_null=42, + expected=42, + msg="$convert onNull should return int as-is ({prefix} input)", + ), + ConvertTest( + "on_null_value_double", + input=_PLACEHOLDER, + to="int", + on_null=3.14, + expected=3.14, + msg="$convert onNull should return double as-is ({prefix} input)", + ), +] + +# Property [onNull Value Null]: onNull value is returned when input is null. +CONVERT_ON_NULL_VALUE_NULL_TESTS: list[ConvertTest] = _build_null_tests( + _ON_NULL_VARY_VALUE_PATTERNS, None, "null" +) + +# Property [onNull Value Missing]: onNull value is returned when input is missing. +CONVERT_ON_NULL_VALUE_MISSING_TESTS: list[ConvertTest] = _build_null_tests( + _ON_NULL_VARY_VALUE_PATTERNS, MISSING, "missing" +) + +CONVERT_ON_NULL_VALUE_ALL_TESTS = ( + CONVERT_ON_NULL_VALUE_NULL_TESTS + CONVERT_ON_NULL_VALUE_MISSING_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(CONVERT_ON_NULL_VALUE_ALL_TESTS)) +def test_convert_on_null_value(collection, test_case: ConvertTest): + """Test $convert onNull with varying value types.""" + result = execute_expression(collection, _expr(test_case)) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_size_limit.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_size_limit.py new file mode 100644 index 00000000..d15dd8f4 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_size_limit.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import pytest +from bson import Binary + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.convert.utils.convert_common import ( # noqa: E501 + ConvertTest, + _expr, +) +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.parametrize import pytest_params +from documentdb_tests.framework.test_constants import STRING_SIZE_LIMIT_BYTES + +# Property [String Size Limit Success]: BinData-to-string conversion succeeds +# when the output is below the string size limit. +CONVERT_STRING_SIZE_LIMIT_SUCCESS_TESTS: list[ConvertTest] = [ + ConvertTest( + "size_limit_hex_under", + input=Binary(b"A" * (STRING_SIZE_LIMIT_BYTES // 2 - 1)), + to="string", + format="hex", + expected="41" * (STRING_SIZE_LIMIT_BYTES // 2 - 1), + msg=( + "$convert should succeed for BinData-to-string when output is" + " just under the size limit" + ), + ), +] + +# Property [String Size Limit Errors]: BinData-to-string conversion produces a +# conversion failure error when the output reaches the string size limit. +CONVERT_STRING_SIZE_LIMIT_ERROR_TESTS: list[ConvertTest] = [ + # hex format: 8_388_608 bytes -> 16_777_216 chars (exactly the limit). + ConvertTest( + "size_limit_err_hex_at_limit", + input=Binary(b"A" * (STRING_SIZE_LIMIT_BYTES // 2)), + to="string", + format="hex", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject BinData-to-string when hex output reaches the size limit", + ), + # base64 format: 12_582_912 bytes -> 16_777_216 chars (exactly the limit). + ConvertTest( + "size_limit_err_base64_at_limit", + input=Binary(b"A" * (STRING_SIZE_LIMIT_BYTES * 3 // 4)), + to="string", + format="base64", + error_code=CONVERSION_FAILURE_ERROR, + msg="$convert should reject BinData-to-string when base64 output reaches the size limit", + ), + # utf8 format: 16_777_216 bytes -> 16_777_216 chars produces a different + # error code than other formats. + ConvertTest( + "size_limit_err_utf8_at_limit", + input=Binary(b"A" * STRING_SIZE_LIMIT_BYTES), + to="string", + format="utf8", + error_code=STRING_SIZE_LIMIT_ERROR, + msg="$convert should reject BinData-to-string when utf8 output reaches the size limit", + ), + ConvertTest( + "size_limit_err_oversized_input", + input="A" * STRING_SIZE_LIMIT_BYTES, + to="int", + error_code=STRING_SIZE_LIMIT_ERROR, + msg="$convert should reject oversized string in input parameter", + ), + ConvertTest( + "size_limit_err_oversized_to", + input=42, + to="A" * STRING_SIZE_LIMIT_BYTES, + error_code=STRING_SIZE_LIMIT_ERROR, + msg="$convert should reject oversized string in to parameter", + ), + ConvertTest( + "size_limit_err_oversized_to_type", + input=42, + to={"type": "A" * STRING_SIZE_LIMIT_BYTES}, + error_code=STRING_SIZE_LIMIT_ERROR, + msg="$convert should reject oversized string in to.type parameter", + ), + ConvertTest( + "size_limit_err_oversized_to_subtype", + input=42, + to={"type": "binData", "subtype": "A" * STRING_SIZE_LIMIT_BYTES}, + error_code=STRING_SIZE_LIMIT_ERROR, + msg="$convert should reject oversized string in to.subtype parameter", + ), + ConvertTest( + "size_limit_err_oversized_format", + input=42, + to="string", + format="A" * STRING_SIZE_LIMIT_BYTES, + error_code=STRING_SIZE_LIMIT_ERROR, + msg="$convert should reject oversized string in format parameter", + ), + ConvertTest( + "size_limit_err_oversized_byte_order", + input=42, + to="binData", + byte_order="A" * STRING_SIZE_LIMIT_BYTES, + error_code=STRING_SIZE_LIMIT_ERROR, + msg="$convert should reject oversized string in byteOrder parameter", + ), +] + +CONVERT_SIZE_LIMIT_TESTS = ( + CONVERT_STRING_SIZE_LIMIT_SUCCESS_TESTS + CONVERT_STRING_SIZE_LIMIT_ERROR_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(CONVERT_SIZE_LIMIT_TESTS)) +def test_convert_size_limit(collection, test_case: ConvertTest): + """Test $convert string size limit behavior.""" + result = execute_expression(collection, _expr(test_case)) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_subtype.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_subtype.py new file mode 100644 index 00000000..96d9f4af --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/test_convert_subtype.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import struct +from datetime import datetime + +import pytest +from bson import Binary, Decimal128, Int64, ObjectId + +from documentdb_tests.compatibility.tests.core.operator.expressions.type.convert.utils.convert_common import ( # noqa: E501 + ConvertTest, + _expr, +) +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_NEGATIVE_ZERO, DOUBLE_NEGATIVE_ZERO + +# Property [Subtype Valid Range]: to.subtype accepts valid subtype values and +# numeric types for the subtype field. +CONVERT_SUBTYPE_VALID_TESTS: list[ConvertTest] = [ + ConvertTest( + "subtype_zero", + input=42, + to={"type": "binData", "subtype": 0}, + expected=struct.pack("} +# works for all target types and silently ignores extra fields. +CONVERT_OBJECT_FORM_TYPE_TESTS: list[ConvertTest] = [ + ConvertTest( + "object_form_string_type", + input=42, + to={"type": "int"}, + expected=42, + msg="$convert should accept object form with string type name", + ), + ConvertTest( + "object_form_numeric_type", + input=42, + to={"type": 1}, + expected=42.0, + msg="$convert should accept object form with numeric type code", + ), + ConvertTest( + "object_form_extra_fields_ignored", + input=42, + to={"type": "int", "extra": "ignored"}, + expected=42, + msg="$convert should silently ignore extra fields in object form", + ), + ConvertTest( + "object_form_case_mismatched_subtype_ignored", + input=42, + to={"type": "binData", "Subtype": 5}, + expected=struct.pack("i", 42), + msg="$convert should accept expression for byteOrder parameter", + ), + ConvertTest( + "expr_args_format", + input="hello", + to="binData", + format={"$concat": ["ut", "f8"]}, + expected=b"hello", + msg="$convert should accept expression for format parameter", + ), + ConvertTest( + "expr_args_on_error", + input="abc", + to="int", + on_error={"$add": [40, 2]}, + expected=42, + msg="$convert should accept expression for onError parameter", + ), + ConvertTest( + "expr_args_on_null", + input=None, + to="int", + on_null={"$add": [40, 2]}, + expected=42, + msg="$convert should accept expression for onNull parameter", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(CONVERT_EXPRESSION_ARGS_TESTS)) +def test_convert_expression_args(collection, test_case: ConvertTest): + """Test $convert expression arguments.""" + result = execute_expression(collection, _expr(test_case)) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) + + +def test_convert_document_fields(collection): + """Test $convert reads values from document fields.""" + result = execute_expression_with_insert( + collection, + {"$convert": {"input": "$val", "to": "$target"}}, + {"val": "42", "target": "int"}, + ) + assert_expression_result( + result, expected=42, msg="$convert should read input and to from document fields" + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/utils/convert_common.py b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/utils/convert_common.py new file mode 100644 index 00000000..cc32075e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/type/convert/utils/convert_common.py @@ -0,0 +1,116 @@ +# $convert is the general form behind $toString, $toInt, $toDouble, etc. +# Each to* operator's tests are parametrized over both the native operator +# and the equivalent $convert expression using the helpers below. +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import Any + +import pytest + +from documentdb_tests.framework.test_case import BaseTestCase + +# Public API for to* operator test files. +__all__ = ["convert_expr", "convert_format_auto_expr", "convert_field_expr"] + + +# Sentinel for "omit this parameter from the expression." Distinct from None +# (which means pass null) and MISSING (which means reference a missing field). +_OMIT = object() + + +@dataclass(frozen=True) +class ConvertTest(BaseTestCase): + """Test case for $convert operator.""" + + input: Any = None + to: Any = None + byte_order: Any = _OMIT + format: Any = _OMIT + on_error: Any = _OMIT + on_null: Any = _OMIT + expr: Any = None # Raw expression override (for syntax tests) + + +def _build_convert( + input: Any, + to: Any, + on_null: Any = _OMIT, + on_error: Any = _OMIT, + fmt: Any = _OMIT, + byte_order: Any = _OMIT, +) -> dict[str, Any]: + doc: dict[str, Any] = {"input": input, "to": to} + if byte_order is not _OMIT: + doc["byteOrder"] = byte_order + if fmt is not _OMIT: + doc["format"] = fmt + if on_error is not _OMIT: + doc["onError"] = on_error + if on_null is not _OMIT: + doc["onNull"] = on_null + return {"$convert": doc} + + +def _expr(test_case: ConvertTest) -> dict[str, Any]: + if test_case.expr is not None: + return dict(test_case.expr) + return _build_convert( + input=test_case.input, + to=test_case.to, + on_null=test_case.on_null, + on_error=test_case.on_error, + fmt=test_case.format, + byte_order=test_case.byte_order, + ) + + +def convert_expr(target_type: str): + """Return a pytest.param that builds a $convert expression for the given target type.""" + return pytest.param( + lambda tc: {"$convert": {"input": tc.value, "to": target_type}}, + id="convert", + ) + + +def convert_format_auto_expr(target_type: str): + """Return a pytest.param that builds a $convert expression with format: 'auto' (needed for string/binData conversions).""" # noqa: E501 + return pytest.param( + lambda tc: { + "$convert": { + "input": tc.value, + "to": target_type, + "format": "auto", + } + }, + id="convert_format_auto", + ) + + +def convert_field_expr(target_type: str): + """Return a pytest.param that builds a $convert expression for a field path.""" + return pytest.param( + lambda field: {"$convert": {"input": field, "to": target_type}}, + id="convert", + ) + + +# Sentinel for pattern templates; replaced with null/missing. +_PLACEHOLDER = object() + + +def _build_null_tests( + patterns: list[ConvertTest], null_value, prefix, field: str = "input" +) -> list[ConvertTest]: + result: list[ConvertTest] = [] + for t in patterns: + assert t.msg is not None + result.append( + replace( + t, + id=f"{prefix}_{t.id}", + msg=t.msg.format(prefix=prefix), + **{field: null_value}, + ) + ) + return result