From 630a16ec95637dc2f4461ef700b26de8113c8417 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Tue, 31 Mar 2026 17:05:00 +0000 Subject: [PATCH 1/5] Add string operator $replace* tests Signed-off-by: Daniel Frankcom --- .../string/replaceAll/test_replaceAll_core.py | 478 ++++++++++++++++ .../replaceAll/test_replaceAll_encoding.py | 461 ++++++++++++++++ .../test_replaceAll_invalid_args.py | 277 ++++++++++ .../replaceAll/test_replaceAll_invariants.py | 165 ++++++ .../string/replaceAll/test_replaceAll_null.py | 115 ++++ .../replaceAll/test_replaceAll_size_limit.py | 140 +++++ .../replaceAll/test_replaceAll_type_errors.py | 520 ++++++++++++++++++ .../test_replaceAll_type_precedence.py | 96 ++++ .../string/replaceAll/utils/__init__.py | 0 .../replaceAll/utils/replaceAll_common.py | 28 + .../string/replaceOne/test_replaceOne_core.py | 377 +++++++++++++ .../replaceOne/test_replaceOne_encoding.py | 221 ++++++++ .../test_replaceOne_invalid_args.py | 400 ++++++++++++++ .../replaceOne/test_replaceOne_invariants.py | 158 ++++++ .../string/replaceOne/test_replaceOne_null.py | 112 ++++ .../replaceOne/test_replaceOne_size_limit.py | 171 ++++++ .../test_replaceOne_special_chars.py | 245 +++++++++ .../replaceOne/test_replaceOne_type_errors.py | 448 +++++++++++++++ .../replaceOne/test_replaceOne_usage.py | 126 +++++ .../replaceOne/test_replaceOne_whitespace.py | 222 ++++++++ .../string/replaceOne/utils/__init__.py | 0 .../replaceOne/utils/replaceOne_common.py | 28 + 22 files changed, 4788 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_core.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_encoding.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_invalid_args.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_invariants.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_null.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_size_limit.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_type_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_type_precedence.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/utils/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/utils/replaceAll_common.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_core.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_encoding.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_invalid_args.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_invariants.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_null.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_size_limit.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_special_chars.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_type_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_usage.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_whitespace.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/utils/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/utils/replaceOne_common.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_core.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_core.py new file mode 100644 index 00000000..3ba60779 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_core.py @@ -0,0 +1,478 @@ +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.test_case import pytest_params +from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceAll.utils.replaceAll_common import ( + ReplaceAllTest, + _expr, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ExpressionTestCase +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + execute_expression, + execute_expression_with_insert, +) + +# Property [Core Replacement]: all occurrences of find are replaced. Matching is left-to-right +# greedy with no re-scanning. +REPLACEALL_CORE_TESTS: list[ReplaceAllTest] = [ + ReplaceAllTest( + "core_all_occurrences", + input="cat bat cat", + find="cat", + replacement="dog", + expected="dog bat dog", + msg="$replaceAll should all occurrences", + ), + ReplaceAllTest( + "core_repeated_single_char", + input="aaa", + find="a", + replacement="X", + expected="XXX", + msg="$replaceAll should repeated single char", + ), + ReplaceAllTest( + "core_no_match", + input="hello world", + find="xyz", + replacement="abc", + expected="hello world", + msg="$replaceAll should no match", + ), + # Overlapping pattern: "aa" in "aaa" matches first two, leaving third. + ReplaceAllTest( + "core_overlapping_greedy", + input="aaa", + find="aa", + replacement="X", + expected="Xa", + msg="$replaceAll should overlapping greedy", + ), + ReplaceAllTest( + "core_overlapping_even", + input="aaaa", + find="aa", + replacement="X", + expected="XX", + msg="$replaceAll should overlapping even", + ), + # Replacement contains find pattern. No re-scanning should occur. + ReplaceAllTest( + "core_no_rescan", + input="ab", + find="a", + replacement="aa", + expected="aab", + msg="$replaceAll should no rescan", + ), + ReplaceAllTest( + "core_no_rescan_multiple", + input="xyx", + find="x", + replacement="xx", + expected="xxyxx", + msg="$replaceAll should no rescan multiple", + ), +] + + +# Property [Case Sensitivity]: matching is case-sensitive. +REPLACEALL_CASE_TESTS: list[ReplaceAllTest] = [ + ReplaceAllTest( + "case_lower_find_no_match", + input="Hello", + find="hello", + replacement="X", + expected="Hello", + msg="$replaceAll case sensitivity: lower find no match", + ), + ReplaceAllTest( + "case_upper_find_no_match", + input="hello", + find="Hello", + replacement="X", + expected="hello", + msg="$replaceAll case sensitivity: upper find no match", + ), + ReplaceAllTest( + "case_exact_match_all", + input="Hello hello Hello", + find="Hello", + replacement="X", + expected="X hello X", + msg="$replaceAll case sensitivity: exact match all", + ), + ReplaceAllTest( + "case_all_upper_no_match", + input="ABC", + find="abc", + replacement="X", + expected="ABC", + msg="$replaceAll case sensitivity: all upper no match", + ), + # Latin extended: é (U+00E9) vs É (U+00C9). + ReplaceAllTest( + "case_latin_extended", + input="\u00c9", + find="\u00e9", + replacement="X", + expected="\u00c9", + msg="$replaceAll case sensitivity: latin extended", + ), + # Greek: σ (U+03C3) vs Σ (U+03A3). + ReplaceAllTest( + "case_greek", + input="\u03a3", + find="\u03c3", + replacement="X", + expected="\u03a3", + msg="$replaceAll case sensitivity: greek", + ), + # Cyrillic: д (U+0434) vs Д (U+0414). + ReplaceAllTest( + "case_cyrillic", + input="\u0414", + find="\u0434", + replacement="X", + expected="\u0414", + msg="$replaceAll case sensitivity: cyrillic", + ), + # Deseret: 𐐨 (U+10428) vs 𐐀 (U+10400). + ReplaceAllTest( + "case_deseret", + input="\U00010400", + find="\U00010428", + replacement="X", + expected="\U00010400", + msg="$replaceAll case sensitivity: deseret", + ), + # No case folding: ß (U+00DF) does not match SS or ss. + ReplaceAllTest( + "case_no_fold_eszett_upper", + input="SS", + find="\u00df", + replacement="X", + expected="SS", + msg="$replaceAll case sensitivity: no fold eszett upper", + ), + ReplaceAllTest( + "case_no_fold_eszett_lower", + input="ss", + find="\u00df", + replacement="X", + expected="ss", + msg="$replaceAll case sensitivity: no fold eszett lower", + ), + # No case folding: fi (U+FB01) does not match fi. + ReplaceAllTest( + "case_no_fold_fi_ligature", + input="fi", + find="\ufb01", + replacement="X", + expected="fi", + msg="$replaceAll case sensitivity: no fold fi ligature", + ), + # No case folding: ı (U+0131) does not match i or I. + ReplaceAllTest( + "case_no_fold_dotless_i_lower", + input="i", + find="\u0131", + replacement="X", + expected="i", + msg="$replaceAll case sensitivity: no fold dotless i lower", + ), + ReplaceAllTest( + "case_no_fold_dotless_i_upper", + input="I", + find="\u0131", + replacement="X", + expected="I", + msg="$replaceAll case sensitivity: no fold dotless i upper", + ), +] + + +# Property [Identity]: find equals replacement leaves input unchanged. +REPLACEALL_IDENTITY_TESTS: list[ReplaceAllTest] = [ + ReplaceAllTest( + "identity_find_equals_replacement", + input="hello world", + find="world", + replacement="world", + expected="hello world", + msg="$replaceAll identity: find equals replacement", + ), + ReplaceAllTest( + "identity_single_char", + input="aaa", + find="a", + replacement="a", + expected="aaa", + msg="$replaceAll identity: single char", + ), + ReplaceAllTest( + "identity_full_match", + input="hello", + find="hello", + replacement="hello", + expected="hello", + msg="$replaceAll identity: full match", + ), + ReplaceAllTest( + "identity_empty_find_empty_replacement", + input="hello", + find="", + replacement="", + expected="hello", + msg="$replaceAll identity: empty find empty replacement", + ), +] + + +# Property [Expression Arguments]: all three parameters accept arbitrary expressions. +REPLACEALL_EXPR_TESTS: list[ReplaceAllTest] = [ + ReplaceAllTest( + "expr_input_expression", + input={"$toUpper": "hello world"}, + find="WORLD", + replacement="X", + expected="HELLO X", + msg="$replaceAll should accept input expression", + ), + ReplaceAllTest( + "expr_find_expression", + input="HELLO WORLD", + find={"$toUpper": "world"}, + replacement="X", + expected="HELLO X", + msg="$replaceAll should accept find expression", + ), + ReplaceAllTest( + "expr_replacement_expression", + input="hello world", + find="world", + replacement={"$toUpper": "earth"}, + expected="hello EARTH", + msg="$replaceAll should accept replacement expression", + ), + ReplaceAllTest( + "expr_all_expressions", + input={"$concat": ["hel", "lo"]}, + find={"$toLower": "LO"}, + replacement={"$toUpper": "p"}, + expected="helP", + msg="$replaceAll should accept all expressions", + ), + # Expression resolving to null follows null-propagation. + ReplaceAllTest( + "expr_null_input", + input={"$literal": None}, + find="a", + replacement="b", + expected=None, + msg="$replaceAll should accept null input", + ), +] + + +# Property [Empty String Behavior]: empty strings in any parameter position produce well-defined +# results including insertion between characters, deletion, and identity. Empty find inserts +# replacement at every code point boundary, not byte boundary. +REPLACEALL_EMPTY_STRING_TESTS: list[ReplaceAllTest] = [ + ReplaceAllTest( + "empty_input_no_match", + input="", + find="abc", + replacement="X", + expected="", + msg="$replaceAll empty string: input no match", + ), + ReplaceAllTest( + "empty_find_inserts_everywhere", + input="abc", + find="", + replacement="X", + expected="XaXbXcX", + msg="$replaceAll empty string: find inserts everywhere", + ), + ReplaceAllTest( + "empty_find_empty_input", + input="", + find="", + replacement="X", + expected="X", + msg="$replaceAll empty string: find empty input", + ), + ReplaceAllTest( + "empty_all_three", + input="", + find="", + replacement="", + expected="", + msg="$replaceAll empty string: all three", + ), + ReplaceAllTest( + "empty_replacement_deletes", + input="hello world", + find="o", + replacement="", + expected="hell wrld", + msg="$replaceAll empty string: replacement deletes", + ), + # Empty find with multi-byte characters: replacement should be inserted at code point + # boundaries, not byte boundaries. + # 2-byte character: é (U+00E9). + ReplaceAllTest( + "empty_find_multibyte_2byte", + input="\u00e9", + find="", + replacement="X", + expected="X\u00e9X", + msg="$replaceAll empty string: find multibyte 2byte", + ), + # 3-byte character: 世 (U+4E16). + ReplaceAllTest( + "empty_find_multibyte_3byte", + input="\u4e16", + find="", + replacement="X", + expected="X\u4e16X", + msg="$replaceAll empty string: find multibyte 3byte", + ), + # 4-byte character: 😀 (U+1F600). + ReplaceAllTest( + "empty_find_multibyte_4byte", + input="\U0001f600", + find="", + replacement="X", + expected="X\U0001f600X", + msg="$replaceAll empty string: find multibyte 4byte", + ), +] + + +# Property [Edge Cases]: position boundaries, backslashes, and large inputs are handled correctly. +REPLACEALL_EDGE_TESTS: list[ReplaceAllTest] = [ + # Find longer than input. + ReplaceAllTest( + "edge_find_longer_than_input", + input="hi", + find="hello", + replacement="X", + expected="hi", + msg="$replaceAll edge: find longer than input", + ), + # Find at start. + ReplaceAllTest( + "edge_find_at_start", + input="abcdef", + find="abc", + replacement="X", + expected="Xdef", + msg="$replaceAll edge: find at start", + ), + # Find at end. + ReplaceAllTest( + "edge_find_at_end", + input="abcdef", + find="def", + replacement="X", + expected="abcX", + msg="$replaceAll edge: find at end", + ), + # Find at start and end. + ReplaceAllTest( + "edge_find_at_start_and_end", + input="abcabc", + find="abc", + replacement="X", + expected="XX", + msg="$replaceAll edge: find at start and end", + ), + # Backslash in all arguments. + ReplaceAllTest( + "edge_backslash_find", + input="a\\b\\c", + find="\\", + replacement="X", + expected="aXbXc", + msg="$replaceAll edge: backslash find", + ), + ReplaceAllTest( + "edge_backslash_replacement", + input="hello", + find="h", + replacement="\\", + expected="\\ello", + msg="$replaceAll edge: backslash replacement", + ), + ReplaceAllTest( + "edge_backslash_in_input_and_find", + input="a\\b", + find="a\\b", + replacement="X", + expected="X", + msg="$replaceAll edge: backslash in input and find", + ), +] + + +REPLACEALL_CORE_ALL_TESTS = ( + REPLACEALL_CORE_TESTS + + REPLACEALL_CASE_TESTS + + REPLACEALL_IDENTITY_TESTS + + REPLACEALL_EXPR_TESTS + + REPLACEALL_EMPTY_STRING_TESTS + + REPLACEALL_EDGE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(REPLACEALL_CORE_ALL_TESTS)) +def test_replaceall_core_cases(collection, test_case: ReplaceAllTest): + """Test $replaceAll core cases.""" + result = execute_expression(collection, _expr(test_case)) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) + + +# Property [Document Field References]: $replaceAll works with field references from inserted +# documents. +REPLACEALL_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + # Object expression: all args from simple field paths. + ExpressionTestCase( + "field_object", + expression={"$replaceAll": {"input": "$i", "find": "$f", "replacement": "$r"}}, + doc={"i": "cat bat cat", "f": "cat", "r": "dog"}, + expected="dog bat dog", + msg="$replaceAll should accept args from document field paths", + ), + # Composite array: all args from $arrayElemAt on a projected array-of-objects field. + ExpressionTestCase( + "field_composite_array", + expression={ + "$replaceAll": { + "input": {"$arrayElemAt": ["$a.b", 0]}, + "find": {"$arrayElemAt": ["$a.b", 1]}, + "replacement": {"$arrayElemAt": ["$a.b", 2]}, + } + }, + doc={"a": [{"b": "cat bat cat"}, {"b": "cat"}, {"b": "dog"}]}, + expected="dog bat dog", + msg="$replaceAll should accept args from composite array field paths", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(REPLACEALL_FIELD_REF_TESTS)) +def test_replaceall_field_refs(collection, test_case: ExpressionTestCase): + """Test $replaceAll with document field references.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assertResult( + 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/string/replaceAll/test_replaceAll_encoding.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_encoding.py new file mode 100644 index 00000000..e749c5cf --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_encoding.py @@ -0,0 +1,461 @@ +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.test_case import pytest_params +from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceAll.utils.replaceAll_common import ( + ReplaceAllTest, + _expr, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import execute_expression + +# Property [Encoding]: matching is diacritic-sensitive, no Unicode normalization, multi-byte +# characters handled correctly, special and regex characters treated literally. +REPLACEALL_ENCODING_TESTS: list[ReplaceAllTest] = [ + # Diacritic sensitivity. + ReplaceAllTest( + "encoding_diacritic_no_match", + input="café", + find="cafe", + replacement="X", + expected="café", + msg="$replaceAll should handle diacritic no match", + ), + ReplaceAllTest( + "encoding_diaeresis_no_match", + input="naïve", + find="naive", + replacement="X", + expected="naïve", + msg="$replaceAll should handle diaeresis no match", + ), + ReplaceAllTest( + "encoding_diacritic_match_all", + input="résumé", + find="é", + replacement="e", + expected="resume", + msg="$replaceAll should handle diacritic match all", + ), + ReplaceAllTest( + "encoding_diacritic_a_no_match", + input="está", + find="esta", + replacement="X", + expected="está", + msg="$replaceAll should handle diacritic a no match", + ), + ReplaceAllTest( + "encoding_diacritic_n_no_match", + input="año", + find="ano", + replacement="X", + expected="año", + msg="$replaceAll should handle diacritic n no match", + ), + # Precomposed U+00E9 does not match decomposed U+0065 U+0301. + ReplaceAllTest( + "encoding_precomposed_vs_decomposed", + input="\u00e9", + find="e\u0301", + replacement="X", + expected="\u00e9", + msg="$replaceAll should handle precomposed vs decomposed", + ), + ReplaceAllTest( + "encoding_decomposed_vs_precomposed", + input="e\u0301", + find="\u00e9", + replacement="X", + expected="e\u0301", + msg="$replaceAll should handle decomposed vs precomposed", + ), + # Same representation matches. + ReplaceAllTest( + "encoding_precomposed_matches_precomposed", + input="\u00e9", + find="\u00e9", + replacement="X", + expected="X", + msg="$replaceAll should handle precomposed matches precomposed", + ), + ReplaceAllTest( + "encoding_decomposed_matches_decomposed", + input="e\u0301", + find="e\u0301", + replacement="X", + expected="X", + msg="$replaceAll should handle decomposed matches decomposed", + ), + # A base character can be matched independently of a following combining mark. + ReplaceAllTest( + "encoding_base_char_splits_combining_mark", + input="e\u0301", + find="e", + replacement="X", + expected="X\u0301", + msg="$replaceAll should handle base char splits combining mark", + ), + # Combining mark alone (U+0301) is matchable as find. + ReplaceAllTest( + "encoding_combining_mark_alone", + input="e\u0301", + find="\u0301", + replacement="X", + expected="eX", + msg="$replaceAll should handle combining mark alone", + ), + # Multi-byte UTF-8: 3-byte (U+4E16), 4-byte (U+1F600). 2-byte is covered by + # encoding_diacritic_match_all. + ReplaceAllTest( + "encoding_3byte_find", + input="hello世界世", + find="世", + replacement="X", + expected="helloX界X", + msg="$replaceAll should handle 3byte find", + ), + ReplaceAllTest( + "encoding_4byte_find", + input="a😀b😀c", + find="😀", + replacement="X", + expected="aXbXc", + msg="$replaceAll should handle 4byte find", + ), + ReplaceAllTest( + "encoding_4byte_replacement", + input="hello", + find="h", + replacement="😀", + expected="😀ello", + msg="$replaceAll should handle 4byte replacement", + ), + # Zero-width and invisible characters treated as regular code points. + # BOM (U+FEFF). + ReplaceAllTest( + "encoding_bom", + input="a\ufeffb", + find="\ufeff", + replacement="X", + expected="aXb", + msg="$replaceAll should handle bom", + ), + # ZWSP (U+200B). + ReplaceAllTest( + "encoding_zwsp", + input="a\u200bb", + find="\u200b", + replacement="X", + expected="aXb", + msg="$replaceAll should handle zwsp", + ), + # ZWJ (U+200D). + ReplaceAllTest( + "encoding_zwj", + input="a\u200db", + find="\u200d", + replacement="X", + expected="aXb", + msg="$replaceAll should handle zwj", + ), + # LTR mark (U+200E). + ReplaceAllTest( + "encoding_ltr_mark", + input="a\u200eb", + find="\u200e", + replacement="X", + expected="aXb", + msg="$replaceAll should handle ltr mark", + ), + # RTL mark (U+200F). + ReplaceAllTest( + "encoding_rtl_mark", + input="a\u200fb", + find="\u200f", + replacement="X", + expected="aXb", + msg="$replaceAll should handle rtl mark", + ), + # ZWJ within emoji sequence can be removed, splitting the sequence. + # Family emoji: U+1F468 U+200D U+1F469 U+200D U+1F467. + ReplaceAllTest( + "encoding_zwj_emoji_split", + input="\U0001f468\u200d\U0001f469\u200d\U0001f467", + find="\u200d", + replacement="", + expected="\U0001f468\U0001f469\U0001f467", + msg="$replaceAll should handle zwj emoji split", + ), + # Unicode boundary code points. + # U+FFFF (last BMP code point). + ReplaceAllTest( + "encoding_boundary_ffff", + input="a\uffffb", + find="\uffff", + replacement="X", + expected="aXb", + msg="$replaceAll should handle boundary ffff", + ), + # U+10000 (first supplementary code point). + ReplaceAllTest( + "encoding_boundary_10000", + input="a\U00010000b", + find="\U00010000", + replacement="X", + expected="aXb", + msg="$replaceAll should handle boundary 10000", + ), + # U+10FFFF (last valid Unicode code point). + ReplaceAllTest( + "encoding_boundary_10ffff", + input="a\U0010ffffb", + find="\U0010ffff", + replacement="X", + expected="aXb", + msg="$replaceAll should handle boundary 10ffff", + ), + # Special characters: newline, tab, carriage return, null byte. + ReplaceAllTest( + "encoding_newline", + input="line1\nline2\nline3", + find="\n", + replacement=" ", + expected="line1 line2 line3", + msg="$replaceAll should handle newline", + ), + ReplaceAllTest( + "encoding_tab", + input="col1\tcol2\tcol3", + find="\t", + replacement=" ", + expected="col1 col2 col3", + msg="$replaceAll should handle tab", + ), + ReplaceAllTest( + "encoding_carriage_return", + input="line1\r\nline2", + find="\r\n", + replacement="\n", + expected="line1\nline2", + msg="$replaceAll should handle carriage return", + ), + # Plain space as explicit find target. + ReplaceAllTest( + "encoding_space", + input="a b c", + find=" ", + replacement="X", + expected="aXbXc", + msg="$replaceAll should handle space", + ), + # NBSP (U+00A0). + ReplaceAllTest( + "encoding_nbsp", + input="a\u00a0b", + find="\u00a0", + replacement="X", + expected="aXb", + msg="$replaceAll should handle nbsp", + ), + # En space (U+2000). + ReplaceAllTest( + "encoding_en_space", + input="a\u2000b", + find="\u2000", + replacement="X", + expected="aXb", + msg="$replaceAll should handle en space", + ), + # Em space (U+2003). + ReplaceAllTest( + "encoding_em_space", + input="a\u2003b", + find="\u2003", + replacement="X", + expected="aXb", + msg="$replaceAll should handle em space", + ), + # No Unicode whitespace equivalence: NBSP does not match regular space. + ReplaceAllTest( + "encoding_nbsp_not_space", + input="a\u00a0b", + find=" ", + replacement="X", + expected="a\u00a0b", + msg="$replaceAll should handle nbsp not space", + ), + # \n alone within CRLF matches only the LF, leaving orphan \r. + ReplaceAllTest( + "encoding_lf_within_crlf", + input="line1\r\nline2", + find="\n", + replacement="X", + expected="line1\rXline2", + msg="$replaceAll should handle lf within crlf", + ), + ReplaceAllTest( + "encoding_null_byte", + input="a\x00b\x00c", + find="\x00", + replacement="X", + expected="aXbXc", + msg="$replaceAll should handle null byte", + ), + # Regex-special characters treated literally. + ReplaceAllTest( + "encoding_regex_dot", + input="a.b.c", + find=".", + replacement="X", + expected="aXbXc", + msg="$replaceAll should handle regex dot", + ), + ReplaceAllTest( + "encoding_regex_dot_star", + input="a.*b", + find=".*", + replacement="X", + expected="aXb", + msg="$replaceAll should handle regex dot star", + ), + ReplaceAllTest( + "encoding_regex_parens_plus", + input="(a+b)(a+b)", + find="(a+b)", + replacement="X", + expected="XX", + msg="$replaceAll should handle regex parens plus", + ), + ReplaceAllTest( + "encoding_regex_brackets", + input="a[0]b[0]", + find="[0]", + replacement="X", + expected="aXbX", + msg="$replaceAll should handle regex brackets", + ), + # Regex-special: ?, |, ^, {, }. + ReplaceAllTest( + "encoding_regex_question", + input="a?b|c^d{e}", + find="?", + replacement="X", + expected="aXb|c^d{e}", + msg="$replaceAll should handle regex question", + ), + ReplaceAllTest( + "encoding_regex_pipe", + input="a|b|c", + find="|", + replacement="X", + expected="aXbXc", + msg="$replaceAll should handle regex pipe", + ), + ReplaceAllTest( + "encoding_regex_caret", + input="^a^b^", + find="^", + replacement="X", + expected="XaXbX", + msg="$replaceAll should handle regex caret", + ), + # Control character 0x01 (SOH). + ReplaceAllTest( + "encoding_control_char_soh", + input="a\x01b\x01c", + find="\x01", + replacement="X", + expected="aXbXc", + msg="$replaceAll should handle control char soh", + ), + # Control character 0x1F (US). + ReplaceAllTest( + "encoding_control_char_us", + input="a\x1fb", + find="\x1f", + replacement="X", + expected="aXb", + msg="$replaceAll should handle control char us", + ), + # JSON/BSON-significant characters: double quote, braces, dollar sign via $literal. + ReplaceAllTest( + "encoding_double_quote", + input='a"b"c', + find='"', + replacement="X", + expected="aXbXc", + msg="$replaceAll should handle double quote", + ), + ReplaceAllTest( + "encoding_left_brace", + input="a{b}c", + find="{", + replacement="X", + expected="aXb}c", + msg="$replaceAll should handle left brace", + ), + ReplaceAllTest( + "encoding_right_brace", + input="a{b}c", + find="}", + replacement="X", + expected="a{bXc", + msg="$replaceAll should handle right brace", + ), + # Backslash sequences in replacement treated as literal text, not regex backreferences. + ReplaceAllTest( + "encoding_backslash_one_literal", + input="abc", + find="b", + replacement="\\1", + expected="a\\1c", + msg="$replaceAll should handle backslash one literal", + ), + ReplaceAllTest( + "encoding_backslash_n_literal", + input="abc", + find="b", + replacement="\\n", + expected="a\\nc", + msg="$replaceAll should handle backslash n literal", + ), + # Dollar-prefixed strings via $literal in all three parameters. + ReplaceAllTest( + "encoding_dollar_literal_input", + input={"$literal": "$hello"}, + find={"$literal": "$hello"}, + replacement="X", + expected="X", + msg="$replaceAll should handle dollar literal input", + ), + ReplaceAllTest( + "encoding_dollar_literal_find", + input={"$literal": "$hello world"}, + find={"$literal": "$hello"}, + replacement="X", + expected="X world", + msg="$replaceAll should handle dollar literal find", + ), + ReplaceAllTest( + "encoding_dollar_literal_replacement", + input="hello", + find="hello", + replacement={"$literal": "$world"}, + expected="$world", + msg="$replaceAll should handle dollar literal replacement", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(REPLACEALL_ENCODING_TESTS)) +def test_replaceall_encoding_cases(collection, test_case: ReplaceAllTest): + """Test $replaceAll encoding cases.""" + result = execute_expression(collection, _expr(test_case)) + assertResult( + 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/string/replaceAll/test_replaceAll_invalid_args.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_invalid_args.py new file mode 100644 index 00000000..ea7872d6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_invalid_args.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + FAILED_TO_PARSE_ERROR, + INVALID_DOLLAR_FIELD_PATH, + REPLACE_MISSING_FIND_ERROR, + REPLACE_MISSING_INPUT_ERROR, + REPLACE_MISSING_REPLACEMENT_ERROR, + REPLACE_NON_OBJECT_ERROR, + REPLACE_UNKNOWN_FIELD_ERROR, +) +from documentdb_tests.framework.test_case import pytest_params +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF +from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceAll.utils.replaceAll_common import ( + ReplaceAllTest, + _expr, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import execute_expression + +# Property [Bare Dollar Sign]: a bare "$" string in any parameter position is interpreted as a +# field path, producing INVALID_DOLLAR_FIELD_PATH. +REPLACEALL_BARE_DOLLAR_TESTS: list[ReplaceAllTest] = [ + ReplaceAllTest( + "bare_dollar_input", + input="$", + find="a", + replacement="b", + error_code=INVALID_DOLLAR_FIELD_PATH, + msg="$replaceAll should reject bare '$' in input", + ), + ReplaceAllTest( + "bare_dollar_find", + input="hello", + find="$", + replacement="b", + error_code=INVALID_DOLLAR_FIELD_PATH, + msg="$replaceAll should reject bare '$' in find", + ), + ReplaceAllTest( + "bare_dollar_replacement", + input="hello", + find="a", + replacement="$", + error_code=INVALID_DOLLAR_FIELD_PATH, + msg="$replaceAll should reject bare '$' in replacement", + ), +] + + +# Property [Double Dollar Sign]: a "$$" string in any parameter position produces +# FAILED_TO_PARSE_ERROR. +REPLACEALL_DOUBLE_DOLLAR_TESTS: list[ReplaceAllTest] = [ + ReplaceAllTest( + "double_dollar_input", + input="$$", + find="a", + replacement="b", + error_code=FAILED_TO_PARSE_ERROR, + msg="$replaceAll should reject '$$' in input", + ), + ReplaceAllTest( + "double_dollar_find", + input="hello", + find="$$", + replacement="b", + error_code=FAILED_TO_PARSE_ERROR, + msg="$replaceAll should reject '$$' in find", + ), + ReplaceAllTest( + "double_dollar_replacement", + input="hello", + find="a", + replacement="$$", + error_code=FAILED_TO_PARSE_ERROR, + msg="$replaceAll should reject '$$' in replacement", + ), +] + + +# Property [Syntax Validation - Non-Object]: a non-object argument to $replaceAll produces +# REPLACE_NON_OBJECT_ERROR. +REPLACEALL_NON_OBJECT_TESTS: list[ReplaceAllTest] = [ + ReplaceAllTest( + "syntax_string", + expr={"$replaceAll": "hello"}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceAll should reject string", + ), + ReplaceAllTest( + "syntax_int", + expr={"$replaceAll": 42}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceAll should reject int", + ), + ReplaceAllTest( + "syntax_float", + expr={"$replaceAll": 3.14}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceAll should reject float", + ), + ReplaceAllTest( + "syntax_long", + expr={"$replaceAll": Int64(42)}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceAll should reject long", + ), + ReplaceAllTest( + "syntax_decimal128", + expr={"$replaceAll": DECIMAL128_ONE_AND_HALF}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceAll should reject decimal128", + ), + ReplaceAllTest( + "syntax_null", + expr={"$replaceAll": None}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceAll should reject null", + ), + ReplaceAllTest( + "syntax_bool", + expr={"$replaceAll": True}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceAll should reject bool", + ), + ReplaceAllTest( + "syntax_array", + expr={"$replaceAll": ["a", "b", "c"]}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceAll should reject array", + ), + ReplaceAllTest( + "syntax_binary", + expr={"$replaceAll": Binary(b"data")}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceAll should reject binary", + ), + ReplaceAllTest( + "syntax_date", + expr={"$replaceAll": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceAll should reject date", + ), + ReplaceAllTest( + "syntax_objectid", + expr={"$replaceAll": ObjectId()}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceAll should reject objectid", + ), + ReplaceAllTest( + "syntax_regex", + expr={"$replaceAll": Regex("pattern")}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceAll should reject regex", + ), + ReplaceAllTest( + "syntax_timestamp", + expr={"$replaceAll": Timestamp(1, 1)}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceAll should reject timestamp", + ), + ReplaceAllTest( + "syntax_minkey", + expr={"$replaceAll": MinKey()}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceAll should reject minkey", + ), + ReplaceAllTest( + "syntax_maxkey", + expr={"$replaceAll": MaxKey()}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceAll should reject maxkey", + ), + ReplaceAllTest( + "syntax_code", + expr={"$replaceAll": Code("function() {}")}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceAll should reject code", + ), + ReplaceAllTest( + "syntax_code_scope", + expr={"$replaceAll": Code("function() {}", {"x": 1})}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceAll should reject code scope", + ), +] + + +# Property [Syntax Validation - Missing and Unknown Fields]: omitting required fields or including +# unknown fields produces a specific error, with precedence non-object > unknown field > missing +# input > missing find > missing replacement > type errors. +REPLACEALL_FIELD_VALIDATION_TESTS: list[ReplaceAllTest] = [ + ReplaceAllTest( + "syntax_missing_input", + expr={"$replaceAll": {"find": "a", "replacement": "b"}}, + error_code=REPLACE_MISSING_INPUT_ERROR, + msg="$replaceAll should reject missing input", + ), + ReplaceAllTest( + "syntax_missing_find", + expr={"$replaceAll": {"input": "hello", "replacement": "b"}}, + error_code=REPLACE_MISSING_FIND_ERROR, + msg="$replaceAll should reject missing find", + ), + ReplaceAllTest( + "syntax_missing_replacement", + expr={"$replaceAll": {"input": "hello", "find": "a"}}, + error_code=REPLACE_MISSING_REPLACEMENT_ERROR, + msg="$replaceAll should reject missing replacement", + ), + ReplaceAllTest( + "syntax_unknown_field", + expr={"$replaceAll": {"input": "hello", "find": "a", "replacement": "b", "extra": 1}}, + error_code=REPLACE_UNKNOWN_FIELD_ERROR, + msg="$replaceAll should reject unknown field", + ), + # Missing input takes precedence over missing find. + ReplaceAllTest( + "syntax_missing_input_and_find", + expr={"$replaceAll": {"replacement": "b"}}, + error_code=REPLACE_MISSING_INPUT_ERROR, + msg="$replaceAll should reject missing input and find", + ), + # Missing input takes precedence over missing replacement. + ReplaceAllTest( + "syntax_missing_input_and_replacement", + expr={"$replaceAll": {"find": "a"}}, + error_code=REPLACE_MISSING_INPUT_ERROR, + msg="$replaceAll should reject missing input and replacement", + ), + # Missing find takes precedence over missing replacement. + ReplaceAllTest( + "syntax_missing_find_and_replacement", + expr={"$replaceAll": {"input": "hello"}}, + error_code=REPLACE_MISSING_FIND_ERROR, + msg="$replaceAll should reject missing find and replacement", + ), + # Missing all required fields: input validated first. + ReplaceAllTest( + "syntax_missing_all", + expr={"$replaceAll": {}}, + error_code=REPLACE_MISSING_INPUT_ERROR, + msg="$replaceAll should reject missing all", + ), + # Unknown field takes precedence over missing fields. + ReplaceAllTest( + "syntax_unknown_precedes_missing", + expr={"$replaceAll": {"extra": 1}}, + error_code=REPLACE_UNKNOWN_FIELD_ERROR, + msg="$replaceAll should reject unknown precedes missing", + ), +] + + +REPLACEALL_INVALID_ARGS_ALL_TESTS = ( + REPLACEALL_BARE_DOLLAR_TESTS + + REPLACEALL_DOUBLE_DOLLAR_TESTS + + REPLACEALL_NON_OBJECT_TESTS + + REPLACEALL_FIELD_VALIDATION_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(REPLACEALL_INVALID_ARGS_ALL_TESTS)) +def test_replaceall_invalid_args_cases(collection, test_case: ReplaceAllTest): + """Test $replaceAll invalid argument cases.""" + result = execute_expression(collection, _expr(test_case)) + assertResult( + 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/string/replaceAll/test_replaceAll_invariants.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_invariants.py new file mode 100644 index 00000000..9db98ba0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_invariants.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.test_case import pytest_params +from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceAll.utils.replaceAll_common import ( + ReplaceAllTest, + _expr, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + execute_expression, + execute_project, +) + +# Property [Length Invariant]: result length equals input length minus N * find length plus +# N * replacement length. +REPLACEALL_LENGTH_TESTS: list[ReplaceAllTest] = [ + ReplaceAllTest( + "length_match_same_size", + input="hello", + find="h", + replacement="j", + msg="$replaceAll length invariant: match same size", + ), + ReplaceAllTest( + "length_match_longer_replacement", + input="hello", + find="l", + replacement="xyz", + msg="$replaceAll length invariant: match longer replacement", + ), + ReplaceAllTest( + "length_match_shorter_replacement", + input="hello", + find="hel", + replacement="x", + msg="$replaceAll length invariant: match shorter replacement", + ), + ReplaceAllTest( + "length_match_empty_replacement", + input="hello", + find="hello", + replacement="", + msg="$replaceAll length invariant: match empty replacement", + ), + ReplaceAllTest( + "length_empty_find", + input="hello", + find="", + replacement="X", + msg="$replaceAll length invariant: empty find", + ), + ReplaceAllTest( + "length_no_match", + input="hello", + find="xyz", + replacement="abc", + msg="$replaceAll length invariant: no match", + ), + ReplaceAllTest( + "length_multibyte", + input="café", + find="é", + replacement="ee", + msg="$replaceAll length invariant: multibyte", + ), + ReplaceAllTest( + "length_4byte_to_1byte", + input="a😀b😀c", + find="😀", + replacement="X", + msg="$replaceAll length invariant: 4byte to 1byte", + ), + ReplaceAllTest( + "length_multiple_matches", + input="abcabcabc", + find="abc", + replacement="X", + msg="$replaceAll length invariant: multiple matches", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(REPLACEALL_LENGTH_TESTS)) +def test_replaceall_length_invariant(collection, test_case: ReplaceAllTest): + """Test $replaceAll result length invariant.""" + expected_len = len(test_case.input.replace(test_case.find, test_case.replacement)) + result = execute_expression(collection, {"$strLenCP": _expr(test_case)}) + assertSuccess(result, [{"result": expected_len}], msg=test_case.msg) + + +# Property [Return Type]: the result is always a string when all arguments are non-null strings. +REPLACEALL_RETURN_TYPE_TESTS: list[ReplaceAllTest] = [ + ReplaceAllTest( + "return_type_match", + input="hello", + find="h", + replacement="j", + msg="$replaceAll should return string for match", + ), + ReplaceAllTest( + "return_type_no_match", + input="hello", + find="x", + replacement="y", + msg="$replaceAll should return string for no match", + ), + ReplaceAllTest( + "return_type_all_empty", + input="", + find="", + replacement="", + msg="$replaceAll should return string for all empty", + ), + ReplaceAllTest( + "return_type_unicode", + input="café", + find="é", + replacement="e", + msg="$replaceAll should return string for unicode", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(REPLACEALL_RETURN_TYPE_TESTS)) +def test_replaceall_return_type(collection, test_case: ReplaceAllTest): + """Test $replaceAll result is always type string.""" + result = execute_expression(collection, {"$type": _expr(test_case)}) + assertSuccess(result, [{"result": "string"}], msg=test_case.msg) + + +# Property [Idempotency]: applying $replaceAll twice with the same find/replacement yields the +# same result as applying it once, when the replacement does not contain the find string. +REPLACEALL_IDEMPOTENCY_TESTS: list[ReplaceAllTest] = [ + ReplaceAllTest( + "idempotent_simple", + input="cat bat cat", + find="cat", + replacement="dog", + expected="dog bat dog", + msg="$replaceAll should be idempotent when replacement does not contain find", + ), + ReplaceAllTest( + "idempotent_no_match", + input="hello", + find="xyz", + replacement="abc", + expected="hello", + msg="$replaceAll should be idempotent when find has no match", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(REPLACEALL_IDEMPOTENCY_TESTS)) +def test_replaceall_idempotency(collection, test_case: ReplaceAllTest): + """Test $replaceAll idempotency.""" + once = _expr(test_case) + twice = { + "$replaceAll": {"input": once, "find": test_case.find, "replacement": test_case.replacement} + } + result = execute_project(collection, {"once": once, "twice": twice}) + assertSuccess( + result, [{"once": test_case.expected, "twice": test_case.expected}], msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_null.py new file mode 100644 index 00000000..9553fa1b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_null.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.test_case import pytest_params +from documentdb_tests.framework.test_constants import MISSING +from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceAll.utils.replaceAll_common import ( + ReplaceAllTest, + _expr, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import execute_expression + +# Property [Null Propagation]: if any argument is null or missing, the result is null, uniformly +# across all three argument positions. +_PLACEHOLDER = object() +_NULL_PATTERNS: list[tuple[Any, Any, Any, str]] = [ + (_PLACEHOLDER, "a", "b", "input"), + ("hello", _PLACEHOLDER, "b", "find"), + ("hello", "a", _PLACEHOLDER, "replacement"), + (_PLACEHOLDER, _PLACEHOLDER, "b", "input_and_find"), + (_PLACEHOLDER, "a", _PLACEHOLDER, "input_and_replacement"), + ("hello", _PLACEHOLDER, _PLACEHOLDER, "find_and_replacement"), + (_PLACEHOLDER, _PLACEHOLDER, _PLACEHOLDER, "all"), +] + + +def _build_null_tests(null_value, prefix) -> list[ReplaceAllTest]: + return [ + ReplaceAllTest( + f"{prefix}_{suffix}", + input=null_value if i is _PLACEHOLDER else i, + find=null_value if f is _PLACEHOLDER else f, + replacement=null_value if r is _PLACEHOLDER else r, + expected=None, + msg=f"$replaceAll {prefix} {suffix}", + ) + for i, f, r, suffix in _NULL_PATTERNS + ] + + +REPLACEALL_NULL_TESTS = _build_null_tests(None, "null") +REPLACEALL_MISSING_TESTS = _build_null_tests(MISSING, "missing") + + +# Property [Mixed Null and Missing]: null and missing interact correctly across positions. +REPLACEALL_MIXED_NULL_TESTS: list[ReplaceAllTest] = [ + ReplaceAllTest( + "null_input_missing_find", + input=None, + find=MISSING, + replacement="b", + expected=None, + msg="$replaceAll should return null for null input missing find", + ), + ReplaceAllTest( + "missing_input_null_find", + input=MISSING, + find=None, + replacement="b", + expected=None, + msg="$replaceAll should return null for missing input null find", + ), + ReplaceAllTest( + "null_find_missing_replacement", + input="hello", + find=None, + replacement=MISSING, + expected=None, + msg="$replaceAll should return null for null find missing replacement", + ), + ReplaceAllTest( + "missing_find_null_replacement", + input="hello", + find=MISSING, + replacement=None, + expected=None, + msg="$replaceAll should return null for missing find null replacement", + ), + ReplaceAllTest( + "null_input_missing_replacement", + input=None, + find="a", + replacement=MISSING, + expected=None, + msg="$replaceAll should return null for null input missing replacement", + ), + ReplaceAllTest( + "missing_input_null_replacement", + input=MISSING, + find="a", + replacement=None, + expected=None, + msg="$replaceAll should return null for missing input null replacement", + ), +] + + +REPLACEALL_NULL_ALL_TESTS = ( + REPLACEALL_NULL_TESTS + REPLACEALL_MISSING_TESTS + REPLACEALL_MIXED_NULL_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(REPLACEALL_NULL_ALL_TESTS)) +def test_replaceall_null_cases(collection, test_case: ReplaceAllTest): + """Test $replaceAll null propagation cases.""" + result = execute_expression(collection, _expr(test_case)) + assertResult( + 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/string/replaceAll/test_replaceAll_size_limit.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_size_limit.py new file mode 100644 index 00000000..1be8200d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_size_limit.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import STRING_SIZE_LIMIT_ERROR +from documentdb_tests.framework.test_case import pytest_params +from documentdb_tests.framework.test_constants import STRING_SIZE_LIMIT_BYTES +from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceAll.utils.replaceAll_common import ( + ReplaceAllTest, + _expr, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import execute_expression + +# Property [String Size Limit - Success]: strings one byte under the limit are accepted in all +# parameter positions, and replacement amplification succeeds when the result stays under the +# limit. +REPLACEALL_SIZE_LIMIT_SUCCESS_TESTS: list[ReplaceAllTest] = [ + ReplaceAllTest( + "size_success_input_max", + input="a" * (STRING_SIZE_LIMIT_BYTES - 1), + find="xyz", + replacement="abc", + expected="a" * (STRING_SIZE_LIMIT_BYTES - 1), + msg="$replaceAll size limit: success input max", + ), + # Replacement amplification producing result one under the limit. + ReplaceAllTest( + "size_success_amplification_max", + input="a", + find="a", + replacement="a" * (STRING_SIZE_LIMIT_BYTES - 1), + expected="a" * (STRING_SIZE_LIMIT_BYTES - 1), + msg="$replaceAll size limit: success amplification max", + ), + # 4-byte emoji at one character below the byte limit. + ReplaceAllTest( + "size_success_4byte_max", + input="\U0001f600" * ((STRING_SIZE_LIMIT_BYTES - 1) // 4), + find="xyz", + replacement="abc", + expected="\U0001f600" * ((STRING_SIZE_LIMIT_BYTES - 1) // 4), + msg="$replaceAll size limit: success 4byte max", + ), + # Empty find amplification just under the limit. + ReplaceAllTest( + "size_success_empty_find_amplification", + input="a" * ((STRING_SIZE_LIMIT_BYTES - 2) // 2), + find="", + replacement="X", + expected="X" + "aX" * ((STRING_SIZE_LIMIT_BYTES - 2) // 2), + msg="$replaceAll size limit: success empty find amplification", + ), +] + + +# Property [String Size Limit - Errors]: strings at the size limit produce an error, enforced +# per literal and per expression result. +REPLACEALL_SIZE_LIMIT_ERROR_TESTS: list[ReplaceAllTest] = [ + # Exactly at the limit. + ReplaceAllTest( + "size_error_input_at_limit", + input="a" * STRING_SIZE_LIMIT_BYTES, + find="a", + replacement="b", + error_code=STRING_SIZE_LIMIT_ERROR, + msg="$replaceAll size limit: error input at limit", + ), + # 2-byte chars at the limit. + ReplaceAllTest( + "size_error_byte_based_2byte", + input="\u00e9" * (STRING_SIZE_LIMIT_BYTES // 2), + find="\u00e9", + replacement="e", + error_code=STRING_SIZE_LIMIT_ERROR, + msg="$replaceAll size limit: error byte based 2byte", + ), + # 4-byte chars at the limit. + ReplaceAllTest( + "size_error_byte_based_4byte", + input="\U0001f600" * (STRING_SIZE_LIMIT_BYTES // 4), + find="\U0001f600", + replacement="x", + error_code=STRING_SIZE_LIMIT_ERROR, + msg="$replaceAll size limit: error byte based 4byte", + ), + # Input literal rejected even when replacement would shrink the result. + ReplaceAllTest( + "size_error_input_shrinking_rejected", + input="a" * STRING_SIZE_LIMIT_BYTES, + find="a" * 100, + replacement="", + error_code=STRING_SIZE_LIMIT_ERROR, + msg="$replaceAll size limit: error input shrinking rejected", + ), + # Result amplification: input under limit, replacement grows result to limit. + ReplaceAllTest( + "size_error_result_amplification", + input="a" * (STRING_SIZE_LIMIT_BYTES - 1), + find="a", + replacement="aa", + error_code=STRING_SIZE_LIMIT_ERROR, + msg="$replaceAll size limit: error result amplification", + ), + # Find parameter at the limit. + ReplaceAllTest( + "size_error_find_at_limit", + input="hello", + find="a" * STRING_SIZE_LIMIT_BYTES, + replacement="b", + error_code=STRING_SIZE_LIMIT_ERROR, + msg="$replaceAll size limit: error find at limit", + ), + # Replacement parameter at the limit. + ReplaceAllTest( + "size_error_replacement_at_limit", + input="hello", + find="a", + replacement="a" * STRING_SIZE_LIMIT_BYTES, + error_code=STRING_SIZE_LIMIT_ERROR, + msg="$replaceAll size limit: error replacement at limit", + ), +] + + +REPLACEALL_SIZE_LIMIT_ALL_TESTS = ( + REPLACEALL_SIZE_LIMIT_SUCCESS_TESTS + REPLACEALL_SIZE_LIMIT_ERROR_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(REPLACEALL_SIZE_LIMIT_ALL_TESTS)) +def test_replaceall_size_limit_cases(collection, test_case: ReplaceAllTest): + """Test $replaceAll size limit cases.""" + result = execute_expression(collection, _expr(test_case)) + assertResult( + 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/string/replaceAll/test_replaceAll_type_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_type_errors.py new file mode 100644 index 00000000..5c4f6043 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_type_errors.py @@ -0,0 +1,520 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + REPLACE_FIND_TYPE_ERROR, + REPLACE_INPUT_TYPE_ERROR, + REPLACE_REPLACEMENT_TYPE_ERROR, +) +from documentdb_tests.framework.test_case import pytest_params +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF +from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceAll.utils.replaceAll_common import ( + ReplaceAllTest, + _expr, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import execute_expression + +# Property [Type Strictness]: all three parameters must resolve to a string or null. Any other +# type produces an error specific to the argument position. +REPLACEALL_TYPE_ERROR_TESTS: list[ReplaceAllTest] = [ + # Invalid input types. + ReplaceAllTest( + "type_input_array", + input=["a"], + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceAll should reject array as input", + ), + ReplaceAllTest( + "type_input_binary", + input=Binary(b"data"), + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceAll should reject binary as input", + ), + ReplaceAllTest( + "type_input_bool", + input=True, + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceAll should reject bool as input", + ), + ReplaceAllTest( + "type_input_date", + input=datetime(2024, 1, 1, tzinfo=timezone.utc), + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceAll should reject date as input", + ), + ReplaceAllTest( + "type_input_decimal128", + input=DECIMAL128_ONE_AND_HALF, + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceAll should reject decimal128 as input", + ), + ReplaceAllTest( + "type_input_float", + input=3.14, + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceAll should reject float as input", + ), + ReplaceAllTest( + "type_input_int", + input=42, + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceAll should reject int as input", + ), + ReplaceAllTest( + "type_input_long", + input=Int64(42), + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceAll should reject long as input", + ), + ReplaceAllTest( + "type_input_maxkey", + input=MaxKey(), + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceAll should reject maxkey as input", + ), + ReplaceAllTest( + "type_input_minkey", + input=MinKey(), + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceAll should reject minkey as input", + ), + ReplaceAllTest( + "type_input_object", + input={"a": 1}, + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceAll should reject object as input", + ), + ReplaceAllTest( + "type_input_objectid", + input=ObjectId("507f1f77bcf86cd799439011"), + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceAll should reject objectid as input", + ), + ReplaceAllTest( + "type_input_regex", + input=Regex("pattern"), + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceAll should reject regex as input", + ), + ReplaceAllTest( + "type_input_empty_array", + input=[], + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceAll should reject empty array as input", + ), + ReplaceAllTest( + "type_input_multi_array", + input=["a", "b"], + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceAll should reject multi array as input", + ), + ReplaceAllTest( + "type_input_nested_array", + input=[["a"]], + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceAll should reject nested array as input", + ), + ReplaceAllTest( + "type_input_timestamp", + input=Timestamp(1, 1), + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceAll should reject timestamp as input", + ), + ReplaceAllTest( + "type_input_binary_uuid", + input=Binary(b"\x00" * 16, subtype=4), + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceAll should reject binary uuid as input", + ), + ReplaceAllTest( + "type_input_code", + input=Code("function(){}"), + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceAll should reject code as input", + ), + ReplaceAllTest( + "type_input_code_with_scope", + input=Code("function(){}", {"x": 1}), + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceAll should reject code with scope as input", + ), + # Invalid find types. + ReplaceAllTest( + "type_find_array", + input="hello", + find=["a"], + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceAll should reject array as find", + ), + ReplaceAllTest( + "type_find_binary", + input="hello", + find=Binary(b"data"), + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceAll should reject binary as find", + ), + ReplaceAllTest( + "type_find_bool", + input="hello", + find=True, + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceAll should reject bool as find", + ), + ReplaceAllTest( + "type_find_date", + input="hello", + find=datetime(2024, 1, 1, tzinfo=timezone.utc), + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceAll should reject date as find", + ), + ReplaceAllTest( + "type_find_decimal128", + input="hello", + find=DECIMAL128_ONE_AND_HALF, + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceAll should reject decimal128 as find", + ), + ReplaceAllTest( + "type_find_float", + input="hello", + find=3.14, + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceAll should reject float as find", + ), + ReplaceAllTest( + "type_find_int", + input="hello", + find=42, + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceAll should reject int as find", + ), + ReplaceAllTest( + "type_find_long", + input="hello", + find=Int64(42), + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceAll should reject long as find", + ), + ReplaceAllTest( + "type_find_maxkey", + input="hello", + find=MaxKey(), + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceAll should reject maxkey as find", + ), + ReplaceAllTest( + "type_find_minkey", + input="hello", + find=MinKey(), + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceAll should reject minkey as find", + ), + ReplaceAllTest( + "type_find_object", + input="hello", + find={"a": 1}, + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceAll should reject object as find", + ), + ReplaceAllTest( + "type_find_objectid", + input="hello", + find=ObjectId("507f1f77bcf86cd799439011"), + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceAll should reject objectid as find", + ), + ReplaceAllTest( + "type_find_regex", + input="hello", + find=Regex("pattern"), + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceAll should reject regex as find", + ), + ReplaceAllTest( + "type_find_empty_array", + input="hello", + find=[], + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceAll should reject empty array as find", + ), + ReplaceAllTest( + "type_find_multi_array", + input="hello", + find=["a", "b"], + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceAll should reject multi array as find", + ), + ReplaceAllTest( + "type_find_nested_array", + input="hello", + find=[["a"]], + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceAll should reject nested array as find", + ), + ReplaceAllTest( + "type_find_timestamp", + input="hello", + find=Timestamp(1, 1), + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceAll should reject timestamp as find", + ), + ReplaceAllTest( + "type_find_binary_uuid", + input="hello", + find=Binary(b"\x00" * 16, subtype=4), + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceAll should reject binary uuid as find", + ), + ReplaceAllTest( + "type_find_code", + input="hello", + find=Code("function(){}"), + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceAll should reject code as find", + ), + ReplaceAllTest( + "type_find_code_with_scope", + input="hello", + find=Code("function(){}", {"x": 1}), + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceAll should reject code with scope as find", + ), + # Invalid replacement types. + ReplaceAllTest( + "type_replacement_array", + input="hello", + find="a", + replacement=["a"], + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceAll should reject array as replacement", + ), + ReplaceAllTest( + "type_replacement_binary", + input="hello", + find="a", + replacement=Binary(b"data"), + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceAll should reject binary as replacement", + ), + ReplaceAllTest( + "type_replacement_bool", + input="hello", + find="a", + replacement=True, + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceAll should reject bool as replacement", + ), + ReplaceAllTest( + "type_replacement_date", + input="hello", + find="a", + replacement=datetime(2024, 1, 1, tzinfo=timezone.utc), + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceAll should reject date as replacement", + ), + ReplaceAllTest( + "type_replacement_decimal128", + input="hello", + find="a", + replacement=DECIMAL128_ONE_AND_HALF, + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceAll should reject decimal128 as replacement", + ), + ReplaceAllTest( + "type_replacement_float", + input="hello", + find="a", + replacement=3.14, + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceAll should reject float as replacement", + ), + ReplaceAllTest( + "type_replacement_int", + input="hello", + find="a", + replacement=42, + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceAll should reject int as replacement", + ), + ReplaceAllTest( + "type_replacement_long", + input="hello", + find="a", + replacement=Int64(42), + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceAll should reject long as replacement", + ), + ReplaceAllTest( + "type_replacement_maxkey", + input="hello", + find="a", + replacement=MaxKey(), + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceAll should reject maxkey as replacement", + ), + ReplaceAllTest( + "type_replacement_minkey", + input="hello", + find="a", + replacement=MinKey(), + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceAll should reject minkey as replacement", + ), + ReplaceAllTest( + "type_replacement_object", + input="hello", + find="a", + replacement={"a": 1}, + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceAll should reject object as replacement", + ), + ReplaceAllTest( + "type_replacement_objectid", + input="hello", + find="a", + replacement=ObjectId("507f1f77bcf86cd799439011"), + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceAll should reject objectid as replacement", + ), + ReplaceAllTest( + "type_replacement_regex", + input="hello", + find="a", + replacement=Regex("pattern"), + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceAll should reject regex as replacement", + ), + ReplaceAllTest( + "type_replacement_empty_array", + input="hello", + find="a", + replacement=[], + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceAll should reject empty array as replacement", + ), + ReplaceAllTest( + "type_replacement_multi_array", + input="hello", + find="a", + replacement=["a", "b"], + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceAll should reject multi array as replacement", + ), + ReplaceAllTest( + "type_replacement_nested_array", + input="hello", + find="a", + replacement=[["a"]], + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceAll should reject nested array as replacement", + ), + ReplaceAllTest( + "type_replacement_timestamp", + input="hello", + find="a", + replacement=Timestamp(1, 1), + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceAll should reject timestamp as replacement", + ), + ReplaceAllTest( + "type_replacement_binary_uuid", + input="hello", + find="a", + replacement=Binary(b"\x00" * 16, subtype=4), + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceAll should reject binary uuid as replacement", + ), + ReplaceAllTest( + "type_replacement_code", + input="hello", + find="a", + replacement=Code("function(){}"), + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceAll should reject code as replacement", + ), + ReplaceAllTest( + "type_replacement_code_with_scope", + input="hello", + find="a", + replacement=Code("function(){}", {"x": 1}), + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceAll should reject code with scope as replacement", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(REPLACEALL_TYPE_ERROR_TESTS)) +def test_replaceall_type_error_cases(collection, test_case: ReplaceAllTest): + """Test $replaceAll type error cases.""" + result = execute_expression(collection, _expr(test_case)) + assertResult( + 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/string/replaceAll/test_replaceAll_type_precedence.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_type_precedence.py new file mode 100644 index 00000000..0c241d13 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_type_precedence.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + REPLACE_FIND_TYPE_ERROR, + REPLACE_INPUT_TYPE_ERROR, + REPLACE_REPLACEMENT_TYPE_ERROR, +) +from documentdb_tests.framework.test_case import pytest_params +from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceAll.utils.replaceAll_common import ( + ReplaceAllTest, + _expr, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import execute_expression + +# Property [Type Error Precedence]: type errors take precedence over null propagation. When +# multiple arguments have type errors, input is validated first, then find, then replacement. +REPLACEALL_TYPE_PRECEDENCE_TESTS: list[ReplaceAllTest] = [ + # Type error on find takes precedence over null input. + ReplaceAllTest( + "precedence_null_input_type_find", + input=None, + find=123, + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceAll precedence: null input type find", + ), + # Type error on replacement takes precedence over null input. + ReplaceAllTest( + "precedence_null_input_type_replacement", + input=None, + find="a", + replacement=123, + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceAll precedence: null input type replacement", + ), + # Type error on replacement takes precedence over null find. + ReplaceAllTest( + "precedence_null_find_type_replacement", + input="hello", + find=None, + replacement=123, + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceAll precedence: null find type replacement", + ), + # Input type error takes precedence over find type error. + ReplaceAllTest( + "precedence_input_before_find", + input=123, + find=456, + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceAll precedence: input before find", + ), + # Input type error takes precedence over replacement type error. + ReplaceAllTest( + "precedence_input_before_replacement", + input=123, + find="a", + replacement=456, + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceAll precedence: input before replacement", + ), + # Find type error takes precedence over replacement type error. + ReplaceAllTest( + "precedence_find_before_replacement", + input="hello", + find=123, + replacement=456, + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceAll precedence: find before replacement", + ), + # All three have type errors: input wins. + ReplaceAllTest( + "precedence_all_type_errors", + input=123, + find=456, + replacement=789, + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceAll precedence: all type errors", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(REPLACEALL_TYPE_PRECEDENCE_TESTS)) +def test_replaceall_type_precedence_cases(collection, test_case: ReplaceAllTest): + """Test $replaceAll type error precedence cases.""" + result = execute_expression(collection, _expr(test_case)) + assertResult( + 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/string/replaceAll/utils/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/utils/replaceAll_common.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/utils/replaceAll_common.py new file mode 100644 index 00000000..295a4870 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/utils/replaceAll_common.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, cast + +from documentdb_tests.framework.test_case import BaseTestCase + + +@dataclass(frozen=True) +class ReplaceAllTest(BaseTestCase): + """Test case for $replaceAll operator.""" + + input: Any = None + find: Any = None + replacement: Any = None + expr: Any = None # Raw expression override for syntax tests + + +def _expr(test_case: ReplaceAllTest) -> dict[str, Any]: + if test_case.expr is not None: + return cast(dict[str, Any], test_case.expr) + return { + "$replaceAll": { + "input": test_case.input, + "find": test_case.find, + "replacement": test_case.replacement, + } + } diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_core.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_core.py new file mode 100644 index 00000000..f233974d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_core.py @@ -0,0 +1,377 @@ +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.test_case import pytest_params +from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceOne.utils.replaceOne_common import ( + ReplaceOneTest, + _expr, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import execute_expression + +# Property [Core Replacement]: only the first occurrence of find is replaced; +# subsequent occurrences are left unchanged. If no match exists, the input is +# returned unchanged. The result is not re-scanned for additional matches. +REPLACEONE_CORE_TESTS: list[ReplaceOneTest] = [ + ReplaceOneTest( + "core_first_occurrence_only", + input="cat bat cat", + find="cat", + replacement="dog", + expected="dog bat cat", + msg="$replaceOne should first occurrence only", + ), + ReplaceOneTest( + "core_repeated_single_char", + input="aaa", + find="a", + replacement="X", + expected="Xaa", + msg="$replaceOne should repeated single char", + ), + ReplaceOneTest( + "core_no_match", + input="hello world", + find="xyz", + replacement="abc", + expected="hello world", + msg="$replaceOne should no match", + ), + # Replacement contains the find pattern. No re-scanning should occur. + ReplaceOneTest( + "core_no_rescan", + input="ab", + find="a", + replacement="aa", + expected="aab", + msg="$replaceOne should no rescan", + ), + ReplaceOneTest( + "core_no_rescan_multiple", + input="xyx", + find="x", + replacement="xx", + expected="xxyx", + msg="$replaceOne should no rescan multiple", + ), + # Overlapping pattern: "aa" in "aaaa" replaces only first match at position 0. + ReplaceOneTest( + "core_overlapping_pattern", + input="aaaa", + find="aa", + replacement="X", + expected="Xaa", + msg="$replaceOne should overlapping pattern", + ), +] + +# Property [Empty String Behavior]: empty find inserts replacement at the +# beginning of input only, and empty replacement deletes the first occurrence. +REPLACEONE_EMPTY_STRING_TESTS: list[ReplaceOneTest] = [ + # Empty find on non-empty input prepends replacement. + ReplaceOneTest( + "empty_find_prepends", + input="hello", + find="", + replacement="X", + expected="Xhello", + msg="$replaceOne empty string: find prepends", + ), + # Empty find on empty input produces the replacement. + ReplaceOneTest( + "empty_find_empty_input", + input="", + find="", + replacement="X", + expected="X", + msg="$replaceOne empty string: find empty input", + ), + # Empty input with non-empty find produces empty string. + ReplaceOneTest( + "empty_input_no_match", + input="", + find="abc", + replacement="X", + expected="", + msg="$replaceOne empty string: input no match", + ), + # Empty replacement deletes the first occurrence. + ReplaceOneTest( + "empty_replacement_deletes", + input="hello world", + find="hello ", + replacement="", + expected="world", + msg="$replaceOne empty string: replacement deletes", + ), + ReplaceOneTest( + "empty_replacement_single_char", + input="aaa", + find="a", + replacement="", + expected="aa", + msg="$replaceOne empty string: replacement single char", + ), +] + +# Property [Case Sensitivity]: matching is case-sensitive for ASCII and +# non-ASCII scripts, with no case folding, ligature expansion, or +# locale-specific folding. +REPLACEONE_CASE_TESTS: list[ReplaceOneTest] = [ + ReplaceOneTest( + "case_lower_find_no_match", + input="Hello", + find="hello", + replacement="X", + expected="Hello", + msg="$replaceOne case sensitivity: lower find no match", + ), + ReplaceOneTest( + "case_upper_find_no_match", + input="hello", + find="Hello", + replacement="X", + expected="hello", + msg="$replaceOne case sensitivity: upper find no match", + ), + ReplaceOneTest( + "case_exact_match_first_only", + input="hello Hello hello", + find="Hello", + replacement="X", + expected="hello X hello", + msg="$replaceOne case sensitivity: exact match first only", + ), + # Greek: uppercase Σ (U+03A3) does not match lowercase σ (U+03C3). + ReplaceOneTest( + "case_greek_upper_sigma_no_match", + input="\u03a3\u03b1", + find="\u03c3\u03b1", + replacement="X", + expected="\u03a3\u03b1", + msg="$replaceOne case sensitivity: greek upper sigma no match", + ), + # Cyrillic: uppercase Б (U+0411) does not match lowercase б (U+0431). + ReplaceOneTest( + "case_cyrillic_upper_no_match", + input="\u0411\u043e\u0433", + find="\u0431\u043e\u0433", + replacement="X", + expected="\u0411\u043e\u0433", + msg="$replaceOne case sensitivity: cyrillic upper no match", + ), + # Latin extended: uppercase Ž (U+017D) does not match lowercase ž (U+017E). + ReplaceOneTest( + "case_latin_extended_no_match", + input="\u017divot", + find="\u017eivot", + replacement="X", + expected="\u017divot", + msg="$replaceOne case sensitivity: latin extended no match", + ), + # Deseret: uppercase 𐐀 (U+10400) does not match lowercase 𐐨 (U+10428). + ReplaceOneTest( + "case_deseret_no_match", + input="\U00010400", + find="\U00010428", + replacement="X", + expected="\U00010400", + msg="$replaceOne case sensitivity: deseret no match", + ), + # No case folding: German sharp s (ß) does not match "SS" or "ss". + ReplaceOneTest( + "case_sharp_s_no_match_upper_ss", + input="Stra\u00dfe", + find="SS", + replacement="X", + expected="Stra\u00dfe", + msg="$replaceOne case sensitivity: sharp s no match upper ss", + ), + ReplaceOneTest( + "case_sharp_s_no_match_lower_ss", + input="Stra\u00dfe", + find="ss", + replacement="X", + expected="Stra\u00dfe", + msg="$replaceOne case sensitivity: sharp s no match lower ss", + ), + # No ligature expansion: fi ligature (U+FB01) does not match "fi". + ReplaceOneTest( + "case_fi_ligature_no_match", + input="\ufb01sh", + find="fi", + replacement="X", + expected="\ufb01sh", + msg="$replaceOne case sensitivity: fi ligature no match", + ), + # No locale-specific folding: Turkish dotless i (U+0131) does not match "i" or "I". + ReplaceOneTest( + "case_turkish_dotless_i_no_match_lower", + input="\u0131stanbul", + find="i", + replacement="X", + expected="\u0131stanbul", + msg="$replaceOne case sensitivity: turkish dotless i no match lower", + ), + ReplaceOneTest( + "case_turkish_dotless_i_no_match_upper", + input="\u0131stanbul", + find="I", + replacement="X", + expected="\u0131stanbul", + msg="$replaceOne case sensitivity: turkish dotless i no match upper", + ), +] + + +# Property [Identity]: when find equals replacement, the result equals the +# input. An empty find with an empty replacement also leaves input unchanged. +REPLACEONE_IDENTITY_TESTS: list[ReplaceOneTest] = [ + ReplaceOneTest( + "identity_find_equals_replacement", + input="hello world", + find="world", + replacement="world", + expected="hello world", + msg="$replaceOne identity: find equals replacement", + ), + ReplaceOneTest( + "identity_single_char", + input="aaa", + find="a", + replacement="a", + expected="aaa", + msg="$replaceOne identity: single char", + ), + ReplaceOneTest( + "identity_full_match", + input="hello", + find="hello", + replacement="hello", + expected="hello", + msg="$replaceOne identity: full match", + ), + ReplaceOneTest( + "identity_empty_find_empty_replacement", + input="hello", + find="", + replacement="", + expected="hello", + msg="$replaceOne identity: empty find empty replacement", + ), + ReplaceOneTest( + "identity_all_empty", + input="", + find="", + replacement="", + expected="", + msg="$replaceOne identity: all empty", + ), +] + + +# Property [Edge Cases]: $replaceOne behaves correctly at boundary conditions. +REPLACEONE_EDGE_TESTS: list[ReplaceOneTest] = [ + # Find at the start of input. + ReplaceOneTest( + "edge_find_at_start", + input="hello world", + find="hello", + replacement="X", + expected="X world", + msg="$replaceOne edge: find at start", + ), + # Find at the middle of input. + ReplaceOneTest( + "edge_find_at_middle", + input="hello big world", + find="big", + replacement="X", + expected="hello X world", + msg="$replaceOne edge: find at middle", + ), + # Find at the end of input. + ReplaceOneTest( + "edge_find_at_end", + input="hello world", + find="world", + replacement="X", + expected="hello X", + msg="$replaceOne edge: find at end", + ), + # Find is a prefix of input. + ReplaceOneTest( + "edge_find_prefix", + input="hello", + find="hel", + replacement="X", + expected="Xlo", + msg="$replaceOne edge: find prefix", + ), + # Find is a suffix of input. + ReplaceOneTest( + "edge_find_suffix", + input="hello", + find="llo", + replacement="X", + expected="heX", + msg="$replaceOne edge: find suffix", + ), + # Find equals the entire input. + ReplaceOneTest( + "edge_find_equals_input", + input="hello", + find="hello", + replacement="X", + expected="X", + msg="$replaceOne edge: find equals input", + ), + # Find longer than input produces no match. + ReplaceOneTest( + "edge_find_longer_than_input", + input="hi", + find="hello", + replacement="X", + expected="hi", + msg="$replaceOne edge: find longer than input", + ), + # Replacement longer than the entire input. + ReplaceOneTest( + "edge_replacement_longer_than_input", + input="hi", + find="hi", + replacement="hello world", + expected="hello world", + msg="$replaceOne edge: replacement longer than input", + ), + # Replacement is the entire input string. + ReplaceOneTest( + "edge_replacement_is_input", + input="hello", + find="h", + replacement="hello", + expected="helloello", + msg="$replaceOne edge: replacement is input", + ), +] + +REPLACEONE_CORE_ALL_TESTS = ( + REPLACEONE_CORE_TESTS + + REPLACEONE_EMPTY_STRING_TESTS + + REPLACEONE_CASE_TESTS + + REPLACEONE_IDENTITY_TESTS + + REPLACEONE_EDGE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(REPLACEONE_CORE_ALL_TESTS)) +def test_replaceone_core_cases(collection, test_case: ReplaceOneTest): + """Test $replaceOne core replacement cases.""" + result = execute_expression(collection, _expr(test_case)) + assertResult( + 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/string/replaceOne/test_replaceOne_encoding.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_encoding.py new file mode 100644 index 00000000..f33832c3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_encoding.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.test_case import pytest_params +from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceOne.utils.replaceOne_common import ( + ReplaceOneTest, + _expr, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import execute_expression + +# Property [Encoding and Character Handling]: matching is diacritic-sensitive, +# no Unicode normalization is performed, multi-byte UTF-8 characters are +# handled correctly, and special/regex characters are treated literally. +REPLACEONE_ENCODING_TESTS: list[ReplaceOneTest] = [ + # Diacritic sensitivity. + ReplaceOneTest( + "encoding_diacritic_no_match", + input="café", + find="cafe", + replacement="X", + expected="café", + msg="$replaceOne should handle diacritic no match", + ), + ReplaceOneTest( + "encoding_diaeresis_no_match", + input="naïve", + find="naive", + replacement="X", + expected="naïve", + msg="$replaceOne should handle diaeresis no match", + ), + ReplaceOneTest( + "encoding_diacritic_match_first", + input="résumé", + find="é", + replacement="e", + expected="resumé", + msg="$replaceOne should handle diacritic match first", + ), + # Precomposed U+00E9 does not match decomposed U+0065 U+0301. + ReplaceOneTest( + "encoding_precomposed_vs_decomposed", + input="\u00e9", + find="e\u0301", + replacement="X", + expected="\u00e9", + msg="$replaceOne should handle precomposed vs decomposed", + ), + ReplaceOneTest( + "encoding_decomposed_vs_precomposed", + input="e\u0301", + find="\u00e9", + replacement="X", + expected="e\u0301", + msg="$replaceOne should handle decomposed vs precomposed", + ), + # Same representation matches. + ReplaceOneTest( + "encoding_precomposed_matches_precomposed", + input="\u00e9", + find="\u00e9", + replacement="X", + expected="X", + msg="$replaceOne should handle precomposed matches precomposed", + ), + ReplaceOneTest( + "encoding_decomposed_matches_decomposed", + input="e\u0301", + find="e\u0301", + replacement="X", + expected="X", + msg="$replaceOne should handle decomposed matches decomposed", + ), + # A base character can be matched independently of a following combining mark. + ReplaceOneTest( + "encoding_base_char_splits_combining_mark", + input="e\u0301", + find="e", + replacement="X", + expected="X\u0301", + msg="$replaceOne should handle base char splits combining mark", + ), + # Multi-byte UTF-8: 2-byte (U+00E9), 3-byte (U+4E16), 4-byte (U+1F600). + ReplaceOneTest( + "encoding_2byte_find", + input="café", + find="é", + replacement="e", + expected="cafe", + msg="$replaceOne should handle 2byte find", + ), + ReplaceOneTest( + "encoding_3byte_find", + input="hello世界", + find="世", + replacement="X", + expected="helloX界", + msg="$replaceOne should handle 3byte find", + ), + ReplaceOneTest( + "encoding_4byte_find", + input="a😀b😀c", + find="😀", + replacement="X", + expected="aXb😀c", + msg="$replaceOne should handle 4byte find", + ), + ReplaceOneTest( + "encoding_4byte_replacement", + input="hello", + find="h", + replacement="😀", + expected="😀ello", + msg="$replaceOne should handle 4byte replacement", + ), +] + +# Property [Unicode and Encoding]: no Unicode normalization is performed, +# combining marks are independently matchable as regular code points, and +# multi-byte UTF-8 characters of all widths work correctly in all parameter +# positions including mixed byte-width strings. +REPLACEONE_UNICODE_TESTS: list[ReplaceOneTest] = [ + # Combining mark alone (U+0301 combining acute accent) is findable. + ReplaceOneTest( + "unicode_combining_mark_find", + input="e\u0301", + find="\u0301", + replacement="X", + expected="eX", + msg="$replaceOne unicode combining mark find", + ), + # Combining mark alone (U+0303 combining tilde) as replacement. + ReplaceOneTest( + "unicode_combining_mark_replacement", + input="nX", + find="X", + replacement="\u0303", + expected="n\u0303", + msg="$replaceOne unicode combining mark replacement", + ), + # Combining mark deleted by empty replacement. + ReplaceOneTest( + "unicode_combining_mark_delete", + input="e\u0301", + find="\u0301", + replacement="", + expected="e", + msg="$replaceOne unicode combining mark delete", + ), + # 2-byte character (U+00E9) in replacement position. + ReplaceOneTest( + "unicode_2byte_replacement", + input="cafe", + find="e", + replacement="\u00e9", + expected="caf\u00e9", + msg="$replaceOne unicode 2byte replacement", + ), + # 3-byte character (U+4E16) in replacement position. + ReplaceOneTest( + "unicode_3byte_replacement", + input="helloX", + find="X", + replacement="\u4e16", + expected="hello\u4e16", + msg="$replaceOne unicode 3byte replacement", + ), + # 4-byte character in all three positions simultaneously. + ReplaceOneTest( + "unicode_4byte_all_positions", + input="a\U0001f600b", + find="\U0001f600", + replacement="\U0001f680", + expected="a\U0001f680b", + msg="$replaceOne unicode 4byte all positions", + ), + # Mixed byte-width string: 1-byte (a), 2-byte (U+00E9), 3-byte (U+4E16), + # 4-byte (U+1F600). Find 3-byte char. + ReplaceOneTest( + "unicode_mixed_find_3byte", + input="a\u00e9\u4e16\U0001f600", + find="\u4e16", + replacement="X", + expected="a\u00e9X\U0001f600", + msg="$replaceOne unicode mixed find 3byte", + ), + # Mixed byte-width: find 4-byte char. + ReplaceOneTest( + "unicode_mixed_find_4byte", + input="a\u00e9\u4e16\U0001f600", + find="\U0001f600", + replacement="Y", + expected="a\u00e9\u4e16Y", + msg="$replaceOne unicode mixed find 4byte", + ), + # Mixed byte-width: find 2-byte char. + ReplaceOneTest( + "unicode_mixed_find_2byte", + input="a\u00e9\u4e16\U0001f600", + find="\u00e9", + replacement="Z", + expected="aZ\u4e16\U0001f600", + msg="$replaceOne unicode mixed find 2byte", + ), +] + +REPLACEONE_ENCODING_ALL_TESTS = REPLACEONE_ENCODING_TESTS + REPLACEONE_UNICODE_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(REPLACEONE_ENCODING_ALL_TESTS)) +def test_replaceone_encoding_cases(collection, test_case: ReplaceOneTest): + """Test $replaceOne encoding and unicode cases.""" + result = execute_expression(collection, _expr(test_case)) + assertResult( + 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/string/replaceOne/test_replaceOne_invalid_args.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_invalid_args.py new file mode 100644 index 00000000..461ebb16 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_invalid_args.py @@ -0,0 +1,400 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + FAILED_TO_PARSE_ERROR, + INVALID_DOLLAR_FIELD_PATH, + REPLACE_FIND_TYPE_ERROR, + REPLACE_INPUT_TYPE_ERROR, + REPLACE_MISSING_FIND_ERROR, + REPLACE_MISSING_INPUT_ERROR, + REPLACE_MISSING_REPLACEMENT_ERROR, + REPLACE_NON_OBJECT_ERROR, + REPLACE_REPLACEMENT_TYPE_ERROR, + REPLACE_UNKNOWN_FIELD_ERROR, +) +from documentdb_tests.framework.test_case import pytest_params +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF, MISSING +from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceOne.utils.replaceOne_common import ( + ReplaceOneTest, + _expr, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import execute_expression + +# Property [Type Error Precedence]: type errors take precedence over null +# propagation. When multiple arguments have type errors, input is validated +# first, then find, then replacement. +REPLACEONE_TYPE_PRECEDENCE_TESTS: list[ReplaceOneTest] = [ + # Type error on find takes precedence over null input. + ReplaceOneTest( + "precedence_null_input_type_find", + input=None, + find=123, + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceOne precedence: null input type find", + ), + # Type error on replacement takes precedence over null input. + ReplaceOneTest( + "precedence_null_input_type_replacement", + input=None, + find="a", + replacement=123, + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceOne precedence: null input type replacement", + ), + # Type error on replacement takes precedence over null find. + ReplaceOneTest( + "precedence_null_find_type_replacement", + input="hello", + find=None, + replacement=123, + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceOne precedence: null find type replacement", + ), + # Input type error takes precedence over find type error. + ReplaceOneTest( + "precedence_input_before_find", + input=123, + find=456, + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceOne precedence: input before find", + ), + # Input type error takes precedence over replacement type error. + ReplaceOneTest( + "precedence_input_before_replacement", + input=123, + find="a", + replacement=456, + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceOne precedence: input before replacement", + ), + # Find type error takes precedence over replacement type error. + ReplaceOneTest( + "precedence_find_before_replacement", + input="hello", + find=123, + replacement=456, + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceOne precedence: find before replacement", + ), + # All three have type errors: input wins. + ReplaceOneTest( + "precedence_all_type_errors", + input=123, + find=456, + replacement=789, + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceOne precedence: all type errors", + ), + # Missing input does not shield type error on find. + ReplaceOneTest( + "precedence_missing_input_type_find", + input=MISSING, + find=123, + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceOne precedence: missing input type find", + ), + # Missing input does not shield type error on replacement. + ReplaceOneTest( + "precedence_missing_input_type_replacement", + input=MISSING, + find="a", + replacement=123, + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceOne precedence: missing input type replacement", + ), + # Missing find does not shield type error on replacement. + ReplaceOneTest( + "precedence_missing_find_type_replacement", + input="hello", + find=MISSING, + replacement=123, + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceOne precedence: missing find type replacement", + ), +] + +# Property [Bare Dollar Sign]: a bare "$" string in any parameter position is +# interpreted as a field path, producing INVALID_DOLLAR_FIELD_PATH. +REPLACEONE_BARE_DOLLAR_TESTS: list[ReplaceOneTest] = [ + ReplaceOneTest( + "bare_dollar_input", + input="$", + find="a", + replacement="b", + error_code=INVALID_DOLLAR_FIELD_PATH, + msg="$replaceOne should reject bare '$' in input", + ), + ReplaceOneTest( + "bare_dollar_find", + input="hello", + find="$", + replacement="b", + error_code=INVALID_DOLLAR_FIELD_PATH, + msg="$replaceOne should reject bare '$' in find", + ), + ReplaceOneTest( + "bare_dollar_replacement", + input="hello", + find="a", + replacement="$", + error_code=INVALID_DOLLAR_FIELD_PATH, + msg="$replaceOne should reject bare '$' in replacement", + ), +] + +# Property [Double Dollar Sign]: a "$$" string in any parameter position is interpreted as a +# variable reference with an empty name, producing FAILED_TO_PARSE_ERROR. +REPLACEONE_DOUBLE_DOLLAR_TESTS: list[ReplaceOneTest] = [ + ReplaceOneTest( + "double_dollar_input", + input="$$", + find="a", + replacement="b", + error_code=FAILED_TO_PARSE_ERROR, + msg="$replaceOne should reject '$$' in input", + ), + ReplaceOneTest( + "double_dollar_find", + input="hello", + find="$$", + replacement="b", + error_code=FAILED_TO_PARSE_ERROR, + msg="$replaceOne should reject '$$' in find", + ), + ReplaceOneTest( + "double_dollar_replacement", + input="hello", + find="a", + replacement="$$", + error_code=FAILED_TO_PARSE_ERROR, + msg="$replaceOne should reject '$$' in replacement", + ), +] + + +# Property [Syntax Validation - Non-Object]: a non-object argument to +# $replaceOne produces REPLACE_NON_OBJECT_ERROR. +REPLACEONE_NON_OBJECT_TESTS: list[ReplaceOneTest] = [ + ReplaceOneTest( + "syntax_string", + expr={"$replaceOne": "hello"}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceOne should reject string", + ), + ReplaceOneTest( + "syntax_int", + expr={"$replaceOne": 42}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceOne should reject int", + ), + ReplaceOneTest( + "syntax_float", + expr={"$replaceOne": 3.14}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceOne should reject float", + ), + ReplaceOneTest( + "syntax_long", + expr={"$replaceOne": Int64(42)}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceOne should reject long", + ), + ReplaceOneTest( + "syntax_decimal128", + expr={"$replaceOne": DECIMAL128_ONE_AND_HALF}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceOne should reject decimal128", + ), + ReplaceOneTest( + "syntax_null", + expr={"$replaceOne": None}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceOne should reject null", + ), + ReplaceOneTest( + "syntax_bool", + expr={"$replaceOne": True}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceOne should reject bool", + ), + ReplaceOneTest( + "syntax_array", + expr={"$replaceOne": ["a", "b", "c"]}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceOne should reject array", + ), + ReplaceOneTest( + "syntax_binary", + expr={"$replaceOne": Binary(b"data")}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceOne should reject binary", + ), + ReplaceOneTest( + "syntax_date", + expr={"$replaceOne": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceOne should reject date", + ), + ReplaceOneTest( + "syntax_objectid", + expr={"$replaceOne": ObjectId()}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceOne should reject objectid", + ), + ReplaceOneTest( + "syntax_regex", + expr={"$replaceOne": Regex("pattern")}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceOne should reject regex", + ), + ReplaceOneTest( + "syntax_timestamp", + expr={"$replaceOne": Timestamp(1, 1)}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceOne should reject timestamp", + ), + ReplaceOneTest( + "syntax_minkey", + expr={"$replaceOne": MinKey()}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceOne should reject minkey", + ), + ReplaceOneTest( + "syntax_maxkey", + expr={"$replaceOne": MaxKey()}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceOne should reject maxkey", + ), + ReplaceOneTest( + "syntax_code", + expr={"$replaceOne": Code("function() {}")}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceOne should reject code", + ), + ReplaceOneTest( + "syntax_code_scope", + expr={"$replaceOne": Code("function() {}", {"x": 1})}, + error_code=REPLACE_NON_OBJECT_ERROR, + msg="$replaceOne should reject code scope", + ), +] + +# Property [Syntax Validation - Missing and Unknown Fields]: omitting required +# fields or including unknown fields produces a specific error, with precedence +# non-object > unknown field > missing input > missing find > missing +# replacement > type errors. +REPLACEONE_FIELD_VALIDATION_TESTS: list[ReplaceOneTest] = [ + ReplaceOneTest( + "syntax_missing_input", + expr={"$replaceOne": {"find": "a", "replacement": "b"}}, + error_code=REPLACE_MISSING_INPUT_ERROR, + msg="$replaceOne should reject missing input", + ), + ReplaceOneTest( + "syntax_missing_find", + expr={"$replaceOne": {"input": "hello", "replacement": "b"}}, + error_code=REPLACE_MISSING_FIND_ERROR, + msg="$replaceOne should reject missing find", + ), + ReplaceOneTest( + "syntax_missing_replacement", + expr={"$replaceOne": {"input": "hello", "find": "a"}}, + error_code=REPLACE_MISSING_REPLACEMENT_ERROR, + msg="$replaceOne should reject missing replacement", + ), + # Empty object produces missing input error. + ReplaceOneTest( + "syntax_empty_object", + expr={"$replaceOne": {}}, + error_code=REPLACE_MISSING_INPUT_ERROR, + msg="$replaceOne should reject empty object", + ), + # Unknown field. + ReplaceOneTest( + "syntax_unknown_field", + expr={"$replaceOne": {"input": "hello", "find": "a", "replacement": "b", "extra": 1}}, + error_code=REPLACE_UNKNOWN_FIELD_ERROR, + msg="$replaceOne should reject unknown field", + ), + # Case-sensitive field names are treated as unknown. + ReplaceOneTest( + "syntax_case_sensitive_input", + expr={"$replaceOne": {"Input": "hello", "find": "a", "replacement": "b"}}, + error_code=REPLACE_UNKNOWN_FIELD_ERROR, + msg="$replaceOne should reject case sensitive input", + ), + ReplaceOneTest( + "syntax_case_sensitive_find", + expr={"$replaceOne": {"input": "hello", "Find": "a", "replacement": "b"}}, + error_code=REPLACE_UNKNOWN_FIELD_ERROR, + msg="$replaceOne should reject case sensitive find", + ), + ReplaceOneTest( + "syntax_case_sensitive_replacement", + expr={"$replaceOne": {"input": "hello", "find": "a", "Replacement": "b"}}, + error_code=REPLACE_UNKNOWN_FIELD_ERROR, + msg="$replaceOne should reject case sensitive replacement", + ), + # Missing field precedence: input > find > replacement. + ReplaceOneTest( + "syntax_missing_input_and_find", + expr={"$replaceOne": {"replacement": "b"}}, + error_code=REPLACE_MISSING_INPUT_ERROR, + msg="$replaceOne should reject missing input and find", + ), + ReplaceOneTest( + "syntax_missing_input_and_replacement", + expr={"$replaceOne": {"find": "a"}}, + error_code=REPLACE_MISSING_INPUT_ERROR, + msg="$replaceOne should reject missing input and replacement", + ), + ReplaceOneTest( + "syntax_missing_find_and_replacement", + expr={"$replaceOne": {"input": "hello"}}, + error_code=REPLACE_MISSING_FIND_ERROR, + msg="$replaceOne should reject missing find and replacement", + ), + # Unknown field takes precedence over missing fields. + ReplaceOneTest( + "syntax_unknown_precedes_missing", + expr={"$replaceOne": {"extra": 1}}, + error_code=REPLACE_UNKNOWN_FIELD_ERROR, + msg="$replaceOne should reject unknown precedes missing", + ), + # Unknown field takes precedence over type errors. + ReplaceOneTest( + "syntax_unknown_precedes_type_error", + expr={"$replaceOne": {"input": 123, "find": "a", "replacement": "b", "extra": 1}}, + error_code=REPLACE_UNKNOWN_FIELD_ERROR, + msg="$replaceOne should reject unknown precedes type error", + ), +] + +REPLACEONE_INVALID_ARGS_ALL_TESTS = ( + REPLACEONE_TYPE_PRECEDENCE_TESTS + + REPLACEONE_BARE_DOLLAR_TESTS + + REPLACEONE_DOUBLE_DOLLAR_TESTS + + REPLACEONE_NON_OBJECT_TESTS + + REPLACEONE_FIELD_VALIDATION_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(REPLACEONE_INVALID_ARGS_ALL_TESTS)) +def test_replaceone_invalid_args_cases(collection, test_case: ReplaceOneTest): + """Test $replaceOne invalid argument cases.""" + result = execute_expression(collection, _expr(test_case)) + assertResult( + 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/string/replaceOne/test_replaceOne_invariants.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_invariants.py new file mode 100644 index 00000000..4406586a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_invariants.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.test_case import pytest_params +from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceOne.utils.replaceOne_common import ( + ReplaceOneTest, + _expr, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + execute_expression, + execute_project, +) + +# Property [Length Invariant]: when a match is found, result length equals +# len(input) - len(find) + len(replacement). When no match is found, result +# length equals len(input). +REPLACEONE_LENGTH_TESTS: list[ReplaceOneTest] = [ + ReplaceOneTest( + "length_match_same_size", + input="hello", + find="h", + replacement="j", + msg="$replaceOne length invariant: match same size", + ), + ReplaceOneTest( + "length_match_longer_replacement", + input="hello", + find="h", + replacement="xyz", + msg="$replaceOne length invariant: match longer replacement", + ), + ReplaceOneTest( + "length_match_shorter_replacement", + input="hello", + find="hel", + replacement="x", + msg="$replaceOne length invariant: match shorter replacement", + ), + ReplaceOneTest( + "length_match_empty_replacement", + input="hello", + find="hello", + replacement="", + msg="$replaceOne length invariant: match empty replacement", + ), + ReplaceOneTest( + "length_empty_find_prepend", + input="hello", + find="", + replacement="abc", + msg="$replaceOne length invariant: empty find prepend", + ), + ReplaceOneTest( + "length_no_match", + input="hello", + find="xyz", + replacement="abc", + msg="$replaceOne length invariant: no match", + ), + ReplaceOneTest( + "length_multibyte", + input="café", + find="é", + replacement="ee", + msg="$replaceOne length invariant: multibyte", + ), + ReplaceOneTest( + "length_4byte_to_1byte", + input="a😀b", + find="😀", + replacement="X", + msg="$replaceOne length invariant: 4byte to 1byte", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(REPLACEONE_LENGTH_TESTS)) +def test_replaceone_length_invariant(collection, test_case: ReplaceOneTest): + """Test $replaceOne result length invariant.""" + expr = _expr(test_case) + input_len = {"$strLenCP": test_case.input} + find_len = {"$strLenCP": test_case.find} + replacement_len = {"$strLenCP": test_case.replacement} + matched = {"$gte": [{"$indexOfCP": [test_case.input, test_case.find]}, 0]} + expected_len = { + "$add": [ + input_len, + { + "$cond": { + "if": matched, + "then": {"$subtract": [replacement_len, find_len]}, + "else": 0, + } + }, + ] + } + result = execute_project( + collection, + {"lengthMatch": {"$eq": [{"$strLenCP": expr}, expected_len]}}, + ) + assertSuccess(result, [{"lengthMatch": True}], msg=test_case.msg) + + +# Property [Return Type]: the result is always a string when all arguments are +# non-null strings. +REPLACEONE_RETURN_TYPE_TESTS: list[ReplaceOneTest] = [ + ReplaceOneTest( + "return_type_match", + input="hello", + find="h", + replacement="j", + msg="$replaceOne should return string for match", + ), + ReplaceOneTest( + "return_type_no_match", + input="hello", + find="x", + replacement="y", + msg="$replaceOne should return string for no match", + ), + ReplaceOneTest( + "return_type_all_empty", + input="", + find="", + replacement="", + msg="$replaceOne should return string for all empty", + ), + ReplaceOneTest( + "return_type_unicode", + input="café", + find="é", + replacement="e", + msg="$replaceOne should return string for unicode", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(REPLACEONE_RETURN_TYPE_TESTS)) +def test_replaceone_return_type(collection, test_case: ReplaceOneTest): + """Test $replaceOne result is always type string.""" + result = execute_expression(collection, {"$type": _expr(test_case)}) + assertSuccess(result, [{"result": "string"}], msg=test_case.msg) + + +# Property [Non-Idempotency]: applying $replaceOne twice replaces successive occurrences, +# producing a different result than applying it once when multiple matches exist. +def test_replaceone_successive_application(collection): + """Test $replaceOne replaces successive occurrences on repeated application.""" + once = {"$replaceOne": {"input": "cat bat cat", "find": "cat", "replacement": "dog"}} + twice = {"$replaceOne": {"input": once, "find": "cat", "replacement": "dog"}} + result = execute_project(collection, {"once": once, "twice": twice}) + assertSuccess( + result, + [{"once": "dog bat cat", "twice": "dog bat dog"}], + msg="$replaceOne applied twice should replace successive occurrences", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_null.py new file mode 100644 index 00000000..16150962 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_null.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.test_case import pytest_params +from documentdb_tests.framework.test_constants import MISSING +from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceOne.utils.replaceOne_common import ( + ReplaceOneTest, + _expr, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import execute_expression + +# Property [Null Propagation]: if any argument is null or missing, the result +# is null, uniformly across all three argument positions. +_PLACEHOLDER = object() +_NULL_PATTERNS: list[tuple[Any, Any, Any, str]] = [ + (_PLACEHOLDER, "a", "b", "input"), + ("hello", _PLACEHOLDER, "b", "find"), + ("hello", "a", _PLACEHOLDER, "replacement"), + (_PLACEHOLDER, _PLACEHOLDER, "b", "input_and_find"), + (_PLACEHOLDER, "a", _PLACEHOLDER, "input_and_replacement"), + ("hello", _PLACEHOLDER, _PLACEHOLDER, "find_and_replacement"), + (_PLACEHOLDER, _PLACEHOLDER, _PLACEHOLDER, "all"), +] + + +def _build_null_tests(null_value, prefix) -> list[ReplaceOneTest]: + return [ + ReplaceOneTest( + f"{prefix}_{suffix}", + input=null_value if i is _PLACEHOLDER else i, + find=null_value if f is _PLACEHOLDER else f, + replacement=null_value if r is _PLACEHOLDER else r, + expected=None, + msg=f"$replaceOne {prefix} {suffix}", + ) + for i, f, r, suffix in _NULL_PATTERNS + ] + + +REPLACEONE_NULL_TESTS = _build_null_tests(None, "null") +REPLACEONE_MISSING_TESTS = _build_null_tests(MISSING, "missing") + +REPLACEONE_MIXED_NULL_TESTS: list[ReplaceOneTest] = [ + ReplaceOneTest( + "null_input_missing_find", + input=None, + find=MISSING, + replacement="b", + expected=None, + msg="$replaceOne should return null for null input missing find", + ), + ReplaceOneTest( + "missing_input_null_find", + input=MISSING, + find=None, + replacement="b", + expected=None, + msg="$replaceOne should return null for missing input null find", + ), + ReplaceOneTest( + "null_find_missing_replacement", + input="hello", + find=None, + replacement=MISSING, + expected=None, + msg="$replaceOne should return null for null find missing replacement", + ), + ReplaceOneTest( + "missing_find_null_replacement", + input="hello", + find=MISSING, + replacement=None, + expected=None, + msg="$replaceOne should return null for missing find null replacement", + ), + ReplaceOneTest( + "null_input_missing_replacement", + input=None, + find="a", + replacement=MISSING, + expected=None, + msg="$replaceOne should return null for null input missing replacement", + ), + ReplaceOneTest( + "missing_input_null_replacement", + input=MISSING, + find="a", + replacement=None, + expected=None, + msg="$replaceOne should return null for missing input null replacement", + ), +] + +REPLACEONE_NULL_ALL_TESTS = ( + REPLACEONE_NULL_TESTS + REPLACEONE_MISSING_TESTS + REPLACEONE_MIXED_NULL_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(REPLACEONE_NULL_ALL_TESTS)) +def test_replaceone_null_cases(collection, test_case: ReplaceOneTest): + """Test $replaceOne null propagation cases.""" + result = execute_expression(collection, _expr(test_case)) + assertResult( + 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/string/replaceOne/test_replaceOne_size_limit.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_size_limit.py new file mode 100644 index 00000000..87f11c3e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_size_limit.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import STRING_SIZE_LIMIT_ERROR +from documentdb_tests.framework.test_case import pytest_params +from documentdb_tests.framework.test_constants import STRING_SIZE_LIMIT_BYTES +from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceOne.utils.replaceOne_common import ( + ReplaceOneTest, + _expr, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import execute_expression + +# Property [String Size Limit - Success]: strings one byte under the limit are +# accepted in all parameter positions, and a shrinking replacement succeeds +# when the input itself is under the limit. +REPLACEONE_SIZE_LIMIT_SUCCESS_TESTS: list[ReplaceOneTest] = [ + ReplaceOneTest( + "size_success_input_max", + input="a" * (STRING_SIZE_LIMIT_BYTES - 1), + find="xyz", + replacement="abc", + expected="a" * (STRING_SIZE_LIMIT_BYTES - 1), + msg="$replaceOne size limit: success input max", + ), + # Large input with a shrinking replacement stays under the limit. + ReplaceOneTest( + "size_success_shrinking_replacement", + input="a" * (STRING_SIZE_LIMIT_BYTES - 1), + find="a", + replacement="", + expected="a" * (STRING_SIZE_LIMIT_BYTES - 2), + msg="$replaceOne size limit: success shrinking replacement", + ), + ReplaceOneTest( + "size_success_find_max", + input="hello", + find="a" * (STRING_SIZE_LIMIT_BYTES - 1), + replacement="X", + expected="hello", + msg="$replaceOne size limit: success find max", + ), + # Replace at start of a near-limit string. + ReplaceOneTest( + "size_find_at_start", + input="X" + "a" * (STRING_SIZE_LIMIT_BYTES - 2), + find="X", + replacement="Y", + expected="Y" + "a" * (STRING_SIZE_LIMIT_BYTES - 2), + msg="$replaceOne size limit: find at start", + ), + # Replace at end of a near-limit string. + ReplaceOneTest( + "size_find_at_end", + input="a" * (STRING_SIZE_LIMIT_BYTES - 2) + "X", + find="X", + replacement="Y", + expected="a" * (STRING_SIZE_LIMIT_BYTES - 2) + "Y", + msg="$replaceOne size limit: find at end", + ), + # Replace in middle of a near-limit string. + ReplaceOneTest( + "size_find_in_middle", + input="a" * ((STRING_SIZE_LIMIT_BYTES - 2) // 2) + + "X" + + "a" * ((STRING_SIZE_LIMIT_BYTES - 2) // 2), + find="X", + replacement="Y", + expected="a" * ((STRING_SIZE_LIMIT_BYTES - 2) // 2) + + "Y" + + "a" * ((STRING_SIZE_LIMIT_BYTES - 2) // 2), + msg="$replaceOne size limit: find in middle", + ), + # Large find string replaces entire input. Both at half-limit because two + # near-limit strings in one command would exceed the BSON document size. + ReplaceOneTest( + "size_large_find", + input="a" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2), + find="a" * ((STRING_SIZE_LIMIT_BYTES - 1) // 2), + replacement="X", + expected="X", + msg="$replaceOne size limit: large find", + ), + # Large replacement string. + ReplaceOneTest( + "size_large_replacement", + input="X", + find="X", + replacement="a" * (STRING_SIZE_LIMIT_BYTES - 1), + expected="a" * (STRING_SIZE_LIMIT_BYTES - 1), + msg="$replaceOne size limit: large replacement", + ), +] + + +# Property [String Size Limit - Errors]: strings at the size limit produce an +# error, enforced per literal and per expression result, not only on the final +# output. +REPLACEONE_SIZE_LIMIT_ERROR_TESTS: list[ReplaceOneTest] = [ + # Exactly at the limit. + ReplaceOneTest( + "size_error_input_at_limit", + input="a" * STRING_SIZE_LIMIT_BYTES, + find="a", + replacement="b", + error_code=STRING_SIZE_LIMIT_ERROR, + msg="$replaceOne size limit: error input at limit", + ), + # 2-byte chars at the limit. + ReplaceOneTest( + "size_error_byte_based_2byte", + input="\u00e9" * (STRING_SIZE_LIMIT_BYTES // 2), + find="\u00e9", + replacement="e", + error_code=STRING_SIZE_LIMIT_ERROR, + msg="$replaceOne size limit: error byte based 2byte", + ), + # 4-byte chars at the limit. + ReplaceOneTest( + "size_error_byte_based_4byte", + input="\U0001f600" * (STRING_SIZE_LIMIT_BYTES // 4), + find="\U0001f600", + replacement="x", + error_code=STRING_SIZE_LIMIT_ERROR, + msg="$replaceOne size limit: error byte based 4byte", + ), + # Input literal rejected even when replacement would shrink the result. + ReplaceOneTest( + "size_error_input_shrinking_rejected", + input="a" * STRING_SIZE_LIMIT_BYTES, + find="a" * 100, + replacement="", + error_code=STRING_SIZE_LIMIT_ERROR, + msg="$replaceOne size limit: error input shrinking rejected", + ), + # Result amplification: input under limit, replacement grows result to limit. + ReplaceOneTest( + "size_error_result_amplification", + input="a" * (STRING_SIZE_LIMIT_BYTES - 1), + find="a", + replacement="aa", + error_code=STRING_SIZE_LIMIT_ERROR, + msg="$replaceOne size limit: error result amplification", + ), + # Find parameter at the limit. + ReplaceOneTest( + "size_error_find_at_limit", + input="hello", + find="a" * STRING_SIZE_LIMIT_BYTES, + replacement="b", + error_code=STRING_SIZE_LIMIT_ERROR, + msg="$replaceOne size limit: error find at limit", + ), +] + +REPLACEONE_SIZE_LIMIT_ALL_TESTS = ( + REPLACEONE_SIZE_LIMIT_SUCCESS_TESTS + REPLACEONE_SIZE_LIMIT_ERROR_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(REPLACEONE_SIZE_LIMIT_ALL_TESTS)) +def test_replaceone_size_limit_cases(collection, test_case: ReplaceOneTest): + """Test $replaceOne size limit cases.""" + result = execute_expression(collection, _expr(test_case)) + assertResult( + 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/string/replaceOne/test_replaceOne_special_chars.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_special_chars.py new file mode 100644 index 00000000..c778065a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_special_chars.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.test_case import pytest_params +from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceOne.utils.replaceOne_common import ( + ReplaceOneTest, + _expr, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import execute_expression + +# Property [Special Characters]: null bytes, control characters, punctuation, +# braces, brackets, quotes, and regex-special characters are treated as literal +# string content. Dollar signs must be passed via $literal. +REPLACEONE_SPECIAL_CHAR_TESTS: list[ReplaceOneTest] = [ + # Control character U+0001 (SOH) in input. + ReplaceOneTest( + "special_control_soh", + input="a\x01b", + find="\x01", + replacement="X", + expected="aXb", + msg="$replaceOne special control soh", + ), + # Control character U+001F (US) in input. + ReplaceOneTest( + "special_control_us", + input="a\x1fb", + find="\x1f", + replacement="X", + expected="aXb", + msg="$replaceOne special control us", + ), + # Control character in find position. + ReplaceOneTest( + "special_control_find_no_match", + input="hello", + find="\x01", + replacement="X", + expected="hello", + msg="$replaceOne special control find no match", + ), + # Control character in replacement position. + ReplaceOneTest( + "special_control_replacement", + input="aXb", + find="X", + replacement="\x01", + expected="a\x01b", + msg="$replaceOne special control replacement", + ), + # Null byte (U+0000) treated as regular string content. + ReplaceOneTest( + "special_null_byte", + input="a\x00b", + find="\x00", + replacement="X", + expected="aXb", + msg="$replaceOne special null byte", + ), + # Null byte in replacement position. + ReplaceOneTest( + "special_null_byte_replacement", + input="aXb", + find="X", + replacement="\x00", + expected="a\x00b", + msg="$replaceOne special null byte replacement", + ), + # Braces treated as literal data. + ReplaceOneTest( + "special_braces", + input="a{b}c", + find="{b}", + replacement="X", + expected="aXc", + msg="$replaceOne special braces", + ), + # Brackets treated as literal data. + ReplaceOneTest( + "special_brackets", + input="a[b]c", + find="[b]", + replacement="X", + expected="aXc", + msg="$replaceOne special brackets", + ), + # Double quotes treated as literal data. + ReplaceOneTest( + "special_double_quotes", + input='a"b"c', + find='"b"', + replacement="X", + expected="aXc", + msg="$replaceOne special double quotes", + ), + # Single quotes treated as literal data. + ReplaceOneTest( + "special_single_quotes", + input="a'b'c", + find="'b'", + replacement="X", + expected="aXc", + msg="$replaceOne special single quotes", + ), + # General punctuation treated as literal data. + ReplaceOneTest( + "special_punctuation", + input="a!@#%&b", + find="!@#%&", + replacement="X", + expected="aXb", + msg="$replaceOne special punctuation", + ), + # Backslash treated as literal data. + ReplaceOneTest( + "special_backslash", + input="a\\b", + find="\\", + replacement="X", + expected="aXb", + msg="$replaceOne special backslash", + ), + # Backslash in replacement position. + ReplaceOneTest( + "special_backslash_replacement", + input="hello", + find="h", + replacement="\\", + expected="\\ello", + msg="$replaceOne special backslash replacement", + ), + # Backslash in both input and find. + ReplaceOneTest( + "special_backslash_in_input_and_find", + input="a\\b", + find="a\\b", + replacement="X", + expected="X", + msg="$replaceOne special backslash in input and find", + ), + # Regex ? treated as literal. + ReplaceOneTest( + "special_regex_question", + input="a?b", + find="?", + replacement="X", + expected="aXb", + msg="$replaceOne special regex question", + ), + # Regex ^ treated as literal. + ReplaceOneTest( + "special_regex_caret", + input="a^b", + find="^", + replacement="X", + expected="aXb", + msg="$replaceOne special regex caret", + ), + # Regex | treated as literal. + ReplaceOneTest( + "special_regex_pipe", + input="a|b", + find="|", + replacement="X", + expected="aXb", + msg="$replaceOne special regex pipe", + ), + # Regex \d treated as literal two-character sequence. + ReplaceOneTest( + "special_regex_backslash_d", + input="a\\db", + find="\\d", + replacement="X", + expected="aXb", + msg="$replaceOne special regex backslash d", + ), + # Regex . treated as literal. + ReplaceOneTest( + "special_regex_dot", + input="total: 10.00 dollars", + find="10.00", + replacement="20.00", + expected="total: 20.00 dollars", + msg="$replaceOne special regex dot", + ), + # Regex .* treated as literal. + ReplaceOneTest( + "special_regex_dot_star", + input="a.*b", + find=".*", + replacement="X", + expected="aXb", + msg="$replaceOne special regex dot star", + ), + # Regex (, ), + treated as literal. + ReplaceOneTest( + "special_regex_parens_plus", + input="(a+b)", + find="(a+b)", + replacement="X", + expected="X", + msg="$replaceOne special regex parens plus", + ), + # Dollar sign via $literal in input and find. + ReplaceOneTest( + "special_dollar_literal_input", + input={"$literal": "$100"}, + find={"$literal": "$"}, + replacement="USD", + expected="USD100", + msg="$replaceOne special dollar literal input", + ), + # Dollar sign via $literal in find. + ReplaceOneTest( + "special_dollar_literal_find", + input="price: $100", + find={"$literal": "$"}, + replacement="USD", + expected="price: USD100", + msg="$replaceOne special dollar literal find", + ), + # Dollar sign via $literal in replacement. + ReplaceOneTest( + "special_dollar_literal_replacement", + input="price: USD100", + find="USD", + replacement={"$literal": "$"}, + expected="price: $100", + msg="$replaceOne special dollar literal replacement", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(REPLACEONE_SPECIAL_CHAR_TESTS)) +def test_replaceone_special_char_cases(collection, test_case: ReplaceOneTest): + """Test $replaceOne special character cases.""" + result = execute_expression(collection, _expr(test_case)) + assertResult( + 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/string/replaceOne/test_replaceOne_type_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_type_errors.py new file mode 100644 index 00000000..b282cd96 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_type_errors.py @@ -0,0 +1,448 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + REPLACE_FIND_TYPE_ERROR, + REPLACE_INPUT_TYPE_ERROR, + REPLACE_REPLACEMENT_TYPE_ERROR, +) +from documentdb_tests.framework.test_case import pytest_params +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF +from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceOne.utils.replaceOne_common import ( + ReplaceOneTest, + _expr, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import execute_expression + +# Property [Type Strictness]: all three parameters must resolve to a string or +# null. Any other type produces an error specific to the argument position. +REPLACEONE_TYPE_ERROR_TESTS: list[ReplaceOneTest] = [ + # Invalid input types. + ReplaceOneTest( + "type_input_array", + input=["a"], + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceOne should reject array as input", + ), + ReplaceOneTest( + "type_input_binary", + input=Binary(b"data"), + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceOne should reject binary as input", + ), + ReplaceOneTest( + "type_input_bool", + input=True, + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceOne should reject bool as input", + ), + ReplaceOneTest( + "type_input_date", + input=datetime(2024, 1, 1, tzinfo=timezone.utc), + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceOne should reject date as input", + ), + ReplaceOneTest( + "type_input_decimal128", + input=DECIMAL128_ONE_AND_HALF, + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceOne should reject decimal128 as input", + ), + ReplaceOneTest( + "type_input_float", + input=3.14, + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceOne should reject float as input", + ), + ReplaceOneTest( + "type_input_int", + input=42, + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceOne should reject int as input", + ), + ReplaceOneTest( + "type_input_long", + input=Int64(42), + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceOne should reject long as input", + ), + ReplaceOneTest( + "type_input_maxkey", + input=MaxKey(), + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceOne should reject maxkey as input", + ), + ReplaceOneTest( + "type_input_minkey", + input=MinKey(), + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceOne should reject minkey as input", + ), + ReplaceOneTest( + "type_input_object", + input={"a": 1}, + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceOne should reject object as input", + ), + ReplaceOneTest( + "type_input_objectid", + input=ObjectId("507f1f77bcf86cd799439011"), + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceOne should reject objectid as input", + ), + ReplaceOneTest( + "type_input_regex", + input=Regex("pattern"), + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceOne should reject regex as input", + ), + ReplaceOneTest( + "type_input_timestamp", + input=Timestamp(1, 1), + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceOne should reject timestamp as input", + ), + ReplaceOneTest( + "type_input_binary_uuid", + input=Binary(b"\x00" * 16, subtype=4), + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceOne should reject binary uuid as input", + ), + ReplaceOneTest( + "type_input_code", + input=Code("function(){}"), + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceOne should reject code as input", + ), + ReplaceOneTest( + "type_input_code_with_scope", + input=Code("function(){}", {"x": 1}), + find="a", + replacement="b", + error_code=REPLACE_INPUT_TYPE_ERROR, + msg="$replaceOne should reject code with scope as input", + ), + # Invalid find types. + ReplaceOneTest( + "type_find_array", + input="hello", + find=["a"], + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceOne should reject array as find", + ), + ReplaceOneTest( + "type_find_binary", + input="hello", + find=Binary(b"data"), + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceOne should reject binary as find", + ), + ReplaceOneTest( + "type_find_bool", + input="hello", + find=True, + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceOne should reject bool as find", + ), + ReplaceOneTest( + "type_find_date", + input="hello", + find=datetime(2024, 1, 1, tzinfo=timezone.utc), + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceOne should reject date as find", + ), + ReplaceOneTest( + "type_find_decimal128", + input="hello", + find=DECIMAL128_ONE_AND_HALF, + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceOne should reject decimal128 as find", + ), + ReplaceOneTest( + "type_find_float", + input="hello", + find=3.14, + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceOne should reject float as find", + ), + ReplaceOneTest( + "type_find_int", + input="hello", + find=42, + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceOne should reject int as find", + ), + ReplaceOneTest( + "type_find_long", + input="hello", + find=Int64(42), + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceOne should reject long as find", + ), + ReplaceOneTest( + "type_find_maxkey", + input="hello", + find=MaxKey(), + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceOne should reject maxkey as find", + ), + ReplaceOneTest( + "type_find_minkey", + input="hello", + find=MinKey(), + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceOne should reject minkey as find", + ), + ReplaceOneTest( + "type_find_object", + input="hello", + find={"a": 1}, + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceOne should reject object as find", + ), + ReplaceOneTest( + "type_find_objectid", + input="hello", + find=ObjectId("507f1f77bcf86cd799439011"), + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceOne should reject objectid as find", + ), + ReplaceOneTest( + "type_find_regex", + input="hello", + find=Regex("pattern"), + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceOne should reject regex as find", + ), + ReplaceOneTest( + "type_find_timestamp", + input="hello", + find=Timestamp(1, 1), + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceOne should reject timestamp as find", + ), + ReplaceOneTest( + "type_find_binary_uuid", + input="hello", + find=Binary(b"\x00" * 16, subtype=4), + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceOne should reject binary uuid as find", + ), + ReplaceOneTest( + "type_find_code", + input="hello", + find=Code("function(){}"), + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceOne should reject code as find", + ), + ReplaceOneTest( + "type_find_code_with_scope", + input="hello", + find=Code("function(){}", {"x": 1}), + replacement="b", + error_code=REPLACE_FIND_TYPE_ERROR, + msg="$replaceOne should reject code with scope as find", + ), + # Invalid replacement types. + ReplaceOneTest( + "type_replacement_array", + input="hello", + find="a", + replacement=["a"], + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceOne should reject array as replacement", + ), + ReplaceOneTest( + "type_replacement_binary", + input="hello", + find="a", + replacement=Binary(b"data"), + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceOne should reject binary as replacement", + ), + ReplaceOneTest( + "type_replacement_bool", + input="hello", + find="a", + replacement=True, + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceOne should reject bool as replacement", + ), + ReplaceOneTest( + "type_replacement_date", + input="hello", + find="a", + replacement=datetime(2024, 1, 1, tzinfo=timezone.utc), + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceOne should reject date as replacement", + ), + ReplaceOneTest( + "type_replacement_decimal128", + input="hello", + find="a", + replacement=DECIMAL128_ONE_AND_HALF, + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceOne should reject decimal128 as replacement", + ), + ReplaceOneTest( + "type_replacement_float", + input="hello", + find="a", + replacement=3.14, + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceOne should reject float as replacement", + ), + ReplaceOneTest( + "type_replacement_int", + input="hello", + find="a", + replacement=42, + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceOne should reject int as replacement", + ), + ReplaceOneTest( + "type_replacement_long", + input="hello", + find="a", + replacement=Int64(42), + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceOne should reject long as replacement", + ), + ReplaceOneTest( + "type_replacement_maxkey", + input="hello", + find="a", + replacement=MaxKey(), + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceOne should reject maxkey as replacement", + ), + ReplaceOneTest( + "type_replacement_minkey", + input="hello", + find="a", + replacement=MinKey(), + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceOne should reject minkey as replacement", + ), + ReplaceOneTest( + "type_replacement_object", + input="hello", + find="a", + replacement={"a": 1}, + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceOne should reject object as replacement", + ), + ReplaceOneTest( + "type_replacement_objectid", + input="hello", + find="a", + replacement=ObjectId("507f1f77bcf86cd799439011"), + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceOne should reject objectid as replacement", + ), + ReplaceOneTest( + "type_replacement_regex", + input="hello", + find="a", + replacement=Regex("pattern"), + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceOne should reject regex as replacement", + ), + ReplaceOneTest( + "type_replacement_timestamp", + input="hello", + find="a", + replacement=Timestamp(1, 1), + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceOne should reject timestamp as replacement", + ), + ReplaceOneTest( + "type_replacement_binary_uuid", + input="hello", + find="a", + replacement=Binary(b"\x00" * 16, subtype=4), + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceOne should reject binary uuid as replacement", + ), + ReplaceOneTest( + "type_replacement_code", + input="hello", + find="a", + replacement=Code("function(){}"), + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceOne should reject code as replacement", + ), + ReplaceOneTest( + "type_replacement_code_with_scope", + input="hello", + find="a", + replacement=Code("function(){}", {"x": 1}), + error_code=REPLACE_REPLACEMENT_TYPE_ERROR, + msg="$replaceOne should reject code with scope as replacement", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(REPLACEONE_TYPE_ERROR_TESTS)) +def test_replaceone_type_error_cases(collection, test_case: ReplaceOneTest): + """Test $replaceOne type error cases.""" + result = execute_expression(collection, _expr(test_case)) + assertResult( + 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/string/replaceOne/test_replaceOne_usage.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_usage.py new file mode 100644 index 00000000..39dc03d4 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_usage.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.test_case import pytest_params +from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceOne.utils.replaceOne_common import ( + ReplaceOneTest, + _expr, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ExpressionTestCase +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + execute_expression, + execute_expression_with_insert, +) + +# Property [Expression Arguments]: all three parameters accept arbitrary +# expressions that resolve to string or null. +REPLACEONE_EXPR_TESTS: list[ReplaceOneTest] = [ + ReplaceOneTest( + "expr_input_expression", + input={"$toUpper": "hello world"}, + find="WORLD", + replacement="X", + expected="HELLO X", + msg="$replaceOne should accept input expression", + ), + ReplaceOneTest( + "expr_find_expression", + input="HELLO WORLD", + find={"$toUpper": "world"}, + replacement="X", + expected="HELLO X", + msg="$replaceOne should accept find expression", + ), + ReplaceOneTest( + "expr_replacement_expression", + input="hello world", + find="world", + replacement={"$toUpper": "earth"}, + expected="hello EARTH", + msg="$replaceOne should accept replacement expression", + ), + ReplaceOneTest( + "expr_all_expressions", + input={"$concat": ["hel", "lo"]}, + find={"$toLower": "LO"}, + replacement={"$toUpper": "p"}, + expected="helP", + msg="$replaceOne should accept all expressions", + ), + # Expression resolving to null follows null propagation. + ReplaceOneTest( + "expr_null_input", + input={"$literal": None}, + find="a", + replacement="b", + expected=None, + msg="$replaceOne should accept null input", + ), + ReplaceOneTest( + "expr_null_find", + input="hello", + find={"$literal": None}, + replacement="b", + expected=None, + msg="$replaceOne should accept null find", + ), + ReplaceOneTest( + "expr_null_replacement", + input="hello", + find="h", + replacement={"$literal": None}, + expected=None, + msg="$replaceOne should accept null replacement", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(REPLACEONE_EXPR_TESTS)) +def test_replaceone_usage_cases(collection, test_case: ReplaceOneTest): + """Test $replaceOne expression argument cases.""" + result = execute_expression(collection, _expr(test_case)) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) + + +# Property [Document Field References]: $replaceOne works with field references +# from inserted documents, not just inline literals. +REPLACEONE_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + # Object expression: all args from simple field paths. + ExpressionTestCase( + "field_object", + expression={"$replaceOne": {"input": "$i", "find": "$f", "replacement": "$r"}}, + doc={"i": "cat bat cat", "f": "cat", "r": "dog"}, + expected="dog bat cat", + msg="$replaceOne should accept args from document field paths", + ), + # Composite array: all args from $arrayElemAt on a projected array-of-objects field. + ExpressionTestCase( + "field_composite_array", + expression={ + "$replaceOne": { + "input": {"$arrayElemAt": ["$a.b", 0]}, + "find": {"$arrayElemAt": ["$a.b", 1]}, + "replacement": {"$arrayElemAt": ["$a.b", 2]}, + } + }, + doc={"a": [{"b": "cat bat cat"}, {"b": "cat"}, {"b": "dog"}]}, + expected="dog bat cat", + msg="$replaceOne should accept args from composite array field paths", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(REPLACEONE_FIELD_REF_TESTS)) +def test_replaceone_field_refs(collection, test_case: ExpressionTestCase): + """Test $replaceOne with document field references.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assertResult( + 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/string/replaceOne/test_replaceOne_whitespace.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_whitespace.py new file mode 100644 index 00000000..ecb811d9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_whitespace.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import pytest + +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.test_case import pytest_params +from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceOne.utils.replaceOne_common import ( + ReplaceOneTest, + _expr, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import execute_expression + +# Property [Whitespace]: all ASCII and Unicode whitespace characters are matched +# exactly as literal content, with no equivalence between different whitespace types. +REPLACEONE_WHITESPACE_TESTS: list[ReplaceOneTest] = [ + ReplaceOneTest( + "whitespace_space", + input="hello world", + find=" ", + replacement="_", + expected="hello_world", + msg="$replaceOne whitespace space", + ), + ReplaceOneTest( + "whitespace_tab", + input="col1\tcol2", + find="\t", + replacement=" ", + expected="col1 col2", + msg="$replaceOne whitespace tab", + ), + ReplaceOneTest( + "whitespace_newline", + input="line1\nline2", + find="\n", + replacement=" ", + expected="line1 line2", + msg="$replaceOne whitespace newline", + ), + ReplaceOneTest( + "whitespace_cr", + input="line1\rline2", + find="\r", + replacement=" ", + expected="line1 line2", + msg="$replaceOne whitespace cr", + ), + # CRLF matched as a two-character unit. + ReplaceOneTest( + "whitespace_crlf_unit", + input="line1\r\nline2", + find="\r\n", + replacement=" ", + expected="line1 line2", + msg="$replaceOne whitespace crlf unit", + ), + # Individual \r within CRLF. + ReplaceOneTest( + "whitespace_crlf_individual_cr", + input="line1\r\nline2", + find="\r", + replacement="", + expected="line1\nline2", + msg="$replaceOne whitespace crlf individual cr", + ), + # Individual \n within CRLF. + ReplaceOneTest( + "whitespace_crlf_individual_lf", + input="line1\r\nline2", + find="\n", + replacement="", + expected="line1\rline2", + msg="$replaceOne whitespace crlf individual lf", + ), + # NBSP (U+00A0) is not equivalent to ASCII space. + ReplaceOneTest( + "whitespace_nbsp_not_space", + input="hello\u00a0world", + find=" ", + replacement="X", + expected="hello\u00a0world", + msg="$replaceOne whitespace nbsp not space", + ), + # NBSP matched exactly. + ReplaceOneTest( + "whitespace_nbsp_exact", + input="hello\u00a0world", + find="\u00a0", + replacement="_", + expected="hello_world", + msg="$replaceOne whitespace nbsp exact", + ), + # En space (U+2000) is not equivalent to ASCII space. + ReplaceOneTest( + "whitespace_en_space_not_space", + input="hello\u2000world", + find=" ", + replacement="X", + expected="hello\u2000world", + msg="$replaceOne whitespace en space not space", + ), + # En space matched exactly. + ReplaceOneTest( + "whitespace_en_space_exact", + input="hello\u2000world", + find="\u2000", + replacement="_", + expected="hello_world", + msg="$replaceOne whitespace en space exact", + ), + # Em space (U+2003) is not equivalent to ASCII space. + ReplaceOneTest( + "whitespace_em_space_not_space", + input="hello\u2003world", + find=" ", + replacement="X", + expected="hello\u2003world", + msg="$replaceOne whitespace em space not space", + ), + # Em space matched exactly. + ReplaceOneTest( + "whitespace_em_space_exact", + input="hello\u2003world", + find="\u2003", + replacement="_", + expected="hello_world", + msg="$replaceOne whitespace em space exact", + ), +] + +# Property [Zero-Width and Invisible Characters]: BOM, ZWSP, ZWJ, LTR mark, +# and RTL mark are treated as regular code points and are findable and +# replaceable. Replacing a ZWJ within an emoji sequence breaks the sequence. +REPLACEONE_ZERO_WIDTH_TESTS: list[ReplaceOneTest] = [ + # BOM (U+FEFF) findable and replaceable. + ReplaceOneTest( + "zw_bom_find", + input="hello\ufeffworld", + find="\ufeff", + replacement="X", + expected="helloXworld", + msg="$replaceOne zw bom find", + ), + # BOM as replacement. + ReplaceOneTest( + "zw_bom_replacement", + input="helloXworld", + find="X", + replacement="\ufeff", + expected="hello\ufeffworld", + msg="$replaceOne zw bom replacement", + ), + # ZWSP (U+200B) findable and replaceable. + ReplaceOneTest( + "zw_zwsp_find", + input="hello\u200bworld", + find="\u200b", + replacement="X", + expected="helloXworld", + msg="$replaceOne zw zwsp find", + ), + # ZWSP as replacement. + ReplaceOneTest( + "zw_zwsp_replacement", + input="helloXworld", + find="X", + replacement="\u200b", + expected="hello\u200bworld", + msg="$replaceOne zw zwsp replacement", + ), + # ZWJ (U+200D) findable and replaceable. + ReplaceOneTest( + "zw_zwj_find", + input="a\u200db", + find="\u200d", + replacement="X", + expected="aXb", + msg="$replaceOne zw zwj find", + ), + # LTR mark (U+200E) findable and replaceable. + ReplaceOneTest( + "zw_ltr_mark_find", + input="hello\u200eworld", + find="\u200e", + replacement="X", + expected="helloXworld", + msg="$replaceOne zw ltr mark find", + ), + # RTL mark (U+200F) findable and replaceable. + ReplaceOneTest( + "zw_rtl_mark_find", + input="hello\u200fworld", + find="\u200f", + replacement="X", + expected="helloXworld", + msg="$replaceOne zw rtl mark find", + ), + # ZWJ within emoji sequence: replacing ZWJ (U+200D) in 👨‍💻 (man + ZWJ + + # laptop) breaks the sequence into separate codepoints. + ReplaceOneTest( + "zw_zwj_emoji_break", + input="\U0001f468\u200d\U0001f4bb", + find="\u200d", + replacement="", + expected="\U0001f468\U0001f4bb", + msg="$replaceOne zw zwj emoji break", + ), +] + +REPLACEONE_WHITESPACE_ALL_TESTS = REPLACEONE_WHITESPACE_TESTS + REPLACEONE_ZERO_WIDTH_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(REPLACEONE_WHITESPACE_ALL_TESTS)) +def test_replaceone_whitespace_cases(collection, test_case: ReplaceOneTest): + """Test $replaceOne whitespace and zero-width character cases.""" + result = execute_expression(collection, _expr(test_case)) + assertResult( + 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/string/replaceOne/utils/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/utils/replaceOne_common.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/utils/replaceOne_common.py new file mode 100644 index 00000000..925f9f9f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/utils/replaceOne_common.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, cast + +from documentdb_tests.framework.test_case import BaseTestCase + + +@dataclass(frozen=True) +class ReplaceOneTest(BaseTestCase): + """Test case for $replaceOne operator.""" + + input: Any = None + find: Any = None + replacement: Any = None + expr: Any = None # Raw expression override for syntax tests + + +def _expr(test_case: ReplaceOneTest) -> dict[str, Any]: + if test_case.expr is not None: + return cast(dict[str, Any], test_case.expr) + return { + "$replaceOne": { + "input": test_case.input, + "find": test_case.find, + "replacement": test_case.replacement, + } + } From 693cd2f720afadfbd49b2c1e504f426610b6962a Mon Sep 17 00:00:00 2001 From: Yunxuan Shi Date: Thu, 9 Apr 2026 16:51:51 -0700 Subject: [PATCH 2/5] Add missing dependencies for $replace* tests - Add __init__.py for package resolution - Add ExpressionTestCase to utils/ - Add REPLACE_* error codes, FAILED_TO_PARSE_ERROR, INVALID_DOLLAR_FIELD_PATH, STRING_SIZE_LIMIT_ERROR to error_codes.py - Add STRING_SIZE_LIMIT_BYTES to test_constants.py - Fix pytest_params import (parametrize module) - Use relative imports for operator common utils - Pin CI MongoDB to 8.2.4 - Run isort/black formatting Signed-off-by: Yunxuan Shi --- .../expressions/string/replaceAll/__init__.py | 0 .../string/replaceAll/test_replaceAll_core.py | 17 ++++++++++------- .../replaceAll/test_replaceAll_encoding.py | 9 ++++++--- .../replaceAll/test_replaceAll_invalid_args.py | 9 ++++++--- .../replaceAll/test_replaceAll_invariants.py | 13 +++++++------ .../string/replaceAll/test_replaceAll_null.py | 9 ++++++--- .../replaceAll/test_replaceAll_size_limit.py | 9 ++++++--- .../replaceAll/test_replaceAll_type_errors.py | 9 ++++++--- .../test_replaceAll_type_precedence.py | 9 ++++++--- .../expressions/string/replaceOne/__init__.py | 0 .../string/replaceOne/test_replaceOne_core.py | 9 ++++++--- .../replaceOne/test_replaceOne_encoding.py | 9 ++++++--- .../replaceOne/test_replaceOne_invalid_args.py | 9 ++++++--- .../replaceOne/test_replaceOne_invariants.py | 13 +++++++------ .../string/replaceOne/test_replaceOne_null.py | 9 ++++++--- .../replaceOne/test_replaceOne_size_limit.py | 9 ++++++--- .../replaceOne/test_replaceOne_special_chars.py | 9 ++++++--- .../replaceOne/test_replaceOne_type_errors.py | 9 ++++++--- .../string/replaceOne/test_replaceOne_usage.py | 17 ++++++++++------- .../replaceOne/test_replaceOne_whitespace.py | 9 ++++++--- 20 files changed, 118 insertions(+), 68 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/__init__.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_core.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_core.py index 3ba60779..4ca97656 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_core.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_core.py @@ -2,17 +2,20 @@ import pytest -from documentdb_tests.framework.assertions import assertResult -from documentdb_tests.framework.test_case import pytest_params -from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceAll.utils.replaceAll_common import ( - ReplaceAllTest, - _expr, -) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ExpressionTestCase from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( execute_expression, execute_expression_with_insert, ) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.parametrize import pytest_params + +from ...utils.expression_test_case import ( + ExpressionTestCase, +) +from .utils.replaceAll_common import ( + ReplaceAllTest, + _expr, +) # Property [Core Replacement]: all occurrences of find are replaced. Matching is left-to-right # greedy with no re-scanning. diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_encoding.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_encoding.py index e749c5cf..b4ef172e 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_encoding.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_encoding.py @@ -2,13 +2,16 @@ import pytest +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + execute_expression, +) from documentdb_tests.framework.assertions import assertResult -from documentdb_tests.framework.test_case import pytest_params -from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceAll.utils.replaceAll_common import ( +from documentdb_tests.framework.parametrize import pytest_params + +from .utils.replaceAll_common import ( ReplaceAllTest, _expr, ) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import execute_expression # Property [Encoding]: matching is diacritic-sensitive, no Unicode normalization, multi-byte # characters handled correctly, special and regex characters treated literally. diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_invalid_args.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_invalid_args.py index ea7872d6..f1c93c1e 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_invalid_args.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_invalid_args.py @@ -5,6 +5,9 @@ import pytest from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + execute_expression, +) from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.error_codes import ( FAILED_TO_PARSE_ERROR, @@ -15,13 +18,13 @@ REPLACE_NON_OBJECT_ERROR, REPLACE_UNKNOWN_FIELD_ERROR, ) -from documentdb_tests.framework.test_case import pytest_params +from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF -from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceAll.utils.replaceAll_common import ( + +from .utils.replaceAll_common import ( ReplaceAllTest, _expr, ) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import execute_expression # Property [Bare Dollar Sign]: a bare "$" string in any parameter position is interpreted as a # field path, producing INVALID_DOLLAR_FIELD_PATH. diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_invariants.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_invariants.py index 9db98ba0..97f6fd48 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_invariants.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_invariants.py @@ -2,16 +2,17 @@ import pytest -from documentdb_tests.framework.assertions import assertSuccess -from documentdb_tests.framework.test_case import pytest_params -from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceAll.utils.replaceAll_common import ( - ReplaceAllTest, - _expr, -) from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( execute_expression, execute_project, ) +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.parametrize import pytest_params + +from .utils.replaceAll_common import ( + ReplaceAllTest, + _expr, +) # Property [Length Invariant]: result length equals input length minus N * find length plus # N * replacement length. diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_null.py index 9553fa1b..dc951bae 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_null.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_null.py @@ -4,14 +4,17 @@ import pytest +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + execute_expression, +) from documentdb_tests.framework.assertions import assertResult -from documentdb_tests.framework.test_case import pytest_params +from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import MISSING -from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceAll.utils.replaceAll_common import ( + +from .utils.replaceAll_common import ( ReplaceAllTest, _expr, ) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import execute_expression # Property [Null Propagation]: if any argument is null or missing, the result is null, uniformly # across all three argument positions. diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_size_limit.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_size_limit.py index 1be8200d..68100d6f 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_size_limit.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_size_limit.py @@ -2,15 +2,18 @@ import pytest +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + execute_expression, +) from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.error_codes import STRING_SIZE_LIMIT_ERROR -from documentdb_tests.framework.test_case import pytest_params +from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import STRING_SIZE_LIMIT_BYTES -from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceAll.utils.replaceAll_common import ( + +from .utils.replaceAll_common import ( ReplaceAllTest, _expr, ) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import execute_expression # Property [String Size Limit - Success]: strings one byte under the limit are accepted in all # parameter positions, and replacement amplification succeeds when the result stays under the diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_type_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_type_errors.py index 5c4f6043..3a958446 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_type_errors.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_type_errors.py @@ -5,19 +5,22 @@ import pytest from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + execute_expression, +) from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.error_codes import ( REPLACE_FIND_TYPE_ERROR, REPLACE_INPUT_TYPE_ERROR, REPLACE_REPLACEMENT_TYPE_ERROR, ) -from documentdb_tests.framework.test_case import pytest_params +from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF -from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceAll.utils.replaceAll_common import ( + +from .utils.replaceAll_common import ( ReplaceAllTest, _expr, ) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import execute_expression # Property [Type Strictness]: all three parameters must resolve to a string or null. Any other # type produces an error specific to the argument position. diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_type_precedence.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_type_precedence.py index 0c241d13..bb0076f9 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_type_precedence.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_type_precedence.py @@ -2,18 +2,21 @@ import pytest +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + execute_expression, +) from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.error_codes import ( REPLACE_FIND_TYPE_ERROR, REPLACE_INPUT_TYPE_ERROR, REPLACE_REPLACEMENT_TYPE_ERROR, ) -from documentdb_tests.framework.test_case import pytest_params -from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceAll.utils.replaceAll_common import ( +from documentdb_tests.framework.parametrize import pytest_params + +from .utils.replaceAll_common import ( ReplaceAllTest, _expr, ) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import execute_expression # Property [Type Error Precedence]: type errors take precedence over null propagation. When # multiple arguments have type errors, input is validated first, then find, then replacement. diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_core.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_core.py index f233974d..addb7380 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_core.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_core.py @@ -2,13 +2,16 @@ import pytest +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + execute_expression, +) from documentdb_tests.framework.assertions import assertResult -from documentdb_tests.framework.test_case import pytest_params -from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceOne.utils.replaceOne_common import ( +from documentdb_tests.framework.parametrize import pytest_params + +from .utils.replaceOne_common import ( ReplaceOneTest, _expr, ) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import execute_expression # Property [Core Replacement]: only the first occurrence of find is replaced; # subsequent occurrences are left unchanged. If no match exists, the input is diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_encoding.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_encoding.py index f33832c3..6593b12c 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_encoding.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_encoding.py @@ -2,13 +2,16 @@ import pytest +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + execute_expression, +) from documentdb_tests.framework.assertions import assertResult -from documentdb_tests.framework.test_case import pytest_params -from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceOne.utils.replaceOne_common import ( +from documentdb_tests.framework.parametrize import pytest_params + +from .utils.replaceOne_common import ( ReplaceOneTest, _expr, ) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import execute_expression # Property [Encoding and Character Handling]: matching is diacritic-sensitive, # no Unicode normalization is performed, multi-byte UTF-8 characters are diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_invalid_args.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_invalid_args.py index 461ebb16..82f23a42 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_invalid_args.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_invalid_args.py @@ -5,6 +5,9 @@ import pytest from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + execute_expression, +) from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.error_codes import ( FAILED_TO_PARSE_ERROR, @@ -18,13 +21,13 @@ REPLACE_REPLACEMENT_TYPE_ERROR, REPLACE_UNKNOWN_FIELD_ERROR, ) -from documentdb_tests.framework.test_case import pytest_params +from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF, MISSING -from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceOne.utils.replaceOne_common import ( + +from .utils.replaceOne_common import ( ReplaceOneTest, _expr, ) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import execute_expression # Property [Type Error Precedence]: type errors take precedence over null # propagation. When multiple arguments have type errors, input is validated diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_invariants.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_invariants.py index 4406586a..5bae8154 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_invariants.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_invariants.py @@ -2,16 +2,17 @@ import pytest -from documentdb_tests.framework.assertions import assertSuccess -from documentdb_tests.framework.test_case import pytest_params -from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceOne.utils.replaceOne_common import ( - ReplaceOneTest, - _expr, -) from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( execute_expression, execute_project, ) +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.parametrize import pytest_params + +from .utils.replaceOne_common import ( + ReplaceOneTest, + _expr, +) # Property [Length Invariant]: when a match is found, result length equals # len(input) - len(find) + len(replacement). When no match is found, result diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_null.py index 16150962..9ba88690 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_null.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_null.py @@ -4,14 +4,17 @@ import pytest +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + execute_expression, +) from documentdb_tests.framework.assertions import assertResult -from documentdb_tests.framework.test_case import pytest_params +from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import MISSING -from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceOne.utils.replaceOne_common import ( + +from .utils.replaceOne_common import ( ReplaceOneTest, _expr, ) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import execute_expression # Property [Null Propagation]: if any argument is null or missing, the result # is null, uniformly across all three argument positions. diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_size_limit.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_size_limit.py index 87f11c3e..2ef60872 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_size_limit.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_size_limit.py @@ -2,15 +2,18 @@ import pytest +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + execute_expression, +) from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.error_codes import STRING_SIZE_LIMIT_ERROR -from documentdb_tests.framework.test_case import pytest_params +from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import STRING_SIZE_LIMIT_BYTES -from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceOne.utils.replaceOne_common import ( + +from .utils.replaceOne_common import ( ReplaceOneTest, _expr, ) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import execute_expression # Property [String Size Limit - Success]: strings one byte under the limit are # accepted in all parameter positions, and a shrinking replacement succeeds diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_special_chars.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_special_chars.py index c778065a..8b228b26 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_special_chars.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_special_chars.py @@ -2,13 +2,16 @@ import pytest +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + execute_expression, +) from documentdb_tests.framework.assertions import assertResult -from documentdb_tests.framework.test_case import pytest_params -from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceOne.utils.replaceOne_common import ( +from documentdb_tests.framework.parametrize import pytest_params + +from .utils.replaceOne_common import ( ReplaceOneTest, _expr, ) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import execute_expression # Property [Special Characters]: null bytes, control characters, punctuation, # braces, brackets, quotes, and regex-special characters are treated as literal diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_type_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_type_errors.py index b282cd96..9b001c39 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_type_errors.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_type_errors.py @@ -5,19 +5,22 @@ import pytest from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + execute_expression, +) from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.error_codes import ( REPLACE_FIND_TYPE_ERROR, REPLACE_INPUT_TYPE_ERROR, REPLACE_REPLACEMENT_TYPE_ERROR, ) -from documentdb_tests.framework.test_case import pytest_params +from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF -from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceOne.utils.replaceOne_common import ( + +from .utils.replaceOne_common import ( ReplaceOneTest, _expr, ) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import execute_expression # Property [Type Strictness]: all three parameters must resolve to a string or # null. Any other type produces an error specific to the argument position. diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_usage.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_usage.py index 39dc03d4..73f1feb0 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_usage.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_usage.py @@ -2,17 +2,20 @@ import pytest -from documentdb_tests.framework.assertions import assertResult -from documentdb_tests.framework.test_case import pytest_params -from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceOne.utils.replaceOne_common import ( - ReplaceOneTest, - _expr, -) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ExpressionTestCase from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( execute_expression, execute_expression_with_insert, ) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.parametrize import pytest_params + +from ...utils.expression_test_case import ( + ExpressionTestCase, +) +from .utils.replaceOne_common import ( + ReplaceOneTest, + _expr, +) # Property [Expression Arguments]: all three parameters accept arbitrary # expressions that resolve to string or null. diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_whitespace.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_whitespace.py index ecb811d9..850e110c 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_whitespace.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_whitespace.py @@ -2,13 +2,16 @@ import pytest +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + execute_expression, +) from documentdb_tests.framework.assertions import assertResult -from documentdb_tests.framework.test_case import pytest_params -from documentdb_tests.compatibility.tests.core.operator.expressions.string.replaceOne.utils.replaceOne_common import ( +from documentdb_tests.framework.parametrize import pytest_params + +from .utils.replaceOne_common import ( ReplaceOneTest, _expr, ) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import execute_expression # Property [Whitespace]: all ASCII and Unicode whitespace characters are matched # exactly as literal content, with no equivalence between different whitespace types. From d7bf7bd25702e2849b5106a6ed3950a766ee8f0e Mon Sep 17 00:00:00 2001 From: Yunxuan Shi Date: Mon, 13 Apr 2026 17:31:39 -0700 Subject: [PATCH 3/5] Truncate large assertion output to keep test failures readable - Add _truncate_repr() to cap custom error messages at 1000 chars - Use raise AssertionError for large values to suppress pytest introspection - Keep bare assert for normal-sized values to preserve pytest diff - Add truncation_limit_lines/chars to pytest.ini as safety net Signed-off-by: Yunxuan Shi # Conflicts: # documentdb_tests/framework/assertions.py --- documentdb_tests/framework/assertions.py | 47 ++++++++++++++++++------ 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/documentdb_tests/framework/assertions.py b/documentdb_tests/framework/assertions.py index 771e7fb3..95a73d7e 100644 --- a/documentdb_tests/framework/assertions.py +++ b/documentdb_tests/framework/assertions.py @@ -12,6 +12,17 @@ from documentdb_tests.framework.infra_exceptions import INFRA_EXCEPTION_TYPES as _INFRA_TYPES +_MAX_REPR_LEN = 1000 + + +def _truncate_repr(obj: Any) -> str: + """Format an object for error output, truncating if too long.""" + text = pprint.pformat(obj, width=100) + if len(text) > _MAX_REPR_LEN: + return text[:_MAX_REPR_LEN] + f"... (truncated, {len(text)} chars total)" + return text + + # BSON numeric types that must match exactly during comparison. Python's == operator # treats some of these as equal (e.g. int and Int64) but they are distinct BSON types. _NUMERIC_BSON_TYPES = (int, float, Int64, Decimal128) @@ -83,7 +94,7 @@ def _format_exception_error(result: Exception) -> str: msg = getattr(result, "details", {}).get("errmsg", str(result)) return ( f"[UNEXPECTED_ERROR] Expected success but got exception:\n" - f"{pprint.pformat({'code': code, 'msg': msg}, width=100)}\n" + f"{_truncate_repr({'code': code, 'msg': msg})}\n" ) @@ -126,14 +137,24 @@ def assertSuccess( error_text = "[RESULT_MISMATCH]" if msg: error_text += f" {msg}" - error_text += f"\n\nExpected:\n{pprint.pformat(expected, width=100)}" - error_text += f"\n\nActual:\n{pprint.pformat(result, width=100)}\n" + error_text += f"\n\nExpected:\n{_truncate_repr(expected)}" + error_text += f"\n\nActual:\n{_truncate_repr(result)}\n" - if ignore_doc_order: - result = _sort_if_list(result) - expected = _sort_if_list(expected) + _large = len(repr(result)) > _MAX_REPR_LEN or len(repr(expected)) > _MAX_REPR_LEN - assert _strict_equal(result, expected), error_text + if ignore_doc_order: + sorted_result = _sort_if_list(result) + sorted_expected = _sort_if_list(expected) + if _large: + if not _strict_equal(sorted_result, sorted_expected): + raise AssertionError(error_text) + else: + assert _strict_equal(sorted_result, sorted_expected), error_text + elif _large: + if not _strict_equal(result, expected): + raise AssertionError(error_text) + else: + assert _strict_equal(result, expected), error_text def assertSuccessPartial( @@ -182,7 +203,7 @@ def assertFailure( else: error_text = ( f"[UNEXPECTED_SUCCESS]{custom_msg} Expected error but got result:\n" - f"{pprint.pformat(result, width=100)}\n" + f"{_truncate_repr(result)}\n" ) raise AssertionError(error_text) @@ -199,10 +220,14 @@ def assertFailure( error_text = ( f"[ERROR_MISMATCH]{custom_msg}\n\n" - f"Expected:\n{pprint.pformat(expected, width=100)}\n\n" - f"Actual:\n{pprint.pformat(actual, width=100)}\n" + f"Expected:\n{_truncate_repr(expected)}\n\n" + f"Actual:\n{_truncate_repr(actual)}\n" ) - assert _strict_equal(actual, expected), error_text + if len(repr(actual)) > _MAX_REPR_LEN or len(repr(expected)) > _MAX_REPR_LEN: + if not _strict_equal(actual, expected): + raise AssertionError(error_text) + else: + assert _strict_equal(actual, expected), error_text def assertFailureCode(result: Union[Any, Exception], expected_code: int, msg: Optional[str] = None): From 0dcf5e1e7d64bcd5d2e8072932fec5cbdb89d5f0 Mon Sep 17 00:00:00 2001 From: Yunxuan Shi Date: Mon, 13 Apr 2026 17:56:31 -0700 Subject: [PATCH 4/5] Add engine_xfail marker for engine-specific known failures - Fix replaceOne/replaceAll tests to use assert_expression_result - Add unit marker to pytest.ini Signed-off-by: Yunxuan Shi --- .../compatibility/result_analyzer/analyzer.py | 22 ++++++++++++--- .../result_analyzer/report_generator.py | 22 +++++++++++++++ .../string/replaceAll/test_replaceAll_core.py | 27 ++++++++++++++++--- .../replaceAll/test_replaceAll_encoding.py | 4 +-- .../test_replaceAll_invalid_args.py | 4 +-- .../string/replaceAll/test_replaceAll_null.py | 4 +-- .../replaceAll/test_replaceAll_size_limit.py | 4 +-- .../replaceAll/test_replaceAll_type_errors.py | 4 +-- .../test_replaceAll_type_precedence.py | 4 +-- .../string/replaceOne/test_replaceOne_core.py | 4 +-- .../replaceOne/test_replaceOne_encoding.py | 4 +-- .../test_replaceOne_invalid_args.py | 4 +-- .../string/replaceOne/test_replaceOne_null.py | 4 +-- .../replaceOne/test_replaceOne_size_limit.py | 4 +-- .../test_replaceOne_special_chars.py | 4 +-- .../replaceOne/test_replaceOne_type_errors.py | 4 +-- .../replaceOne/test_replaceOne_usage.py | 6 ++--- .../replaceOne/test_replaceOne_whitespace.py | 4 +-- documentdb_tests/conftest.py | 12 +++++++++ documentdb_tests/pytest.ini | 4 +++ 20 files changed, 112 insertions(+), 37 deletions(-) diff --git a/documentdb_tests/compatibility/result_analyzer/analyzer.py b/documentdb_tests/compatibility/result_analyzer/analyzer.py index 244e6e80..9c709adf 100644 --- a/documentdb_tests/compatibility/result_analyzer/analyzer.py +++ b/documentdb_tests/compatibility/result_analyzer/analyzer.py @@ -19,6 +19,8 @@ "PASS": "passed", "FAIL": "failed", "SKIPPED": "skipped", + "XFAIL": "xfailed", + "XPASS": "xpassed", } @@ -28,6 +30,8 @@ class TestOutcome: PASS = "PASS" FAIL = "FAIL" SKIPPED = "SKIPPED" + XFAIL = "XFAIL" + XPASS = "XPASS" def categorize_outcome(test_result: Dict[str, Any]) -> str: @@ -38,12 +42,14 @@ def categorize_outcome(test_result: Dict[str, Any]) -> str: - PASS: Test passed - FAIL: Test failed (for any reason) - SKIPPED: Test skipped + - XFAIL: Test expected to fail and did fail + - XPASS: Test expected to fail but passed Args: test_result: Test result dictionary from pytest JSON report Returns: - One of: PASS, FAIL, SKIPPED + One of: PASS, FAIL, SKIPPED, XFAIL, XPASS """ outcome = test_result.get("outcome", "") @@ -51,8 +57,11 @@ def categorize_outcome(test_result: Dict[str, Any]) -> str: return TestOutcome.PASS elif outcome == "skipped": return TestOutcome.SKIPPED + elif outcome == "xfailed": + return TestOutcome.XFAIL + elif outcome == "xpassed": + return TestOutcome.XPASS else: - # Failed or any other outcome return TestOutcome.FAIL @@ -94,6 +103,11 @@ def extract_failure_tag(test_result: Dict[str, Any]) -> str: crash_info = call_info.get("crash", {}) crash_message = crash_info.get("message", "") + # Detect strict XPASS from longrepr + longrepr = call_info.get("longrepr", "") + if longrepr.startswith("[XPASS(strict)]"): + return "XPASS_STRICT" + match = re.search(r"\[([A-Z_]+)\]", crash_message) if match: return match.group(1) @@ -300,10 +314,12 @@ def analyze_results(self, json_report_path: str) -> Dict[str, Any]: "passed": 0, "failed": 0, "skipped": 0, + "xfailed": 0, + "xpassed": 0, } by_tag: Dict[str, Dict[str, int]] = defaultdict( - lambda: {"passed": 0, "failed": 0, "skipped": 0} + lambda: {"passed": 0, "failed": 0, "skipped": 0, "xfailed": 0, "xpassed": 0} ) tests_details = [] diff --git a/documentdb_tests/compatibility/result_analyzer/report_generator.py b/documentdb_tests/compatibility/result_analyzer/report_generator.py index c39e629d..8314f2a9 100644 --- a/documentdb_tests/compatibility/result_analyzer/report_generator.py +++ b/documentdb_tests/compatibility/result_analyzer/report_generator.py @@ -101,6 +101,8 @@ def generate_text_report(analysis: Dict[str, Any], output_path: str): lines.append(f"Passed: {summary['passed']} ({summary['pass_rate']}%)") lines.append(f"Failed: {summary['failed']}") lines.append(f"Skipped: {summary['skipped']}") + lines.append(f"XFailed: {summary['xfailed']}") + lines.append(f"XPassed: {summary['xpassed']}") lines.append("") # Results by tag @@ -141,6 +143,16 @@ def generate_text_report(analysis: Dict[str, Any], output_path: str): for test in skipped_tests: lines.append(f" {test['name']}") + # XPASS warning + xpassed_tests = [t for t in analysis["tests"] if t["outcome"] == "XPASS"] + if xpassed_tests: + lines.append("") + lines.append(f"⚠ ERROR: {len(xpassed_tests)} test(s) unexpectedly passed (XPASS).") + lines.append(" With xfail_strict=true, these should appear as failures instead.") + lines.append(" If you see this, the test run may not have used pytest.ini.") + for test in xpassed_tests: + lines.append(f" {test['name']}") + lines.append("") lines.append("=" * 80) @@ -163,6 +175,8 @@ def print_summary(analysis: Dict[str, Any]): print(f"Passed: {summary['passed']} ({summary['pass_rate']}%)") print(f"Failed: {summary['failed']}") print(f"Skipped: {summary['skipped']}") + print(f"XFailed: {summary['xfailed']}") + print(f"XPassed: {summary['xpassed']}") print("=" * 60) # Failed test counts by type @@ -174,4 +188,12 @@ def print_summary(analysis: Dict[str, Any]): for ft in sorted(grouped): print(f" {ft}: {len(grouped[ft])}") + if summary["xpassed"] > 0: + xpassed_tests = [t for t in analysis["tests"] if t["outcome"] == "XPASS"] + print(f"\n⚠ ERROR: {summary['xpassed']} test(s) unexpectedly passed (XPASS).") + print(" With xfail_strict=true, these should appear as failures instead.") + print(" If you see this, the test run may not have used pytest.ini.") + for t in xpassed_tests: + print(f" {t['name']}") + print("=" * 60 + "\n") diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_core.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_core.py index 4ca97656..6f570498 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_core.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_core.py @@ -3,10 +3,10 @@ import pytest from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, execute_expression, execute_expression_with_insert, ) -from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.parametrize import pytest_params from ...utils.expression_test_case import ( @@ -334,6 +334,13 @@ replacement="X", expected="X\u00e9X", msg="$replaceAll empty string: find multibyte 2byte", + marks=( + pytest.mark.engine_xfail( + engine="mongodb", + reason="MongoDB returns invalid UTF-8 for empty-find multibyte (#95)", + raises=AssertionError, + ), + ), ), # 3-byte character: 世 (U+4E16). ReplaceAllTest( @@ -343,6 +350,13 @@ replacement="X", expected="X\u4e16X", msg="$replaceAll empty string: find multibyte 3byte", + marks=( + pytest.mark.engine_xfail( + engine="mongodb", + reason="MongoDB returns invalid UTF-8 for empty-find multibyte (#95)", + raises=AssertionError, + ), + ), ), # 4-byte character: 😀 (U+1F600). ReplaceAllTest( @@ -352,6 +366,13 @@ replacement="X", expected="X\U0001f600X", msg="$replaceAll empty string: find multibyte 4byte", + marks=( + pytest.mark.engine_xfail( + engine="mongodb", + reason="MongoDB returns invalid UTF-8 for empty-find multibyte (#95)", + raises=AssertionError, + ), + ), ), ] @@ -436,7 +457,7 @@ def test_replaceall_core_cases(collection, test_case: ReplaceAllTest): """Test $replaceAll core cases.""" result = execute_expression(collection, _expr(test_case)) - assertResult( + assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, @@ -476,6 +497,6 @@ def test_replaceall_core_cases(collection, test_case: ReplaceAllTest): def test_replaceall_field_refs(collection, test_case: ExpressionTestCase): """Test $replaceAll with document field references.""" result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) - assertResult( + 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/string/replaceAll/test_replaceAll_encoding.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_encoding.py index b4ef172e..5f3dd933 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_encoding.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_encoding.py @@ -3,9 +3,9 @@ import pytest from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, execute_expression, ) -from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.parametrize import pytest_params from .utils.replaceAll_common import ( @@ -456,7 +456,7 @@ def test_replaceall_encoding_cases(collection, test_case: ReplaceAllTest): """Test $replaceAll encoding cases.""" result = execute_expression(collection, _expr(test_case)) - assertResult( + assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_invalid_args.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_invalid_args.py index f1c93c1e..df0d1d33 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_invalid_args.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_invalid_args.py @@ -6,9 +6,9 @@ from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, execute_expression, ) -from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.error_codes import ( FAILED_TO_PARSE_ERROR, INVALID_DOLLAR_FIELD_PATH, @@ -272,7 +272,7 @@ def test_replaceall_invalid_args_cases(collection, test_case: ReplaceAllTest): """Test $replaceAll invalid argument cases.""" result = execute_expression(collection, _expr(test_case)) - assertResult( + assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_null.py index dc951bae..55b0014e 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_null.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_null.py @@ -5,9 +5,9 @@ import pytest from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, execute_expression, ) -from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import MISSING @@ -110,7 +110,7 @@ def _build_null_tests(null_value, prefix) -> list[ReplaceAllTest]: def test_replaceall_null_cases(collection, test_case: ReplaceAllTest): """Test $replaceAll null propagation cases.""" result = execute_expression(collection, _expr(test_case)) - assertResult( + assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_size_limit.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_size_limit.py index 68100d6f..4d6356bd 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_size_limit.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_size_limit.py @@ -3,9 +3,9 @@ import pytest from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, execute_expression, ) -from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.error_codes import STRING_SIZE_LIMIT_ERROR from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import STRING_SIZE_LIMIT_BYTES @@ -135,7 +135,7 @@ def test_replaceall_size_limit_cases(collection, test_case: ReplaceAllTest): """Test $replaceAll size limit cases.""" result = execute_expression(collection, _expr(test_case)) - assertResult( + assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_type_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_type_errors.py index 3a958446..7b685549 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_type_errors.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_type_errors.py @@ -6,9 +6,9 @@ from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, execute_expression, ) -from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.error_codes import ( REPLACE_FIND_TYPE_ERROR, REPLACE_INPUT_TYPE_ERROR, @@ -515,7 +515,7 @@ def test_replaceall_type_error_cases(collection, test_case: ReplaceAllTest): """Test $replaceAll type error cases.""" result = execute_expression(collection, _expr(test_case)) - assertResult( + assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_type_precedence.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_type_precedence.py index bb0076f9..4122a908 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_type_precedence.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceAll/test_replaceAll_type_precedence.py @@ -3,9 +3,9 @@ import pytest from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, execute_expression, ) -from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.error_codes import ( REPLACE_FIND_TYPE_ERROR, REPLACE_INPUT_TYPE_ERROR, @@ -91,7 +91,7 @@ def test_replaceall_type_precedence_cases(collection, test_case: ReplaceAllTest): """Test $replaceAll type error precedence cases.""" result = execute_expression(collection, _expr(test_case)) - assertResult( + assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_core.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_core.py index addb7380..393d6b98 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_core.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_core.py @@ -3,9 +3,9 @@ import pytest from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, execute_expression, ) -from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.parametrize import pytest_params from .utils.replaceOne_common import ( @@ -372,7 +372,7 @@ def test_replaceone_core_cases(collection, test_case: ReplaceOneTest): """Test $replaceOne core replacement cases.""" result = execute_expression(collection, _expr(test_case)) - assertResult( + assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_encoding.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_encoding.py index 6593b12c..6b743c08 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_encoding.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_encoding.py @@ -3,9 +3,9 @@ import pytest from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, execute_expression, ) -from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.parametrize import pytest_params from .utils.replaceOne_common import ( @@ -216,7 +216,7 @@ def test_replaceone_encoding_cases(collection, test_case: ReplaceOneTest): """Test $replaceOne encoding and unicode cases.""" result = execute_expression(collection, _expr(test_case)) - assertResult( + assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_invalid_args.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_invalid_args.py index 82f23a42..4b53bf56 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_invalid_args.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_invalid_args.py @@ -6,9 +6,9 @@ from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, execute_expression, ) -from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.error_codes import ( FAILED_TO_PARSE_ERROR, INVALID_DOLLAR_FIELD_PATH, @@ -395,7 +395,7 @@ def test_replaceone_invalid_args_cases(collection, test_case: ReplaceOneTest): """Test $replaceOne invalid argument cases.""" result = execute_expression(collection, _expr(test_case)) - assertResult( + assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_null.py index 9ba88690..fedf2154 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_null.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_null.py @@ -5,9 +5,9 @@ import pytest from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, execute_expression, ) -from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import MISSING @@ -107,7 +107,7 @@ def _build_null_tests(null_value, prefix) -> list[ReplaceOneTest]: def test_replaceone_null_cases(collection, test_case: ReplaceOneTest): """Test $replaceOne null propagation cases.""" result = execute_expression(collection, _expr(test_case)) - assertResult( + assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_size_limit.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_size_limit.py index 2ef60872..286f9339 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_size_limit.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_size_limit.py @@ -3,9 +3,9 @@ import pytest from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, execute_expression, ) -from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.error_codes import STRING_SIZE_LIMIT_ERROR from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import STRING_SIZE_LIMIT_BYTES @@ -166,7 +166,7 @@ def test_replaceone_size_limit_cases(collection, test_case: ReplaceOneTest): """Test $replaceOne size limit cases.""" result = execute_expression(collection, _expr(test_case)) - assertResult( + assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_special_chars.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_special_chars.py index 8b228b26..fd1e7bd8 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_special_chars.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_special_chars.py @@ -3,9 +3,9 @@ import pytest from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, execute_expression, ) -from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.parametrize import pytest_params from .utils.replaceOne_common import ( @@ -240,7 +240,7 @@ def test_replaceone_special_char_cases(collection, test_case: ReplaceOneTest): """Test $replaceOne special character cases.""" result = execute_expression(collection, _expr(test_case)) - assertResult( + assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_type_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_type_errors.py index 9b001c39..4b6c8d25 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_type_errors.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_type_errors.py @@ -6,9 +6,9 @@ from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, execute_expression, ) -from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.error_codes import ( REPLACE_FIND_TYPE_ERROR, REPLACE_INPUT_TYPE_ERROR, @@ -443,7 +443,7 @@ def test_replaceone_type_error_cases(collection, test_case: ReplaceOneTest): """Test $replaceOne type error cases.""" result = execute_expression(collection, _expr(test_case)) - assertResult( + assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_usage.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_usage.py index 73f1feb0..2ba10a06 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_usage.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_usage.py @@ -3,10 +3,10 @@ import pytest from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, execute_expression, execute_expression_with_insert, ) -from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.parametrize import pytest_params from ...utils.expression_test_case import ( @@ -84,7 +84,7 @@ def test_replaceone_usage_cases(collection, test_case: ReplaceOneTest): """Test $replaceOne expression argument cases.""" result = execute_expression(collection, _expr(test_case)) - assertResult( + assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, @@ -124,6 +124,6 @@ def test_replaceone_usage_cases(collection, test_case: ReplaceOneTest): def test_replaceone_field_refs(collection, test_case: ExpressionTestCase): """Test $replaceOne with document field references.""" result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) - assertResult( + 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/string/replaceOne/test_replaceOne_whitespace.py b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_whitespace.py index 850e110c..b1ecb9df 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_whitespace.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/string/replaceOne/test_replaceOne_whitespace.py @@ -3,9 +3,9 @@ import pytest from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, execute_expression, ) -from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.parametrize import pytest_params from .utils.replaceOne_common import ( @@ -217,7 +217,7 @@ def test_replaceone_whitespace_cases(collection, test_case: ReplaceOneTest): """Test $replaceOne whitespace and zero-width character cases.""" result = execute_expression(collection, _expr(test_case)) - assertResult( + assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, diff --git a/documentdb_tests/conftest.py b/documentdb_tests/conftest.py index e2e0b1cc..998ade42 100644 --- a/documentdb_tests/conftest.py +++ b/documentdb_tests/conftest.py @@ -58,6 +58,18 @@ def pytest_configure(config): config.connection_string = "mongodb://localhost:27017" +def pytest_runtest_setup(item): + """Apply engine-specific xfail markers.""" + for marker in item.iter_markers("engine_xfail"): + if getattr(item.config, "engine_name", None) == marker.kwargs.get("engine"): + item.add_marker( + pytest.mark.xfail( + reason=marker.kwargs.get("reason", ""), + raises=marker.kwargs.get("raises", AssertionError), + ) + ) + + @pytest.fixture(scope="session") def engine_client(request): """ diff --git a/documentdb_tests/pytest.ini b/documentdb_tests/pytest.ini index 4c760452..fc5d6321 100644 --- a/documentdb_tests/pytest.ini +++ b/documentdb_tests/pytest.ini @@ -12,6 +12,9 @@ addopts = --tb=short --color=yes +# Make xfail strict by default (XPASS fails the suite) +xfail_strict = true + # Markers for test categorization markers = # Horizontal tags (API Operations) @@ -38,6 +41,7 @@ markers = smoke: Quick smoke tests for feature detection slow: Tests that take longer to execute replica: Tests that can only run on a replica + engine_xfail(engine, reason, raises): expected failure for a specific engine # Timeout for tests (seconds) timeout = 300 From 32e67f516988116215a52f558a13dc0cbded82af Mon Sep 17 00:00:00 2001 From: Yunxuan Shi Date: Thu, 16 Apr 2026 13:20:49 -0700 Subject: [PATCH 5/5] Simplify assertion Signed-off-by: Yunxuan Shi --- documentdb_tests/framework/assertions.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/documentdb_tests/framework/assertions.py b/documentdb_tests/framework/assertions.py index 95a73d7e..657c814a 100644 --- a/documentdb_tests/framework/assertions.py +++ b/documentdb_tests/framework/assertions.py @@ -143,14 +143,10 @@ def assertSuccess( _large = len(repr(result)) > _MAX_REPR_LEN or len(repr(expected)) > _MAX_REPR_LEN if ignore_doc_order: - sorted_result = _sort_if_list(result) - sorted_expected = _sort_if_list(expected) - if _large: - if not _strict_equal(sorted_result, sorted_expected): - raise AssertionError(error_text) - else: - assert _strict_equal(sorted_result, sorted_expected), error_text - elif _large: + result = _sort_if_list(result) + expected = _sort_if_list(expected) + + if _large: if not _strict_equal(result, expected): raise AssertionError(error_text) else: