diff --git a/documentdb_tests/compatibility/tests/core/collation/command_level/operations/test_operations_update_array_ops.py b/documentdb_tests/compatibility/tests/core/collation/command_level/operations/test_operations_update_array_ops.py index e47e908e2..d18a75707 100644 --- a/documentdb_tests/compatibility/tests/core/collation/command_level/operations/test_operations_update_array_ops.py +++ b/documentdb_tests/compatibility/tests/core/collation/command_level/operations/test_operations_update_array_ops.py @@ -117,6 +117,54 @@ expected={"ok": 1.0, "n": 1, "nModified": 1}, msg="$pullAll with strength 2 should remove case-variant elements", ), + CommandTestCase( + "pullall_accent_insensitive", + docs=[{"_id": 1, "tags": ["cafe", "caf\u00e9", "tea"]}], + command=lambda ctx: { + "update": ctx.collection, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$pullAll": {"tags": ["cafe"]}}, + "collation": {"locale": "en", "strength": 1}, + } + ], + }, + expected={"ok": 1.0, "n": 1, "nModified": 1}, + msg="$pullAll with strength 1 should remove accent-variant elements", + ), + CommandTestCase( + "pullall_strength3_no_case_match", + docs=[{"_id": 1, "tags": ["Apple", "apple", "banana"]}], + command=lambda ctx: { + "update": ctx.collection, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$pullAll": {"tags": ["Apple"]}}, + "collation": {"locale": "en", "strength": 3}, + } + ], + }, + expected={"ok": 1.0, "n": 1, "nModified": 1}, + msg="$pullAll with strength 3 should not match case variants", + ), + CommandTestCase( + "pullall_numeric_ordering", + docs=[{"_id": 1, "vals": ["1", "2", "10", "20"]}], + command=lambda ctx: { + "update": ctx.collection, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$pullAll": {"vals": ["10"]}}, + "collation": {"locale": "en", "numericOrdering": True}, + } + ], + }, + expected={"ok": 1.0, "n": 1, "nModified": 1}, + msg="$pullAll with numericOrdering should match numeric string values", + ), CommandTestCase( "pullall_no_collation_binary", docs=[{"_id": 1, "tags": ["Apple", "apple", "banana"]}], @@ -132,6 +180,39 @@ expected={"ok": 1.0, "n": 1, "nModified": 1}, msg="$pullAll without collation should use binary comparison", ), + CommandTestCase( + "pullall_collection_default_collation", + target_collection=CustomCollection(options={"collation": {"locale": "en", "strength": 2}}), + docs=[{"_id": 1, "tags": ["Apple", "banana"]}], + command=lambda ctx: { + "update": ctx.collection, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$pullAll": {"tags": ["apple"]}}, + } + ], + }, + expected={"ok": 1.0, "n": 1, "nModified": 1}, + msg="$pullAll should inherit collection default collation", + ), + CommandTestCase( + "pullall_explicit_overrides_collection_default", + target_collection=CustomCollection(options={"collation": {"locale": "en", "strength": 2}}), + docs=[{"_id": 1, "tags": ["Apple", "apple", "banana"]}], + command=lambda ctx: { + "update": ctx.collection, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$pullAll": {"tags": ["apple"]}}, + "collation": {"locale": "en", "strength": 3}, + } + ], + }, + expected={"ok": 1.0, "n": 1, "nModified": 1}, + msg="$pullAll explicit collation should override collection default", + ), ] # Property [AddToSet with Collation]: $addToSet uses collation to determine diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/__init__.py b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_argument_handling.py b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_argument_handling.py new file mode 100644 index 000000000..eb7cd1dd9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_argument_handling.py @@ -0,0 +1,61 @@ +"""Tests for $pullAll argument handling. + +Covers: null/missing field handling, empty operand, multiple fields. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.update.utils import UpdateTestCase +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +SUCCESS_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "null_in_values_removes_null", + setup_docs=[{"_id": 1, "a": [1, None, 2, None, 3]}], + query={"_id": 1}, + update={"$pullAll": {"a": [None]}}, + expected={"_id": 1, "a": [1, 2, 3]}, + msg="Should remove null elements from array", + ), + UpdateTestCase( + "nonexistent_field_noop", + setup_docs=[{"_id": 1, "b": "other"}], + query={"_id": 1}, + update={"$pullAll": {"a": [1, 2]}}, + expected={"_id": 1, "b": "other"}, + msg="Should be no-op when field does not exist", + ), + UpdateTestCase( + "multiple_fields", + setup_docs=[{"_id": 1, "a": [1, 2, 3], "b": ["x", "y", "z"]}], + query={"_id": 1}, + update={"$pullAll": {"a": [2], "b": ["y"]}}, + expected={"_id": 1, "a": [1, 3], "b": ["x", "z"]}, + msg="Should process multiple fields independently", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SUCCESS_TESTS)) +def test_pullAll_argument_handling(collection, test: UpdateTestCase): + """Test $pullAll null values, missing fields, and multiple fields handling.""" + collection.insert_many(test.setup_docs) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": test.query, "u": test.update}]}, + ) + result = execute_command(collection, {"find": collection.name, "filter": test.query}) + assertSuccess(result, [test.expected], msg=test.msg) + + +def test_pullAll_empty_operand_noop(collection): + """Test $pullAll with empty operand expression {} is a no-op.""" + collection.insert_one({"_id": 1, "a": [1, 2, 3]}) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$pullAll": {}}}]}, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess(result, [{"_id": 1, "a": [1, 2, 3]}], msg="Empty $pullAll should be no-op") diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_bson_type_validation.py b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_bson_type_validation.py new file mode 100644 index 000000000..34df97c8b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_bson_type_validation.py @@ -0,0 +1,107 @@ +"""Tests for $pullAll BSON type validation. + +Verifies that $pullAll rejects non-array target field types with error code 2, +rejects non-array argument types with error code 2, and can match any BSON type +as values to remove from an array. +""" + +import pytest +from bson import Binary + +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccess +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import BAD_VALUE_ERROR +from documentdb_tests.framework.executor import execute_command + +PULLALL_PARAMS = [ + BsonTypeTestCase( + id="target_field", + msg="$pullAll should error when the document field it targets is not an array", + valid_types=[BsonType.ARRAY], + default_error_code=BAD_VALUE_ERROR, + expected=[{"_id": 1, "arr": [2]}], + valid_inputs={BsonType.ARRAY: [1, 2]}, + ), + BsonTypeTestCase( + id="argument", + msg="$pullAll should reject non-array argument types", + valid_types=[BsonType.ARRAY], + default_error_code=BAD_VALUE_ERROR, + expected=[{"_id": 1, "arr": [1, 2, 3]}], + valid_inputs={BsonType.ARRAY: [99]}, + ), + BsonTypeTestCase( + id="value_element", + msg="$pullAll should accept any BSON type as value to match and remove", + valid_types=list(BsonType), + default_error_code=BAD_VALUE_ERROR, + expected=[{"_id": 1, "arr": []}], + valid_inputs={BsonType.BIN_DATA: Binary(b"\x00\x01\x02", 128)}, + ), +] + + +def _setup_doc(spec, sample_value) -> dict: + """Build the setup document based on which aspect is being tested.""" + if spec.id == "target_field": + return {"_id": 1, "arr": sample_value} + if spec.id == "argument": + return {"_id": 1, "arr": [1, 2, 3]} + return {"_id": 1, "arr": [sample_value]} + + +def _build_update(spec, sample_value) -> dict: + """Build the update command based on which aspect is being tested.""" + if spec.id == "target_field": + return {"$pullAll": {"arr": [1]}} + if spec.id == "argument": + return {"$pullAll": {"arr": sample_value}} + return {"$pullAll": {"arr": [sample_value]}} + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", generate_bson_rejection_test_cases(PULLALL_PARAMS) +) +def test_pullAll_bson_type_rejected(collection, bson_type, sample_value, spec): + """Test $pullAll rejects invalid BSON types with error.""" + setup_doc = _setup_doc(spec, sample_value) + update = _build_update(spec, sample_value) + collection.insert_one(setup_doc) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": update}], + }, + ) + assertFailureCode( + result, + spec.expected_code(bson_type), + msg=f"$pullAll should reject {bson_type.value} for {spec.id}", + ) + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", generate_bson_acceptance_test_cases(PULLALL_PARAMS) +) +def test_pullAll_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test $pullAll accepts valid BSON types.""" + setup_doc = _setup_doc(spec, sample_value) + update = _build_update(spec, sample_value) + collection.insert_one(setup_doc) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": update}], + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess( + result, spec.expected, msg=f"$pullAll should accept {bson_type.value} for {spec.id}" + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_core_behavior.py new file mode 100644 index 000000000..8a78e4f9f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_core_behavior.py @@ -0,0 +1,91 @@ +"""Tests for $pullAll core behavior. + +Covers: removal of all instances, empty values list, all elements removed, +empty array target, duplicate values in values list, values not present. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.update.utils import UpdateTestCase +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +PULLALL_CORE_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "removes_all_instances", + setup_docs=[{"_id": 1, "a": [1, 2, 3, 1, 2, 3]}], + query={"_id": 1}, + update={"$pullAll": {"a": [1, 3]}}, + expected={"_id": 1, "a": [2, 2]}, + msg="Should remove all instances of each specified value", + ), + UpdateTestCase( + "removes_multiple_occurrences", + setup_docs=[{"_id": 1, "a": [5, 5, 5, 6, 5]}], + query={"_id": 1}, + update={"$pullAll": {"a": [5]}}, + expected={"_id": 1, "a": [6]}, + msg="Should remove multiple occurrences of same value", + ), + UpdateTestCase( + "values_not_present_noop", + setup_docs=[{"_id": 1, "a": [1, 2, 3]}], + query={"_id": 1}, + update={"$pullAll": {"a": [99, 100]}}, + expected={"_id": 1, "a": [1, 2, 3]}, + msg="Should be no-op when values not present", + ), + UpdateTestCase( + "some_values_present_some_not", + setup_docs=[{"_id": 1, "a": [1, 2, 3, 4]}], + query={"_id": 1}, + update={"$pullAll": {"a": [2, 4, 99]}}, + expected={"_id": 1, "a": [1, 3]}, + msg="Should remove only matching values", + ), + UpdateTestCase( + "empty_values_list_noop", + setup_docs=[{"_id": 1, "a": [1, 2, 3]}], + query={"_id": 1}, + update={"$pullAll": {"a": []}}, + expected={"_id": 1, "a": [1, 2, 3]}, + msg="Should be no-op with empty values list", + ), + UpdateTestCase( + "removes_all_leaves_empty_array", + setup_docs=[{"_id": 1, "a": [1, 2, 3]}], + query={"_id": 1}, + update={"$pullAll": {"a": [1, 2, 3]}}, + expected={"_id": 1, "a": []}, + msg="Should leave empty array when all elements removed", + ), + UpdateTestCase( + "empty_array_noop", + setup_docs=[{"_id": 1, "a": []}], + query={"_id": 1}, + update={"$pullAll": {"a": [1, 2]}}, + expected={"_id": 1, "a": []}, + msg="Should be no-op on empty array", + ), + UpdateTestCase( + "duplicate_values_in_values_list", + setup_docs=[{"_id": 1, "a": [1, 2, 3, 1]}], + query={"_id": 1}, + update={"$pullAll": {"a": [1, 1, 1]}}, + expected={"_id": 1, "a": [2, 3]}, + msg="Duplicate values in values list should behave same as single", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(PULLALL_CORE_TESTS)) +def test_pullAll_core_behavior(collection, test: UpdateTestCase): + """Test $pullAll core removal behavior.""" + collection.insert_many(test.setup_docs) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": test.query, "u": test.update}]}, + ) + result = execute_command(collection, {"find": collection.name, "filter": test.query}) + assertSuccess(result, [test.expected], msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_data_types.py b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_data_types.py new file mode 100644 index 000000000..fba090d1d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_data_types.py @@ -0,0 +1,216 @@ +"""Tests for $pullAll data type matching semantics. + +Covers: numeric equivalence, BSON type distinction, NaN/Infinity handling, +cross-type NaN/Infinity/negative-zero equivalence. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.update.utils import UpdateTestCase +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_NEGATIVE_ZERO, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + FLOAT_NEGATIVE_NAN, +) + +NUMERIC_EQUIVALENCE_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "mixed_types_in_values_list", + setup_docs=[{"_id": 1, "a": [1, 2.0, Int64(3), Decimal128("4")]}], + query={"_id": 1}, + update={"$pullAll": {"a": [Int64(1), 2, Decimal128("3"), 4.0]}}, + expected={"_id": 1, "a": []}, + msg="Should remove with mixed numeric types in values list", + ), +] + +BSON_TYPE_DISTINCTION_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "false_does_not_remove_int_zero", + setup_docs=[{"_id": 1, "a": [0, False]}], + query={"_id": 1}, + update={"$pullAll": {"a": [False]}}, + expected={"_id": 1, "a": [0]}, + msg="false should NOT remove int(0) — distinct BSON types", + ), + UpdateTestCase( + "true_does_not_remove_int_one", + setup_docs=[{"_id": 1, "a": [1, True]}], + query={"_id": 1}, + update={"$pullAll": {"a": [True]}}, + expected={"_id": 1, "a": [1]}, + msg="true should NOT remove int(1) — distinct BSON types", + ), + UpdateTestCase( + "null_removes_only_null", + setup_docs=[{"_id": 1, "a": [None, 0, "", False]}], + query={"_id": 1}, + update={"$pullAll": {"a": [None]}}, + expected={"_id": 1, "a": [0, "", False]}, + msg="Should remove only null elements", + ), + UpdateTestCase( + "empty_string_does_not_remove_null", + setup_docs=[{"_id": 1, "a": [None, ""]}], + query={"_id": 1}, + update={"$pullAll": {"a": [""]}}, + expected={"_id": 1, "a": [None]}, + msg="empty string should NOT remove null — distinct types", + ), +] + +NAN_INFINITY_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "float_nan_removes_nan", + setup_docs=[{"_id": 1, "a": [FLOAT_NAN, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [FLOAT_NAN]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Should remove NaN elements", + ), + UpdateTestCase( + "decimal128_nan_removes_nan", + setup_docs=[{"_id": 1, "a": [DECIMAL128_NAN, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [DECIMAL128_NAN]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Should remove Decimal128 NaN elements", + ), + UpdateTestCase( + "infinity_removes_infinity", + setup_docs=[{"_id": 1, "a": [FLOAT_INFINITY, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [FLOAT_INFINITY]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Should remove Infinity elements", + ), + UpdateTestCase( + "neg_infinity_removes_neg_infinity", + setup_docs=[{"_id": 1, "a": [FLOAT_NEGATIVE_INFINITY, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [FLOAT_NEGATIVE_INFINITY]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Should remove -Infinity elements", + ), + UpdateTestCase( + "neg_zero_removes_zero", + setup_docs=[{"_id": 1, "a": [0.0, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [DOUBLE_NEGATIVE_ZERO]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Should remove 0.0 via -0.0 (numeric equivalence)", + ), + UpdateTestCase( + "decimal128_neg_zero_removes_zero", + setup_docs=[{"_id": 1, "a": [Decimal128("0"), 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [DECIMAL128_NEGATIVE_ZERO]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Should remove Decimal128 0 via Decimal128 -0 (numeric equivalence)", + ), + UpdateTestCase( + "float_nan_matches_decimal128_nan", + setup_docs=[{"_id": 1, "a": [DECIMAL128_NAN, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [FLOAT_NAN]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="float NaN should match Decimal128 NaN (cross-type NaN equivalence)", + ), + UpdateTestCase( + "float_inf_matches_decimal128_inf", + setup_docs=[{"_id": 1, "a": [DECIMAL128_INFINITY, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [FLOAT_INFINITY]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="float Infinity should match Decimal128 Infinity (cross-type equivalence)", + ), + UpdateTestCase( + "decimal128_neg_infinity_removes_self", + setup_docs=[{"_id": 1, "a": [DECIMAL128_NEGATIVE_INFINITY, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [DECIMAL128_NEGATIVE_INFINITY]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Should remove Decimal128 -Infinity elements", + ), + UpdateTestCase( + "float_neg_inf_matches_decimal128_neg_inf", + setup_docs=[{"_id": 1, "a": [DECIMAL128_NEGATIVE_INFINITY, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [FLOAT_NEGATIVE_INFINITY]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="float -Infinity should match Decimal128 -Infinity (cross-type equivalence)", + ), + UpdateTestCase( + "float_neg_nan_removes_nan", + setup_docs=[{"_id": 1, "a": [FLOAT_NEGATIVE_NAN, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [FLOAT_NEGATIVE_NAN]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Should remove float -NaN elements", + ), + UpdateTestCase( + "decimal128_neg_nan_removes_nan", + setup_docs=[{"_id": 1, "a": [DECIMAL128_NEGATIVE_NAN, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [DECIMAL128_NEGATIVE_NAN]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Should remove Decimal128 -NaN elements", + ), + UpdateTestCase( + "float_nan_matches_float_neg_nan", + setup_docs=[{"_id": 1, "a": [FLOAT_NEGATIVE_NAN, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [FLOAT_NAN]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="float NaN should match float -NaN (NaN sign equivalence)", + ), + UpdateTestCase( + "decimal128_nan_matches_decimal128_neg_nan", + setup_docs=[{"_id": 1, "a": [DECIMAL128_NEGATIVE_NAN, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [DECIMAL128_NAN]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="Decimal128 NaN should match Decimal128 -NaN (NaN sign equivalence)", + ), + UpdateTestCase( + "float_neg_nan_matches_decimal128_nan", + setup_docs=[{"_id": 1, "a": [DECIMAL128_NAN, 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [FLOAT_NEGATIVE_NAN]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="float -NaN should match Decimal128 NaN (cross-type NaN equivalence)", + ), + UpdateTestCase( + "float_neg_zero_removes_decimal128_zero", + setup_docs=[{"_id": 1, "a": [Decimal128("0"), 1, 2]}], + query={"_id": 1}, + update={"$pullAll": {"a": [DOUBLE_NEGATIVE_ZERO]}}, + expected={"_id": 1, "a": [1, 2]}, + msg="float -0.0 should remove Decimal128 0 (cross-type negative zero equivalence)", + ), +] + +ALL_TESTS = NUMERIC_EQUIVALENCE_TESTS + BSON_TYPE_DISTINCTION_TESTS + NAN_INFINITY_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_pullAll_data_type_matching(collection, test: UpdateTestCase): + """Test $pullAll data type matching semantics.""" + collection.insert_many(test.setup_docs) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": test.query, "u": test.update}]}, + ) + result = execute_command(collection, {"find": collection.name, "filter": test.query}) + assertSuccess(result, [test.expected], msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_nested_fields.py b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_nested_fields.py new file mode 100644 index 000000000..f4e14443b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_nested_fields.py @@ -0,0 +1,90 @@ +"""Tests for $pullAll with dot notation and nested fields. + +Covers: deeply nested dot notation paths, intermediate path behavior, +positional operator, non-array argument rejection. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.update.utils import UpdateTestCase +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccess +from documentdb_tests.framework.error_codes import BAD_VALUE_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +NESTED_FIELD_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "dot_notation_deep", + setup_docs=[{"_id": 1, "a": {"b": {"c": [10, 20, 30]}}}], + query={"_id": 1}, + update={"$pullAll": {"a.b.c": [20]}}, + expected={"_id": 1, "a": {"b": {"c": [10, 30]}}}, + msg="Should remove from deeply nested array", + ), + UpdateTestCase( + "intermediate_does_not_exist_noop", + setup_docs=[{"_id": 1, "x": 1}], + query={"_id": 1}, + update={"$pullAll": {"a.b": [1]}}, + expected={"_id": 1, "x": 1}, + msg="Should be no-op when intermediate path does not exist", + ), + UpdateTestCase( + "dot_notation_array_index", + setup_docs=[{"_id": 1, "a": [{"b": [1, 2, 3]}, {"b": [4, 5, 6]}]}], + query={"_id": 1}, + update={"$pullAll": {"a.0.b": [2, 3]}}, + expected={"_id": 1, "a": [{"b": [1]}, {"b": [4, 5, 6]}]}, + msg="Should pull from specific array element via numeric index in dot notation", + ), + UpdateTestCase( + "array_intermediate_no_traversal", + setup_docs=[{"_id": 1, "a": [{"b": [1, 2, 3]}, {"b": [2, 3, 4]}]}], + query={"_id": 1}, + update={"$pullAll": {"a.b": [2]}}, + expected={"_id": 1, "a": [{"b": [1, 2, 3]}, {"b": [2, 3, 4]}]}, + msg="Should be no-op when intermediate is an array without explicit index", + ), + UpdateTestCase( + "positional_operator", + setup_docs=[{"_id": 1, "a": [{"b": [1, 2, 3]}, {"b": [2, 4, 5]}]}], + query={"a.b": 2}, + update={"$pullAll": {"a.$.b": [2]}}, + expected={"_id": 1, "a": [{"b": [1, 3]}, {"b": [2, 4, 5]}]}, + msg="Should pull from first matched array element via positional operator", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(NESTED_FIELD_TESTS)) +def test_pullAll_nested_fields(collection, test: UpdateTestCase): + """Test $pullAll with dot notation and nested fields.""" + collection.insert_many(test.setup_docs) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": test.query, "u": test.update}]}, + ) + result = execute_command(collection, {"find": collection.name, "filter": test.query}) + assertSuccess(result, [test.expected], msg=test.msg) + + +def test_pullAll_nested_object_argument_rejected(collection): + """Test $pullAll rejects object argument on nested path.""" + collection.insert_one({"_id": 1, "a": {"b": {"c": [10, 20, 30]}}}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$pullAll": {"a.b.c": [20], "a.b": {"c": [10, 20, 30]}}}, + } + ], + }, + ) + assertFailureCode( + result, + BAD_VALUE_ERROR, + msg="$pullAll should reject object argument on nested path", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_update_integration.py b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_update_integration.py new file mode 100644 index 000000000..0d6500e1d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_update_integration.py @@ -0,0 +1,131 @@ +"""Tests for $pullAll update command integration. + +Covers: updateOne, updateMany, bulkWrite, upsert behavior, large arrays. +""" + +from documentdb_tests.framework.assertions import assertSuccess, assertSuccessPartial +from documentdb_tests.framework.executor import execute_command + + +def test_pullAll_updateOne(collection): + """Test $pullAll with updateOne only modifies one document when multiple match.""" + collection.insert_many( + [ + {"q": 1, "a": [1, 2, 3]}, + {"q": 1, "a": [1, 2, 3]}, + ] + ) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"q": 1}, "u": {"$pullAll": {"a": [2, 3]}}}]}, + ) + assertSuccessPartial( + result, {"n": 1, "nModified": 1, "ok": 1.0}, msg="updateOne should succeed" + ) + + +def test_pullAll_updateMany(collection): + """Test $pullAll with updateMany processes each matched document independently.""" + collection.insert_many( + [ + {"_id": 1, "a": [1, 2, 3]}, + {"_id": 2, "a": [2, 3, 4]}, + {"_id": 3, "a": [5, 6, 7]}, + ] + ) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {}, "u": {"$pullAll": {"a": [2, 3]}}, "multi": True}], + }, + ) + result = execute_command( + collection, {"find": collection.name, "filter": {}, "sort": {"_id": 1}} + ) + assertSuccess( + result, + [ + {"_id": 1, "a": [1]}, + {"_id": 2, "a": [4]}, + {"_id": 3, "a": [5, 6, 7]}, + ], + msg="updateMany should process each doc independently", + ) + + +def test_pullAll_bulkWrite(collection): + """Test $pullAll in bulkWrite updates multiple documents correctly.""" + collection.insert_many( + [ + {"_id": 1, "a": [1, 2, 3]}, + {"_id": 2, "a": [4, 5, 6]}, + ] + ) + execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 1}, "u": {"$pullAll": {"a": [1]}}}, + {"q": {"_id": 2}, "u": {"$pullAll": {"a": [5, 6]}}}, + ], + }, + ) + result = execute_command( + collection, {"find": collection.name, "filter": {}, "sort": {"_id": 1}} + ) + assertSuccess( + result, + [{"_id": 1, "a": [2, 3]}, {"_id": 2, "a": [4]}], + msg="bulkWrite should update both docs correctly", + ) + + +def test_pullAll_upsert_creates_doc_without_array(collection): + """Test $pullAll with upsert:true creates doc without array field.""" + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 99}, "u": {"$pullAll": {"a": [1, 2]}}, "upsert": True}], + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 99}}) + assertSuccess(result, [{"_id": 99}], msg="Upsert should create doc without array field") + + +def test_pullAll_large_array(collection): + """Test $pullAll removing many values from a large array.""" + large_array = list(range(200)) + collection.insert_one({"_id": 1, "a": large_array}) + values_to_remove = list(range(0, 200, 2)) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$pullAll": {"a": values_to_remove}}}], + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess( + result, + [{"_id": 1, "a": list(range(1, 200, 2))}], + msg="Should remove even numbers from large array", + ) + + +def test_pullAll_large_values_list(collection): + """Test $pullAll with large values list (100+ values).""" + collection.insert_one({"_id": 1, "a": list(range(50))}) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$pullAll": {"a": list(range(150))}}}], + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess( + result, [{"_id": 1, "a": []}], msg="Should remove all elements with large values list" + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_value_matching.py b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_value_matching.py new file mode 100644 index 000000000..5bdfe5953 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/pullAll/test_pullAll_value_matching.py @@ -0,0 +1,115 @@ +"""Tests for $pullAll value matching behavior. + +Covers: array element matching (order-sensitive), document matching +(field-order-sensitive), input correlation, mixed-type exact matching. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.update.utils import UpdateTestCase +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +PULLALL_VALUE_MATCHING_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "array_exact_match_removes", + setup_docs=[{"_id": 1, "a": [[1, 2, 3], [4, 5, 6]]}], + query={"_id": 1}, + update={"$pullAll": {"a": [[1, 2, 3]]}}, + expected={"_id": 1, "a": [[4, 5, 6]]}, + msg="Should remove exact array matches", + ), + UpdateTestCase( + "array_different_order_no_remove", + setup_docs=[{"_id": 1, "a": [[1, 2, 3], [4, 5, 6]]}], + query={"_id": 1}, + update={"$pullAll": {"a": [[3, 2, 1]]}}, + expected={"_id": 1, "a": [[1, 2, 3], [4, 5, 6]]}, + msg="Should NOT remove array with different order", + ), + UpdateTestCase( + "values_list_removes_individual_not_subarray", + setup_docs=[{"_id": 1, "a": [1, 2, 3, [1, 2, 3]]}], + query={"_id": 1}, + update={"$pullAll": {"a": [1, 2, 3]}}, + expected={"_id": 1, "a": [[1, 2, 3]]}, + msg="Should remove individual elements, not the subarray", + ), + UpdateTestCase( + "doc_exact_match_removes", + setup_docs=[{"_id": 1, "a": [{"a": 1, "b": 2}, {"c": 3}]}], + query={"_id": 1}, + update={"$pullAll": {"a": [{"a": 1, "b": 2}]}}, + expected={"_id": 1, "a": [{"c": 3}]}, + msg="Should remove document with exact field order match", + ), + UpdateTestCase( + "doc_different_field_order_no_remove", + setup_docs=[{"_id": 1, "a": [{"a": 1, "b": 2}, {"c": 3}]}], + query={"_id": 1}, + update={"$pullAll": {"a": [{"b": 2, "a": 1}]}}, + expected={"_id": 1, "a": [{"a": 1, "b": 2}, {"c": 3}]}, + msg="Should NOT remove document with different field order (unlike $pull)", + ), + UpdateTestCase( + "nested_doc_field_order_must_match", + setup_docs=[{"_id": 1, "a": [{"x": {"a": 1, "b": 2}}]}], + query={"_id": 1}, + update={"$pullAll": {"a": [{"x": {"b": 2, "a": 1}}]}}, + expected={"_id": 1, "a": [{"x": {"a": 1, "b": 2}}]}, + msg="Nested document field order must match at all levels", + ), + UpdateTestCase( + "nested_doc_exact_match_removes", + setup_docs=[{"_id": 1, "a": [{"x": {"a": 1, "b": 2}}]}], + query={"_id": 1}, + update={"$pullAll": {"a": [{"x": {"a": 1, "b": 2}}]}}, + expected={"_id": 1, "a": []}, + msg="Should remove nested document with exact match", + ), + UpdateTestCase( + "only_exact_field_order_removed", + setup_docs=[{"_id": 1, "a": [{"a": 1, "b": 2}, {"b": 2, "a": 1}, {"a": 1, "b": 2}]}], + query={"_id": 1}, + update={"$pullAll": {"a": [{"a": 1, "b": 2}]}}, + expected={"_id": 1, "a": [{"b": 2, "a": 1}]}, + msg="Should only remove exact field-order matches", + ), + UpdateTestCase( + "only_exact_element_order_removed", + setup_docs=[{"_id": 1, "a": [[1, 2], [2, 1], [1, 2]]}], + query={"_id": 1}, + update={"$pullAll": {"a": [[1, 2]]}}, + expected={"_id": 1, "a": [[2, 1]]}, + msg="Should only remove exact element-order matches", + ), + UpdateTestCase( + "array_values_mixed_types_exact_match", + setup_docs=[{"_id": 1, "a": [[1, "two", True], [True, "two", 1]]}], + query={"_id": 1}, + update={"$pullAll": {"a": [[1, "two", True]]}}, + expected={"_id": 1, "a": [[True, "two", 1]]}, + msg="Should use exact match for array values with mixed types", + ), + UpdateTestCase( + "partial_doc_does_not_match", + setup_docs=[{"_id": 1, "a": [{"a": 1, "b": 2, "c": 3}, {"d": 4}]}], + query={"_id": 1}, + update={"$pullAll": {"a": [{"a": 1, "b": 2}]}}, + expected={"_id": 1, "a": [{"a": 1, "b": 2, "c": 3}, {"d": 4}]}, + msg="Partial document should NOT match (unlike $pull which does subset matching)", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(PULLALL_VALUE_MATCHING_TESTS)) +def test_pullAll_value_matching(collection, test: UpdateTestCase): + """Test $pullAll value matching semantics.""" + collection.insert_many(test.setup_docs) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": test.query, "u": test.update}]}, + ) + result = execute_command(collection, {"find": collection.name, "filter": test.query}) + assertSuccess(result, [test.expected], msg=test.msg)