From e94e0010eee19d44a30c7618107ce828e6c0fa6b Mon Sep 17 00:00:00 2001 From: "Victor [C] Tsang" Date: Fri, 19 Jun 2026 18:54:54 +0000 Subject: [PATCH] Add query and write tests for bulkWrite Signed-off-by: Victor [C] Tsang --- .../operations/test_operations_bulk_write.py | 106 +-- .../test_bulkWrite_argument_validation.py | 113 ++++ .../test_bulkWrite_bson_type_validation.py | 380 +++++++++++ .../test_bulkWrite_bypass_validation.py | 389 +++++++++++ .../bulkWrite/test_bulkWrite_core_delete.py | 106 +++ .../bulkWrite/test_bulkWrite_core_insert.py | 313 +++++++++ .../bulkWrite/test_bulkWrite_core_update.py | 262 ++++++++ .../bulkWrite/test_bulkWrite_errors.py | 608 ++++++++++++++++++ .../bulkWrite/test_bulkWrite_let_variables.py | 228 +++++++ .../test_bulkWrite_mixed_operations.py | 208 ++++++ .../test_bulkWrite_response_structure.py | 200 ++++++ .../bulkWrite/test_bulkWrite_sub_features.py | 174 +++++ 12 files changed, 3039 insertions(+), 48 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_argument_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_bson_type_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_bypass_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_delete.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_insert.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_update.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_let_variables.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_mixed_operations.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_response_structure.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_sub_features.py diff --git a/documentdb_tests/compatibility/tests/core/collation/command_level/operations/test_operations_bulk_write.py b/documentdb_tests/compatibility/tests/core/collation/command_level/operations/test_operations_bulk_write.py index 49a630c78..1e6fbc44d 100644 --- a/documentdb_tests/compatibility/tests/core/collation/command_level/operations/test_operations_bulk_write.py +++ b/documentdb_tests/compatibility/tests/core/collation/command_level/operations/test_operations_bulk_write.py @@ -1,4 +1,4 @@ -"""Tests for collation in bulkWrite operations.""" +"""Tests for collation in the bulkWrite command.""" from __future__ import annotations @@ -8,13 +8,14 @@ CommandContext, CommandTestCase, ) -from documentdb_tests.framework.assertions import assertResult -from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.target_collection import CustomCollection # Property [BulkWrite Update Collation]: individual update operations within a -# bulkWrite can specify collation, affecting filter matching independently. +# bulkWrite command can specify collation, affecting filter matching +# independently of other operations in the same command. COLLATION_BULK_UPDATE_TESTS: list[CommandTestCase] = [ CommandTestCase( "bulk_update_case_insensitive", @@ -23,21 +24,24 @@ {"_id": 2, "x": "banana", "v": 1}, ], command=lambda ctx: { - "update": ctx.collection, - "updates": [ + "bulkWrite": 1, + "ops": [ { - "q": {"x": "apple"}, - "u": {"$set": {"v": 2}}, + "update": 0, + "filter": {"x": "apple"}, + "updateMods": {"$set": {"v": 2}}, "collation": {"locale": "en", "strength": 2}, }, { - "q": {"x": "BANANA"}, - "u": {"$set": {"v": 3}}, + "update": 0, + "filter": {"x": "BANANA"}, + "updateMods": {"$set": {"v": 3}}, "collation": {"locale": "en", "strength": 2}, }, ], + "nsInfo": [{"ns": ctx.namespace}], }, - expected={"ok": 1.0, "n": 2, "nModified": 2}, + expected={"ok": 1.0, "nMatched": 2, "nModified": 2}, msg="bulkWrite updates should each use their own collation", ), CommandTestCase( @@ -47,26 +51,30 @@ {"_id": 2, "x": "banana", "v": 1}, ], command=lambda ctx: { - "update": ctx.collection, - "updates": [ + "bulkWrite": 1, + "ops": [ { - "q": {"x": "apple"}, - "u": {"$set": {"v": 2}}, + "update": 0, + "filter": {"x": "apple"}, + "updateMods": {"$set": {"v": 2}}, "collation": {"locale": "en", "strength": 2}, }, { - "q": {"x": "BANANA"}, - "u": {"$set": {"v": 3}}, + "update": 0, + "filter": {"x": "BANANA"}, + "updateMods": {"$set": {"v": 3}}, }, ], + "nsInfo": [{"ns": ctx.namespace}], }, - expected={"ok": 1.0, "n": 1, "nModified": 1}, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, msg="bulkWrite with mixed collation: only collated op should match case-insensitively", ), ] # Property [BulkWrite Delete Collation]: individual delete operations within a -# bulkWrite can specify collation, affecting filter matching independently. +# bulkWrite command can specify collation, affecting filter matching +# independently of other operations in the same command. COLLATION_BULK_DELETE_TESTS: list[CommandTestCase] = [ CommandTestCase( "bulk_delete_case_insensitive", @@ -76,16 +84,18 @@ {"_id": 3, "x": "cherry"}, ], command=lambda ctx: { - "delete": ctx.collection, - "deletes": [ + "bulkWrite": 1, + "ops": [ { - "q": {"x": "apple"}, - "limit": 0, + "delete": 0, + "filter": {"x": "apple"}, + "multi": True, "collation": {"locale": "en", "strength": 2}, }, ], + "nsInfo": [{"ns": ctx.namespace}], }, - expected={"ok": 1.0, "n": 1}, + expected={"ok": 1.0, "nDeleted": 1}, msg="bulkWrite delete with collation should match case-insensitively", ), CommandTestCase( @@ -96,15 +106,17 @@ {"_id": 3, "x": "banana"}, ], command=lambda ctx: { - "delete": ctx.collection, - "deletes": [ + "bulkWrite": 1, + "ops": [ { - "q": {"x": "apple"}, - "limit": 0, + "delete": 0, + "filter": {"x": "apple"}, + "multi": True, }, ], + "nsInfo": [{"ns": ctx.namespace}], }, - expected={"ok": 1.0, "n": 1}, + expected={"ok": 1.0, "nDeleted": 1}, msg="bulkWrite delete without collation should use binary comparison", ), ] @@ -120,15 +132,17 @@ {"_id": 2, "x": "banana", "v": 1}, ], command=lambda ctx: { - "update": ctx.collection, - "updates": [ + "bulkWrite": 1, + "ops": [ { - "q": {"x": "apple"}, - "u": {"$set": {"v": 2}}, + "update": 0, + "filter": {"x": "apple"}, + "updateMods": {"$set": {"v": 2}}, }, ], + "nsInfo": [{"ns": ctx.namespace}], }, - expected={"ok": 1.0, "n": 1, "nModified": 1}, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, msg="bulkWrite update should inherit collection default collation", ), CommandTestCase( @@ -139,15 +153,17 @@ {"_id": 2, "x": "banana"}, ], command=lambda ctx: { - "delete": ctx.collection, - "deletes": [ + "bulkWrite": 1, + "ops": [ { - "q": {"x": "apple"}, - "limit": 0, + "delete": 0, + "filter": {"x": "apple"}, + "multi": True, }, ], + "nsInfo": [{"ns": ctx.namespace}], }, - expected={"ok": 1.0, "n": 1}, + expected={"ok": 1.0, "nDeleted": 1}, msg="bulkWrite delete should inherit collection default collation", ), ] @@ -161,14 +177,8 @@ @pytest.mark.parametrize("test", pytest_params(COLLATION_BULK_WRITE_TESTS)) def test_collation_bulk_write(database_client, collection, test): - """Test collation behavior in bulkWrite operations.""" + """Test collation behavior in the bulkWrite command.""" collection = test.prepare(database_client, collection) ctx = CommandContext.from_collection(collection) - result = execute_command(collection, test.build_command(ctx)) - assertResult( - result, - expected=test.build_expected(ctx), - error_code=test.error_code, - msg=test.msg, - raw_res=True, - ) + result = execute_admin_command(collection, test.build_command(ctx)) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_argument_validation.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_argument_validation.py new file mode 100644 index 000000000..775a74499 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_argument_validation.py @@ -0,0 +1,113 @@ +"""Tests for bulkWrite argument acceptance — valid ops, nsInfo, and optional fields.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +BULKWRITE_ARGUMENT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "ops_single_operation", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1}}]}, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should accept ops with a single operation", + ), + CommandTestCase( + "ops_many_operations", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": i}} for i in range(50)], + }, + expected={"ok": 1.0, "nInserted": 50}, + msg="bulkWrite should accept ops with many operations", + ), + CommandTestCase( + "let_with_document", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"$expr": {"$eq": ["$x", "$$targetVal"]}}, + "updateMods": {"$set": {"matched": True}}, + } + ], + "let": {"targetVal": 10}, + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite should accept a let document with variables", + ), + CommandTestCase( + "ops_only_updates", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command={ + "bulkWrite": 1, + "ops": [ + {"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": 10}}}, + {"update": 0, "filter": {"_id": 2}, "updateMods": {"$set": {"x": 20}}}, + ], + }, + expected={"ok": 1.0, "nMatched": 2, "nModified": 2}, + msg="bulkWrite should accept an ops array of only updates", + ), + CommandTestCase( + "ops_only_deletes", + docs=[{"_id": 1}, {"_id": 2}], + command={ + "bulkWrite": 1, + "ops": [ + {"delete": 0, "filter": {"_id": 1}}, + {"delete": 0, "filter": {"_id": 2}}, + ], + }, + expected={"ok": 1.0, "nDeleted": 2}, + msg="bulkWrite should accept an ops array of only deletes", + ), + CommandTestCase( + "writeConcern_valid", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "writeConcern": {"w": 1}, + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should accept a valid writeConcern", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(BULKWRITE_ARGUMENT_TESTS)) +def test_bulkWrite_argument_validation(database_client, collection, test): + """Test bulkWrite argument acceptance — valid ops, nsInfo, and optional fields.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = test.build_command(ctx) + if "nsInfo" not in command: + command = {**command, "nsInfo": [{"ns": ctx.namespace}]} + result = execute_admin_command(collection, command) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + + +def test_bulkWrite_accepts_unused_nsInfo_entry(collection): + """Test bulkWrite accepts an nsInfo array with more namespaces than the ops reference.""" + ns = f"{collection.database.name}.{collection.name}" + ns_unused = f"{collection.database.name}.{collection.name}_unused" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "nsInfo": [{"ns": ns}, {"ns": ns_unused}], + }, + ) + assertSuccessPartial( + result, + {"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should accept an nsInfo entry that no op references", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_bson_type_validation.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_bson_type_validation.py new file mode 100644 index 000000000..6657bb102 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_bson_type_validation.py @@ -0,0 +1,380 @@ +"""Tests for bulkWrite BSON type validation of command, operation, and namespace fields. + +Verifies that each bulkWrite input field accepts its valid BSON types and rejects +all other types with the correct error code, using the shared BSON type harness. +""" + +from typing import Any + +import pytest + +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccessPartial +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 ( + FAILED_TO_PARSE_ERROR, + MISSING_FIELD_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ZERO, + DOUBLE_ZERO, + INT32_ZERO, + INT64_ZERO, +) + +COMMAND_FIELD_PARAMS: list[BsonTypeTestCase] = [ + BsonTypeTestCase( + id="command_bulkWrite", + keyword="bulkWrite", + msg="bulkWrite dispatches on the first field being named 'bulkWrite'; its value is " + "ignored, so the command field accepts any BSON type", + valid_types=list(BsonType), + ), + BsonTypeTestCase( + id="command_ordered", + keyword="ordered", + msg="bulkWrite ordered accepts only bool and null", + valid_types=[BsonType.BOOL, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="command_bypassDocumentValidation", + keyword="bypassDocumentValidation", + msg="bulkWrite bypassDocumentValidation accepts bool, null, and numeric types", + valid_types=[ + BsonType.BOOL, + BsonType.NULL, + BsonType.DOUBLE, + BsonType.INT, + BsonType.LONG, + BsonType.DECIMAL, + ], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="command_comment", + keyword="comment", + msg="bulkWrite comment accepts any BSON type", + valid_types=list(BsonType), + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="command_let", + keyword="let", + msg="bulkWrite let accepts only a document or null", + valid_types=[BsonType.OBJECT, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="command_errorsOnly", + keyword="errorsOnly", + msg="bulkWrite errorsOnly accepts only bool and null", + valid_types=[BsonType.BOOL, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="command_cursor", + keyword="cursor", + msg="bulkWrite cursor accepts only a document or null", + valid_types=[BsonType.OBJECT, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + valid_inputs={BsonType.OBJECT: {"batchSize": 1}}, + ), + BsonTypeTestCase( + id="command_writeConcern", + keyword="writeConcern", + msg="bulkWrite writeConcern accepts only a document or null", + valid_types=[BsonType.OBJECT, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + valid_inputs={BsonType.OBJECT: {"w": 1}}, + ), +] + +NAMESPACE_PARAMS: list[BsonTypeTestCase] = [ + BsonTypeTestCase( + id="command_nsInfo", + msg="bulkWrite nsInfo accepts only an array", + valid_types=[BsonType.ARRAY], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, + ), + BsonTypeTestCase( + id="nsInfo_ns", + msg="bulkWrite nsInfo.ns accepts only a string", + valid_types=[BsonType.STRING], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, + ), +] + +INSERT_OP_PARAMS: list[BsonTypeTestCase] = [ + BsonTypeTestCase( + id="op_insert_index", + msg="bulkWrite insert namespace index accepts only numeric types", + valid_types=[BsonType.DOUBLE, BsonType.INT, BsonType.LONG, BsonType.DECIMAL], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, + valid_inputs={ + BsonType.DOUBLE: DOUBLE_ZERO, + BsonType.INT: INT32_ZERO, + BsonType.LONG: INT64_ZERO, + BsonType.DECIMAL: DECIMAL128_ZERO, + }, + ), + BsonTypeTestCase( + id="op_document", + msg="bulkWrite insert document accepts only a document", + valid_types=[BsonType.OBJECT], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, + ), +] + +UPDATE_OP_PARAMS: list[BsonTypeTestCase] = [ + BsonTypeTestCase( + id="op_update_index", + msg="bulkWrite update namespace index accepts only numeric types", + valid_types=[BsonType.DOUBLE, BsonType.INT, BsonType.LONG, BsonType.DECIMAL], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, + valid_inputs={ + BsonType.DOUBLE: DOUBLE_ZERO, + BsonType.INT: INT32_ZERO, + BsonType.LONG: INT64_ZERO, + BsonType.DECIMAL: DECIMAL128_ZERO, + }, + ), + BsonTypeTestCase( + id="op_filter", + msg="bulkWrite update filter accepts only a document", + valid_types=[BsonType.OBJECT], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, + ), + BsonTypeTestCase( + id="op_updateMods", + msg="bulkWrite updateMods accepts a document or an aggregation pipeline array", + valid_types=[BsonType.OBJECT, BsonType.ARRAY], + default_error_code=FAILED_TO_PARSE_ERROR, + valid_inputs={BsonType.ARRAY: [{"$set": {"x": 1}}]}, + ), + BsonTypeTestCase( + id="op_arrayFilters", + msg="bulkWrite arrayFilters accepts only an array or null", + valid_types=[BsonType.ARRAY, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + valid_inputs={BsonType.ARRAY: []}, + ), + BsonTypeTestCase( + id="op_multi", + msg="bulkWrite multi accepts only bool and null", + valid_types=[BsonType.BOOL, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="op_hint", + msg="bulkWrite hint accepts only a string or a document", + valid_types=[BsonType.STRING, BsonType.OBJECT], + default_error_code=FAILED_TO_PARSE_ERROR, + ), + BsonTypeTestCase( + id="op_constants", + msg="bulkWrite constants accepts only a document or null", + valid_types=[BsonType.OBJECT, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="op_collation", + msg="bulkWrite collation accepts only a document or null", + valid_types=[BsonType.OBJECT, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + valid_inputs={BsonType.OBJECT: {"locale": "en"}}, + ), + BsonTypeTestCase( + id="op_upsert", + msg="bulkWrite upsert accepts only bool and null", + valid_types=[BsonType.BOOL, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + ), +] + +DELETE_OP_PARAMS: list[BsonTypeTestCase] = [ + BsonTypeTestCase( + id="op_delete_index", + msg="bulkWrite delete namespace index accepts only numeric types", + valid_types=[BsonType.DOUBLE, BsonType.INT, BsonType.LONG, BsonType.DECIMAL], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, + valid_inputs={ + BsonType.DOUBLE: DOUBLE_ZERO, + BsonType.INT: INT32_ZERO, + BsonType.LONG: INT64_ZERO, + BsonType.DECIMAL: DECIMAL128_ZERO, + }, + ), + BsonTypeTestCase( + id="op_delete_filter", + msg="bulkWrite delete filter accepts only a document", + valid_types=[BsonType.OBJECT], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, + ), + BsonTypeTestCase( + id="op_delete_multi", + msg="bulkWrite delete multi accepts only bool and null", + valid_types=[BsonType.BOOL, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="op_delete_hint", + msg="bulkWrite delete hint accepts only a string or a document", + valid_types=[BsonType.STRING, BsonType.OBJECT], + default_error_code=FAILED_TO_PARSE_ERROR, + ), + BsonTypeTestCase( + id="op_delete_collation", + msg="bulkWrite delete collation accepts only a document or null", + valid_types=[BsonType.OBJECT, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + valid_inputs={BsonType.OBJECT: {"locale": "en"}}, + ), +] + +BULKWRITE_TYPE_PARAMS: list[BsonTypeTestCase] = ( + COMMAND_FIELD_PARAMS + NAMESPACE_PARAMS + INSERT_OP_PARAMS + UPDATE_OP_PARAMS + DELETE_OP_PARAMS +) + + +def _inject_command_nsInfo( + command: dict[str, Any], bson_type: BsonType, sample_value: Any, namespace: str +) -> None: + """Replace the whole ``nsInfo`` array (valid ARRAY uses the live namespace).""" + command["nsInfo"] = [{"ns": namespace}] if bson_type == BsonType.ARRAY else sample_value + + +def _inject_nsInfo_ns( + command: dict[str, Any], bson_type: BsonType, sample_value: Any, namespace: str +) -> None: + """Set ``nsInfo[0].ns`` (valid STRING uses the live namespace).""" + ns_val = namespace if bson_type == BsonType.STRING else sample_value + command["nsInfo"] = [{"ns": ns_val}] + + +def _inject_op_field(command: dict[str, Any], spec_id: str, sample_value: Any) -> None: + """Build the op under test with ``sample_value`` and set it as ``ops[0]``. + + Field order matters: the op discriminator (insert/update/delete) must be first, so + ``sample_value`` leads the ``*_index`` specs. + """ + ops_by_spec: dict[str, dict[str, Any]] = { + "op_insert_index": {"insert": sample_value, "document": {"_id": 1}}, + "op_document": {"insert": 0, "document": sample_value}, + "op_update_index": {"update": sample_value, "filter": {}, "updateMods": {"$set": {"x": 1}}}, + "op_filter": {"update": 0, "filter": sample_value, "updateMods": {"$set": {"x": 1}}}, + "op_updateMods": {"update": 0, "filter": {}, "updateMods": sample_value}, + "op_arrayFilters": { + "update": 0, + "filter": {}, + "updateMods": {"$set": {"x": 1}}, + "arrayFilters": sample_value, + }, + "op_multi": { + "update": 0, + "filter": {}, + "updateMods": {"$set": {"x": 1}}, + "multi": sample_value, + }, + "op_hint": { + "update": 0, + "filter": {}, + "updateMods": {"$set": {"x": 1}}, + "hint": sample_value, + }, + "op_constants": { + "update": 0, + "filter": {}, + "updateMods": [{"$set": {"x": 1}}], + "constants": sample_value, + }, + "op_collation": { + "update": 0, + "filter": {}, + "updateMods": {"$set": {"x": 1}}, + "collation": sample_value, + }, + "op_upsert": { + "update": 0, + "filter": {}, + "updateMods": {"$set": {"x": 1}}, + "upsert": sample_value, + }, + "op_delete_index": {"delete": sample_value, "filter": {}}, + "op_delete_filter": {"delete": 0, "filter": sample_value}, + "op_delete_multi": {"delete": 0, "filter": {}, "multi": sample_value}, + "op_delete_hint": {"delete": 0, "filter": {}, "hint": sample_value}, + "op_delete_collation": {"delete": 0, "filter": {}, "collation": sample_value}, + } + command["ops"] = [ops_by_spec[spec_id]] + + +def _inject_top_level(command: dict[str, Any], spec: BsonTypeTestCase, sample_value: Any) -> None: + """Set a top-level command field directly on the command document.""" + if spec.keyword is None: + raise ValueError(f"top-level spec {spec.id!r} must define a keyword") + command[spec.keyword] = sample_value + + +def _build_command( + spec: BsonTypeTestCase, bson_type: BsonType, sample_value: Any, namespace: str +) -> dict[str, Any]: + """Build a bulkWrite command injecting ``sample_value`` into the field named by ``spec``.""" + command: dict[str, Any] = { + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "nsInfo": [{"ns": namespace}], + } + + if spec.id == "command_nsInfo": + _inject_command_nsInfo(command, bson_type, sample_value, namespace) + elif spec.id == "nsInfo_ns": + _inject_nsInfo_ns(command, bson_type, sample_value, namespace) + elif spec.id.startswith("op_"): + _inject_op_field(command, spec.id, sample_value) + else: + _inject_top_level(command, spec, sample_value) + + return command + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", generate_bson_rejection_test_cases(BULKWRITE_TYPE_PARAMS) +) +def test_bulkWrite_bson_type_rejected(collection, bson_type, sample_value, spec): + """Test bulkWrite rejects invalid BSON types for each input field with the correct code.""" + namespace = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, _build_command(spec, bson_type, sample_value, namespace) + ) + assertFailureCode( + result, spec.expected_code(bson_type), msg=f"{spec.msg}: rejects {bson_type.value}" + ) + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", generate_bson_acceptance_test_cases(BULKWRITE_TYPE_PARAMS) +) +def test_bulkWrite_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test bulkWrite accepts valid BSON types for each input field.""" + namespace = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, _build_command(spec, bson_type, sample_value, namespace) + ) + assertSuccessPartial( + result, {"ok": 1.0, "nErrors": 0}, msg=f"{spec.msg}: accepts {bson_type.value}" + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_bypass_validation.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_bypass_validation.py new file mode 100644 index 000000000..243c9d988 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_bypass_validation.py @@ -0,0 +1,389 @@ +"""Tests for bulkWrite bypassDocumentValidation value coercion and type rejection. + +Mirrors ``aggregate/test_aggregate_bypass_validation.py``. bulkWrite-specific: a validator +failure surfaces as an op-level error (``nErrors:1``, ``cursor.firstBatch.0.code`` 121). +""" + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + DOCUMENT_VALIDATION_FAILURE_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.target_collection import CustomCollection +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + FLOAT_NEGATIVE_NAN, + INT32_ZERO, +) + +_VALIDATOR = {"$jsonSchema": {"bsonType": "object", "required": ["name"]}} + + +def _validated_collection() -> CustomCollection: + return CustomCollection(options={"validator": _VALIDATOR}) + + +_BYPASS = {"ok": Eq(1.0), "nErrors": Eq(0), "nInserted": Eq(1)} + +BULKWRITE_BYPASS_TRUTHY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "bypass_truthy_true", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": True, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=true (truthy) should bypass the validator", + ), + CommandTestCase( + "bypass_truthy_int32_1", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": 1, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=int32 1 (truthy) should bypass the validator", + ), + CommandTestCase( + "bypass_truthy_int64_1", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": Int64(1), + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=Int64 1 (truthy) should bypass the validator", + ), + CommandTestCase( + "bypass_truthy_double_1", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": 1.0, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=double 1.0 (truthy) should bypass the validator", + ), + CommandTestCase( + "bypass_truthy_decimal128_1", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": Decimal128("1"), + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=Decimal128 1 (truthy) should bypass the validator", + ), + CommandTestCase( + "bypass_truthy_nan", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": FLOAT_NAN, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=NaN (truthy) should bypass the validator", + ), + CommandTestCase( + "bypass_truthy_neg_nan", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": FLOAT_NEGATIVE_NAN, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=-NaN (truthy) should bypass the validator", + ), + CommandTestCase( + "bypass_truthy_decimal128_nan", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": DECIMAL128_NAN, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=Decimal128 NaN (truthy) should bypass", + ), + CommandTestCase( + "bypass_truthy_decimal128_neg_nan", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": DECIMAL128_NEGATIVE_NAN, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=Decimal128 -NaN (truthy) should bypass", + ), + CommandTestCase( + "bypass_truthy_infinity", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": FLOAT_INFINITY, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=Infinity (truthy) should bypass the validator", + ), + CommandTestCase( + "bypass_truthy_neg_infinity", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": FLOAT_NEGATIVE_INFINITY, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=-Infinity (truthy) should bypass the validator", + ), + CommandTestCase( + "bypass_truthy_decimal128_infinity", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": DECIMAL128_INFINITY, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=Decimal128 Infinity (truthy) should bypass", + ), + CommandTestCase( + "bypass_truthy_decimal128_neg_infinity", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": DECIMAL128_NEGATIVE_INFINITY, + }, + expected=_BYPASS, + msg="bulkWrite bypassDocumentValidation=Decimal128 -Infinity (truthy) should bypass", + ), +] + +_ENFORCE = { + "ok": Eq(1.0), + "nErrors": Eq(1), + "nInserted": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(DOCUMENT_VALIDATION_FAILURE_ERROR), +} + +BULKWRITE_BYPASS_FALSY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "bypass_falsy_false", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": False, + }, + expected=_ENFORCE, + msg="bulkWrite bypassDocumentValidation=false (falsy) should enforce the validator", + ), + CommandTestCase( + "bypass_falsy_int32_0", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": INT32_ZERO, + }, + expected=_ENFORCE, + msg="bulkWrite bypassDocumentValidation=int32 0 (falsy) should enforce the validator", + ), + CommandTestCase( + "bypass_falsy_double_0", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": DOUBLE_ZERO, + }, + expected=_ENFORCE, + msg="bulkWrite bypassDocumentValidation=double 0.0 (falsy) should enforce the validator", + ), + CommandTestCase( + "bypass_falsy_double_neg_zero", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": DOUBLE_NEGATIVE_ZERO, + }, + expected=_ENFORCE, + msg="bulkWrite bypassDocumentValidation=double -0.0 (falsy) should enforce the validator", + ), + CommandTestCase( + "bypass_falsy_decimal128_0", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": DECIMAL128_ZERO, + }, + expected=_ENFORCE, + msg="bulkWrite bypassDocumentValidation=Decimal128 0 (falsy) should enforce the validator", + ), + CommandTestCase( + "bypass_falsy_decimal128_neg_zero", + target_collection=_validated_collection(), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": DECIMAL128_NEGATIVE_ZERO, + }, + expected=_ENFORCE, + msg="bulkWrite bypassDocumentValidation=Decimal128 -0 (falsy) should enforce the validator", + ), +] + +_OPS = [{"insert": 0, "document": {"_id": 1}}] + +BULKWRITE_BYPASS_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "bypass_reject_string", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": "hello"}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject string for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_array", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": [1, 2]}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject array for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_document", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": {"a": 1}}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject document for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_objectid", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": ObjectId()}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject objectid for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_datetime", + command={ + "bulkWrite": 1, + "ops": _OPS, + "bypassDocumentValidation": datetime(2024, 1, 1, tzinfo=timezone.utc), + }, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject datetime for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_timestamp", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": Timestamp(1, 1)}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject timestamp for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_regex", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": Regex(".*")}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject regex for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_binary", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": Binary(b"hello")}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject binary for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_code", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": Code("function(){}")}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject code for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_code_with_scope", + command={ + "bulkWrite": 1, + "ops": _OPS, + "bypassDocumentValidation": Code("function(){}", {"x": 1}), + }, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject code_with_scope for bypassDocumentValidation", + ), + CommandTestCase( + "bypass_reject_minkey", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": MinKey()}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject minkey for bypassDocumentValidation with TypeMismatch", + ), + CommandTestCase( + "bypass_reject_maxkey", + command={"bulkWrite": 1, "ops": _OPS, "bypassDocumentValidation": MaxKey()}, + error_code=TYPE_MISMATCH_ERROR, + msg="bulkWrite should reject maxkey for bypassDocumentValidation with TypeMismatch", + ), +] + +BULKWRITE_BYPASS_VALIDATION_TESTS = ( + BULKWRITE_BYPASS_TRUTHY_TESTS + BULKWRITE_BYPASS_FALSY_TESTS + BULKWRITE_BYPASS_REJECTION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(BULKWRITE_BYPASS_VALIDATION_TESTS)) +def test_bulkWrite_bypass_validation(database_client, collection, test): + """Test bulkWrite bypassDocumentValidation value coercion and type rejection.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = test.build_command(ctx) + if "nsInfo" not in command: + command = {**command, "nsInfo": [{"ns": ctx.namespace}]} + result = execute_admin_command(collection, command) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_delete.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_delete.py new file mode 100644 index 000000000..0610402ab --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_delete.py @@ -0,0 +1,106 @@ +"""Tests for bulkWrite core delete operations.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertSuccess, assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +BULKWRITE_DELETE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "single_delete", + docs=[{"_id": 1, "x": 1}], + command={"bulkWrite": 1, "ops": [{"delete": 0, "filter": {"_id": 1}}]}, + expected={"ok": 1.0, "nDeleted": 1}, + msg="bulkWrite should perform a single delete", + ), + CommandTestCase( + "delete_no_match", + docs=[{"_id": 1, "x": 1}], + command={"bulkWrite": 1, "ops": [{"delete": 0, "filter": {"_id": 999}}]}, + expected={"ok": 1.0, "nDeleted": 0}, + msg="bulkWrite delete with a non-matching filter should delete nothing", + ), + CommandTestCase( + "delete_multi_true", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 1}, {"_id": 3, "x": 2}], + command={"bulkWrite": 1, "ops": [{"delete": 0, "filter": {"x": 1}, "multi": True}]}, + expected={"ok": 1.0, "nDeleted": 2}, + msg="bulkWrite delete with multi:true should delete all matching documents", + ), + CommandTestCase( + "delete_multi_false", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 1}], + command={"bulkWrite": 1, "ops": [{"delete": 0, "filter": {"x": 1}, "multi": False}]}, + expected={"ok": 1.0, "nDeleted": 1}, + msg="bulkWrite delete with multi:false should delete only the first match", + ), + CommandTestCase( + "delete_nonexistent_collection", + command={"bulkWrite": 1, "ops": [{"delete": 0, "filter": {"x": 1}}]}, + expected={"ok": 1.0, "nDeleted": 0}, + msg="bulkWrite delete on a non-existent collection should delete nothing", + ), + CommandTestCase( + "delete_empty_collection", + docs=[], + command={"bulkWrite": 1, "ops": [{"delete": 0, "filter": {"x": 1}}]}, + expected={"ok": 1.0, "nDeleted": 0}, + msg="bulkWrite delete on an empty collection should delete nothing", + ), + CommandTestCase( + "insert_then_delete", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "x": 1}}, + {"delete": 0, "filter": {"_id": 1}}, + ], + }, + expected={"ok": 1.0, "nInserted": 1, "nDeleted": 1}, + msg="bulkWrite should insert then delete the same document", + ), + CommandTestCase( + "delete_without_multi_deletes_one", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 1}, {"_id": 3, "x": 1}], + command={"bulkWrite": 1, "ops": [{"delete": 0, "filter": {"x": 1}}]}, + expected={"ok": 1.0, "nDeleted": 1}, + msg="bulkWrite delete without the multi flag should delete only one document", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(BULKWRITE_DELETE_TESTS)) +def test_bulkWrite_core_delete(database_client, collection, test): + """Test bulkWrite core delete operations.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = test.build_command(ctx) + if "nsInfo" not in command: + command = {**command, "nsInfo": [{"ns": ctx.namespace}]} + result = execute_admin_command(collection, command) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + + +def test_bulkWrite_delete_namespace_isolation(collection): + """Test delete targeting one namespace does not affect documents in a different namespace.""" + sibling = collection.database[f"{collection.name}_b"] + sibling.drop() + collection.insert_one({"_id": 1, "x": 1}) + sibling.insert_one({"_id": 1, "x": 1}) + ns_a = f"{collection.database.name}.{collection.name}" + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [{"delete": 0, "filter": {"_id": 1}}], + "nsInfo": [{"ns": ns_a}], + }, + ) + other_ns = execute_command(sibling, {"find": sibling.name, "filter": {}}) + assertSuccess(other_ns, [{"_id": 1, "x": 1}]) + sibling.drop() diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_insert.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_insert.py new file mode 100644 index 000000000..7cb6d9ec2 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_insert.py @@ -0,0 +1,313 @@ +"""Tests for bulkWrite core insert operations, data type coverage, and document edge cases.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.target_collection import CappedCollection +from documentdb_tests.framework.test_constants import BSON_TYPE_SAMPLES, BsonType + +BULKWRITE_INSERT_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "insert_double", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.DOUBLE]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a double field value", + ), + CommandTestCase( + "insert_string", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.STRING]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a string field value", + ), + CommandTestCase( + "insert_object", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.OBJECT]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a object field value", + ), + CommandTestCase( + "insert_array", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.ARRAY]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a array field value", + ), + CommandTestCase( + "insert_bindata", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.BIN_DATA]}} + ], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a bindata field value", + ), + CommandTestCase( + "insert_objectid", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.OBJECT_ID]}} + ], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a objectid field value", + ), + CommandTestCase( + "insert_boolean", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.BOOL]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a boolean field value", + ), + CommandTestCase( + "insert_date", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.DATE]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a date field value", + ), + CommandTestCase( + "insert_null", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.NULL]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a null field value", + ), + CommandTestCase( + "insert_regex", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.REGEX]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a regex field value", + ), + CommandTestCase( + "insert_int", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.INT]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a int field value", + ), + CommandTestCase( + "insert_long", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.LONG]}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a long field value", + ), + CommandTestCase( + "insert_timestamp", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.TIMESTAMP]}} + ], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a timestamp field value", + ), + CommandTestCase( + "insert_decimal128", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.DECIMAL]}} + ], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a decimal128 field value", + ), + CommandTestCase( + "insert_minkey", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.MIN_KEY]}} + ], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a minkey field value", + ), + CommandTestCase( + "insert_maxkey", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.MAX_KEY]}} + ], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a maxkey field value", + ), + CommandTestCase( + "insert_javascript", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "v": BSON_TYPE_SAMPLES[BsonType.JAVASCRIPT]}} + ], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a javascript field value", + ), +] + +BULKWRITE_INSERT_CORE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "single_insert", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1, "a": 1}}]}, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should perform a single insert", + ), + CommandTestCase( + "multiple_inserts", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "a": 1}}, + {"insert": 0, "document": {"_id": 2, "a": 2}}, + {"insert": 0, "document": {"_id": 3, "a": 3}}, + ], + }, + expected={"ok": 1.0, "nInserted": 3}, + msg="bulkWrite should perform multiple inserts in one command", + ), + CommandTestCase( + "insert_with_specified_id", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 100, "x": "hello"}}]}, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with a specified _id", + ), + CommandTestCase( + "insert_auto_generated_id", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"x": "no_id"}}]}, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with an auto-generated _id", + ), + CommandTestCase( + "insert_into_nonexistent_collection", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1, "x": 1}}]}, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite insert should implicitly create a non-existent collection", + ), + CommandTestCase( + "insert_empty_document", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {}}]}, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert an empty document with an auto-generated _id", + ), + CommandTestCase( + "insert_deeply_nested", + command={ + "bulkWrite": 1, + "ops": [ + { + "insert": 0, + "document": { + "_id": 1, + "level0": { + "level1": { + "level2": { + "level3": { + "level4": { + "level5": { + "level6": { + "level7": { + "level8": { + "level9": { + "level10": { + "level11": {"value": "deep"} + } + } + } + } + } + } + } + } + } + } + }, + }, + } + ], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a deeply nested document", + ), + CommandTestCase( + "insert_long_field_names", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "a" * 200: "value"}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with very long field names", + ), + CommandTestCase( + "insert_dollar_prefixed_fields", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "$dollar": "value"}}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with dollar-prefixed field names", + ), + CommandTestCase( + "insert_dot_notation_fields", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1, "a.b": "value"}}]}, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert a document with dot-notation field names", + ), + CommandTestCase( + "insert_into_capped_collection", + target_collection=CappedCollection(size=1048576), + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1, "x": 1}}]}, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite should insert into a capped collection", + ), +] + +BULKWRITE_INSERT_TESTS = BULKWRITE_INSERT_TYPE_TESTS + BULKWRITE_INSERT_CORE_TESTS + + +@pytest.mark.parametrize("test", pytest_params(BULKWRITE_INSERT_TESTS)) +def test_bulkWrite_core_insert(database_client, collection, test): + """Test bulkWrite core insert operations, data type coverage, and document edge cases.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = test.build_command(ctx) + if "nsInfo" not in command: + command = {**command, "nsInfo": [{"ns": ctx.namespace}]} + result = execute_admin_command(collection, command) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_update.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_update.py new file mode 100644 index 000000000..044fb3ecc --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_core_update.py @@ -0,0 +1,262 @@ +"""Tests for bulkWrite core update operations, filter edge cases, and multi-operation handling.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import ( + assertResult, + assertSuccess, + assertSuccessPartial, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +BULKWRITE_UPDATE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "single_update", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": 20}}}], + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite should perform a single update", + ), + CommandTestCase( + "update_set_inc_unset", + docs=[{"_id": 1, "x": 10, "y": 5, "z": 1}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 1}, + "updateMods": {"$set": {"x": 20}, "$inc": {"y": 1}, "$unset": {"z": ""}}, + } + ], + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite update should apply $set, $inc, and $unset operators", + ), + CommandTestCase( + "update_pipeline", + docs=[{"_id": 1, "x": 10, "y": 5}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 1}, + "updateMods": [{"$set": {"x": 99}}, {"$unset": "y"}], + } + ], + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite update should accept an aggregation pipeline", + ), + CommandTestCase( + "update_no_match", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"_id": 999}, "updateMods": {"$set": {"x": 20}}}], + }, + expected={"ok": 1.0, "nMatched": 0, "nModified": 0}, + msg="bulkWrite update with a non-matching filter should match nothing", + ), + CommandTestCase( + "update_multi_true", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 1}, {"_id": 3, "x": 2}], + command={ + "bulkWrite": 1, + "ops": [ + {"update": 0, "filter": {"x": 1}, "updateMods": {"$set": {"x": 99}}, "multi": True} + ], + }, + expected={"ok": 1.0, "nMatched": 2, "nModified": 2}, + msg="bulkWrite update with multi:true should update all matching documents", + ), + CommandTestCase( + "update_multi_false", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 1}], + command={ + "bulkWrite": 1, + "ops": [ + {"update": 0, "filter": {"x": 1}, "updateMods": {"$set": {"x": 99}}, "multi": False} + ], + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite update with multi:false should update only the first match", + ), + CommandTestCase( + "upsert_true_no_match", + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 100}, + "updateMods": {"$set": {"x": 1}}, + "upsert": True, + } + ], + }, + expected={"ok": 1.0, "nUpserted": 1}, + msg="bulkWrite update with upsert:true and no match should insert a document", + ), + CommandTestCase( + "update_nonexistent_collection", + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"x": 1}, "updateMods": {"$set": {"x": 2}}}], + }, + expected={"ok": 1.0, "nMatched": 0, "nModified": 0}, + msg="bulkWrite update on a non-existent collection should match nothing", + ), + CommandTestCase( + "update_empty_collection", + docs=[], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"x": 1}, "updateMods": {"$set": {"x": 2}}}], + }, + expected={"ok": 1.0, "nMatched": 0, "nModified": 0}, + msg="bulkWrite update on an empty collection should match nothing", + ), + CommandTestCase( + "update_empty_filter_multi", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {}, "updateMods": {"$set": {"y": 1}}, "multi": True}], + }, + expected={"ok": 1.0, "nMatched": 2, "nModified": 2}, + msg="bulkWrite update with an empty filter and multi:true should match all documents", + ), + CommandTestCase( + "update_and_or_filter", + docs=[{"_id": 1, "x": 1, "y": 1}, {"_id": 2, "x": 1, "y": 2}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"$and": [{"x": 1}, {"$or": [{"y": 1}]}]}, + "updateMods": {"$set": {"z": 1}}, + } + ], + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite update should support $and/$or operators in the filter", + ), + CommandTestCase( + "update_regex_filter", + docs=[{"_id": 1, "name": "Alice"}, {"_id": 2, "name": "Bob"}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"name": {"$regex": "^A"}}, + "updateMods": {"$set": {"matched": True}}, + } + ], + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite update should support a regex filter", + ), + CommandTestCase( + "update_expr_filter", + docs=[{"_id": 1, "x": 5}, {"_id": 2, "x": 15}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"$expr": {"$gt": ["$x", 10]}}, + "updateMods": {"$set": {"big": True}}, + } + ], + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite update should support $expr in the filter", + ), + CommandTestCase( + "large_batch_updates", + docs=[{"_id": i, "x": i} for i in range(100)], + command={ + "bulkWrite": 1, + "ops": [ + {"update": 0, "filter": {"_id": i}, "updateMods": {"$set": {"x": i + 1}}} + for i in range(100) + ], + }, + expected={"ok": 1.0, "nMatched": 100, "nModified": 100}, + msg="bulkWrite should apply a large batch of update operations", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(BULKWRITE_UPDATE_TESTS)) +def test_bulkWrite_core_update(database_client, collection, test): + """Test bulkWrite core update operations, filter edge cases, and multi-operation handling.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = test.build_command(ctx) + if "nsInfo" not in command: + command = {**command, "nsInfo": [{"ns": ctx.namespace}]} + result = execute_admin_command(collection, command) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + + +def test_bulkWrite_sequential_ops_accumulate_on_same_document(collection): + """Test multiple ops on the same document in one bulkWrite apply sequentially (x: 1->2->12).""" + collection.insert_one({"_id": 1, "x": 1}) + ns = f"{collection.database.name}.{collection.name}" + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": 2}}}, + {"update": 0, "filter": {"_id": 1}, "updateMods": {"$inc": {"x": 10}}}, + ], + "nsInfo": [{"ns": ns}], + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess(result, [{"_id": 1, "x": 12}]) + + +def test_bulkWrite_upsert_id_in_response(collection): + """Test an upsert carries the upserted _id in cursor.firstBatch[].upserted._id.""" + ns = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 100}, + "updateMods": {"$set": {"x": 1}}, + "upsert": True, + } + ], + "nsInfo": [{"ns": ns}], + }, + ) + assertResult( + result, + expected={ + "ok": Eq(1.0), + "nUpserted": Eq(1), + "cursor.firstBatch.0.upserted._id": Eq(100), + }, + raw_res=True, + msg="bulkWrite upsert should carry the upserted _id in cursor.firstBatch[].upserted._id", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_errors.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_errors.py new file mode 100644 index 000000000..20343825b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_errors.py @@ -0,0 +1,608 @@ +"""Tests for bulkWrite error and rejection cases.""" + +import uuid + +import pytest +from bson.binary import Binary + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, + IndexModel, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + BSON_OBJECT_TOO_LARGE_ERROR, + COLLECTION_UUID_MISMATCH_ERROR, + COMMAND_NOT_FOUND_ERROR, + COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + DOCUMENT_VALIDATION_FAILURE_ERROR, + DUPLICATE_KEY_ERROR, + FAILED_TO_PARSE_ERROR, + IMMUTABLE_FIELD_ERROR, + INVALID_BSON_ID_ERROR, + INVALID_LENGTH_ERROR, + INVALID_NAMESPACE_ERROR, + MISSING_FIELD_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, + UPDATE_C_FIELD_REQUIRES_PIPELINE_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Len +from documentdb_tests.framework.target_collection import CustomCollection, ViewCollection + +BULKWRITE_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "ops_empty_array", + command={"bulkWrite": 1, "ops": []}, + error_code=INVALID_LENGTH_ERROR, + msg="bulkWrite with an empty ops array should fail with InvalidLength", + ), + CommandTestCase( + "ops_field_missing", + command={"bulkWrite": 1}, + error_code=MISSING_FIELD_ERROR, + msg="bulkWrite without an ops field should fail with MissingField", + ), + CommandTestCase( + "ops_insert_missing_document", + command={"bulkWrite": 1, "ops": [{"insert": 0}]}, + error_code=MISSING_FIELD_ERROR, + msg="bulkWrite insert op missing document should fail with MissingField", + ), + CommandTestCase( + "ops_update_missing_filter", + command={"bulkWrite": 1, "ops": [{"update": 0, "updateMods": {"$set": {"x": 1}}}]}, + error_code=MISSING_FIELD_ERROR, + msg="bulkWrite update op missing filter should fail with MissingField", + ), + CommandTestCase( + "ops_delete_missing_filter", + command={"bulkWrite": 1, "ops": [{"delete": 0}]}, + error_code=MISSING_FIELD_ERROR, + msg="bulkWrite delete op missing filter should fail with MissingField", + ), + CommandTestCase( + "ops_invalid_namespace_index", + command={"bulkWrite": 1, "ops": [{"insert": 5, "document": {"_id": 1}}]}, + error_code=BAD_VALUE_ERROR, + msg="bulkWrite op referencing a non-existent nsInfo index should fail with BadValue", + ), + CommandTestCase( + "ops_negative_namespace_index", + command={"bulkWrite": 1, "ops": [{"insert": -1, "document": {"_id": 1}}]}, + error_code=BAD_VALUE_ERROR, + msg="bulkWrite op with a negative namespace index should fail with BadValue", + ), + CommandTestCase( + "nsInfo_empty_array", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1}}], "nsInfo": []}, + error_code=BAD_VALUE_ERROR, + msg="bulkWrite with an empty nsInfo array should fail with BadValue", + ), + CommandTestCase( + "nsInfo_invalid_ns_format", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "nsInfo": [{"ns": "no_dot"}], + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="bulkWrite with an invalid namespace format should fail with InvalidNamespace", + ), + CommandTestCase( + "unrecognized_top_level_field", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "unknownField": True, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="bulkWrite with an unrecognized top-level field should fail", + ), + CommandTestCase( + "unrecognized_op_discriminator", + command={ + "bulkWrite": 1, + "ops": [{"replace": 0, "document": {"_id": 1}}], + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="bulkWrite op with an unrecognized discriminator should fail at parse time", + ), +] + +BULKWRITE_OPERATION_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "invalid_filter_expression", + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"$badOp": 1}, "updateMods": {"$set": {"x": 1}}}], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nModified": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(BAD_VALUE_ERROR), + }, + msg="bulkWrite with an invalid query operator should report nErrors:1", + ), + CommandTestCase( + "invalid_update_expression", + docs=[{"_id": 1, "x": 1}], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"_id": 1}, "updateMods": {"$badOp": {"x": 1}}}], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nModified": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(FAILED_TO_PARSE_ERROR), + }, + msg="bulkWrite with an invalid update operator should report nErrors:1", + ), + CommandTestCase( + "duplicate_id_insert", + docs=[{"_id": 1}], + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1}}]}, + expected={"ok": Eq(1.0), "nErrors": Eq(1), "nInserted": Eq(0)}, + msg="bulkWrite duplicate _id insert should report nErrors:1", + ), + CommandTestCase( + "ordered_true_stops_at_dupkey", + docs=[{"_id": 1}], + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + {"insert": 0, "document": {"_id": 3}}, + ], + "ordered": True, + }, + expected={"ok": Eq(1.0), "nErrors": Eq(1), "nInserted": Eq(0)}, + msg="bulkWrite ordered:true should stop at the duplicate key", + ), + CommandTestCase( + "ordered_false_continues_after_dupkey", + docs=[{"_id": 1}], + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + {"insert": 0, "document": {"_id": 3}}, + ], + "ordered": False, + }, + expected={"ok": Eq(1.0), "nErrors": Eq(1), "nInserted": Eq(2)}, + msg="bulkWrite ordered:false should continue after the duplicate key", + ), + CommandTestCase( + "insert_unique_index_violation", + indexes=[IndexModel([("x", 1)], unique=True)], + docs=[{"_id": 1, "x": 1}], + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 2, "x": 1}}]}, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nInserted": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(DUPLICATE_KEY_ERROR), + }, + msg="bulkWrite insert violating a unique index should report nErrors:1", + ), + CommandTestCase( + "update_causes_unique_index_violation", + indexes=[IndexModel([("x", 1)], unique=True)], + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"_id": 2}, "updateMods": {"$set": {"x": 1}}}], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nModified": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(DUPLICATE_KEY_ERROR), + }, + msg="bulkWrite update causing a unique index violation should report nErrors:1", + ), + CommandTestCase( + "update_immutable_field", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"_id": 2}}}], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nModified": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(IMMUTABLE_FIELD_ERROR), + }, + msg="bulkWrite update of the immutable _id field should report nErrors:1", + ), + CommandTestCase( + "update_on_view", + target_collection=ViewCollection(), + docs=[{"_id": 1, "x": 1}], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {}, "updateMods": {"$set": {"x": 2}}}], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nModified": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR), + }, + msg="bulkWrite update on a view should report nErrors:1", + ), + CommandTestCase( + "schema_validation_rejects_insert", + target_collection=CustomCollection( + options={"validator": {"$jsonSchema": {"required": ["name"]}}} + ), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "bypassDocumentValidation": False, + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nInserted": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(DOCUMENT_VALIDATION_FAILURE_ERROR), + }, + msg="bulkWrite bypassDocumentValidation:false should reject a validator-violating insert", + ), + CommandTestCase( + "arrayFilters_invalid_filter", + docs=[{"_id": 1, "items": [{"x": 1}]}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 1}, + "updateMods": {"$set": {"items.$[elem].x": 99}}, + "arrayFilters": [{"elem.x": {"$badOp": 1}}], + } + ], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nModified": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(BAD_VALUE_ERROR), + }, + msg="bulkWrite arrayFilters with an invalid operator should report nErrors:1", + ), + CommandTestCase( + "update_hint_nonexistent_index", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"x": 10}, + "updateMods": {"$set": {"x": 20}}, + "hint": "nonexistent_index", + } + ], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nModified": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(BAD_VALUE_ERROR), + }, + msg="bulkWrite update with a hint on a non-existent index should report nErrors:1", + ), + CommandTestCase( + "delete_hint_nonexistent_index", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [{"delete": 0, "filter": {"x": 10}, "hint": "nonexistent_index"}], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nDeleted": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(BAD_VALUE_ERROR), + }, + msg="bulkWrite delete with a hint on a non-existent index should report nErrors:1", + ), + CommandTestCase( + "constants_on_non_pipeline_update", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 1}, + "updateMods": {"$set": {"x": 20}}, + "constants": {"val": 1}, + } + ], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nModified": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(UPDATE_C_FIELD_REQUIRES_PIPELINE_ERROR), + }, + msg="bulkWrite constants on a non-pipeline update should report nErrors:1", + ), + CommandTestCase( + "errorsOnly_true_with_error", + docs=[{"_id": 1}], + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + ], + "errorsOnly": True, + "ordered": False, + }, + expected={"ok": Eq(1.0), "nErrors": Eq(1)}, + msg="bulkWrite errorsOnly:true with a failing operation should report nErrors:1", + ), + CommandTestCase( + "array_id_surfaces_as_op_level_error", + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": [1, 2]}}], + }, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nInserted": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(INVALID_BSON_ID_ERROR), + }, + msg="bulkWrite should surface an array _id as an op-level error (code 53), not reject it", + ), + CommandTestCase( + "collection_uuid_mismatch", + docs=[{"_id": 0}], + command=lambda ctx: { + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "nsInfo": [{"ns": ctx.namespace, "collectionUUID": Binary(uuid.uuid4().bytes, 4)}], + }, + # Unlike renameCollection (top-level), bulkWrite surfaces this mismatch op-level. + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nInserted": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(COLLECTION_UUID_MISMATCH_ERROR), + }, + msg="bulkWrite should surface a collectionUUID mismatch as an op-level error (code 361)", + ), +] + +BULKWRITE_ERROR_TESTS = BULKWRITE_REJECTION_TESTS + BULKWRITE_OPERATION_ERROR_TESTS + + +@pytest.mark.parametrize("test", pytest_params(BULKWRITE_ERROR_TESTS)) +def test_bulkWrite_errors(database_client, collection, test): + """Test bulkWrite error and rejection cases.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = test.build_command(ctx) + if "nsInfo" not in command: + command = {**command, "nsInfo": [{"ns": ctx.namespace}]} + result = execute_admin_command(collection, command) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +def test_bulkWrite_failure_in_one_ns_does_not_block_another(collection): + """Test an unordered bulkWrite failure in one namespace does not block writes to another.""" + sibling = collection.database[f"{collection.name}_b"] + sibling.drop() + collection.insert_one({"_id": 1, "x": 1}) + ns = f"{collection.database.name}.{collection.name}" + ns_b = f"{collection.database.name}.{sibling.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "x": 2}}, # dup key on ns 0 + {"insert": 1, "document": {"_id": 1, "y": 1}}, # succeeds on ns 1 + ], + "nsInfo": [{"ns": ns}, {"ns": ns_b}], + "ordered": False, + }, + ) + assertResult( + result, + expected={"ok": Eq(1.0), "nInserted": Eq(1), "nErrors": Eq(1)}, + raw_res=True, + ) + sibling.drop() + + +def test_bulkWrite_command_name_must_be_first_field(collection): + """Test bulkWrite is rejected with CommandNotFound when bulkWrite is not the first field.""" + ns = f"{collection.database.name}.{collection.name}" + # nsInfo is intentionally placed before bulkWrite so it is read as the command name. + result = execute_admin_command( + collection, + { + "nsInfo": [{"ns": ns}], + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + }, + ) + assertResult(result, error_code=COMMAND_NOT_FOUND_ERROR, raw_res=True) + + +def test_bulkWrite_oversized_document(collection): + """Test an op inserting a document over the 16MB BSON limit reports an op-level error.""" + large_doc = {"_id": 1, "data": "x" * (16 * 1024 * 1024)} + ns = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, + {"bulkWrite": 1, "ops": [{"insert": 0, "document": large_doc}], "nsInfo": [{"ns": ns}]}, + ) + assertResult( + result, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nInserted": Eq(0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(BSON_OBJECT_TOO_LARGE_ERROR), + }, + raw_res=True, + msg="bulkWrite oversized document should report an op-level BSONObjectTooLarge error", + ) + + +def test_bulkWrite_oversized_document_in_batch(collection): + """Test an oversized doc fails op-level while a sibling op in the unordered batch succeeds.""" + large_doc = {"_id": 1, "data": "x" * (16 * 1024 * 1024)} + ns = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": large_doc}, + {"insert": 0, "document": {"_id": 2, "x": 1}}, + ], + "nsInfo": [{"ns": ns}], + "ordered": False, + }, + ) + assertResult( + result, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nInserted": Eq(1), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(BSON_OBJECT_TOO_LARGE_ERROR), + }, + raw_res=True, + msg="bulkWrite oversized doc should fail op-level while the sibling op succeeds", + ) + + +def test_bulkWrite_duplicate_key_per_op_cursor_detail(collection): + """Test a dup-key op reports per-op detail (idx/code/keyPattern/keyValue) in firstBatch.""" + collection.insert_one({"_id": 1}) + ns = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, + {"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1}}], "nsInfo": [{"ns": ns}]}, + ) + assertResult( + result, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "nInserted": Eq(0), + "cursor.firstBatch.0.ok": Eq(0.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.code": Eq(DUPLICATE_KEY_ERROR), + "cursor.firstBatch.0.keyPattern": Eq({"_id": 1}), + "cursor.firstBatch.0.keyValue": Eq({"_id": 1}), + }, + raw_res=True, + msg="bulkWrite dup-key op should report per-op detail (idx/code/keyPattern/keyValue) " + "in firstBatch", + ) + + +def test_bulkWrite_ordered_partial_success_cursor_shape(collection): + """Test ordered:true partial-succeeds, errors at idx 1, and omits the never-run 3rd op.""" + collection.insert_one({"_id": 1}) # pre-existing → second op is a dup + ns = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 2}}, # good + {"insert": 0, "document": {"_id": 1}}, # dup → error at idx 1 + {"insert": 0, "document": {"_id": 3}}, # never runs (ordered:true) + ], + "nsInfo": [{"ns": ns}], + "ordered": True, + }, + ) + assertResult( + result, + expected={ + "ok": Eq(1.0), + "nInserted": Eq(1), + "nErrors": Eq(1), + "cursor.firstBatch": Len(2), # 3rd op absent — never executed + "cursor.firstBatch.0.ok": Eq(1.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.1.ok": Eq(0.0), + "cursor.firstBatch.1.idx": Eq(1), + "cursor.firstBatch.1.code": Eq(DUPLICATE_KEY_ERROR), + }, + raw_res=True, + msg="bulkWrite ordered:true should partial-succeed, error at idx 1, and omit the 3rd op", + ) + + +_MAX_WRITE_BATCH_SIZE = 100_000 + + +def test_bulkWrite_exceeds_max_write_batch_size(collection): + """Test exceeding maxWriteBatchSize (100,000 ops) is rejected with InvalidLength.""" + ns = f"{collection.database.name}.{collection.name}" + ops = [{"insert": 0, "document": {}} for _ in range(_MAX_WRITE_BATCH_SIZE + 1)] + result = execute_admin_command( + collection, + {"bulkWrite": 1, "ops": ops, "nsInfo": [{"ns": ns}]}, + ) + assertResult( + result, + error_code=INVALID_LENGTH_ERROR, + raw_res=True, + msg="bulkWrite exceeding maxWriteBatchSize should fail with InvalidLength", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_let_variables.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_let_variables.py new file mode 100644 index 000000000..11109622a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_let_variables.py @@ -0,0 +1,228 @@ +"""Tests for bulkWrite let variables, per-statement constants, and constants override behavior.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import ( + assertResult, + assertSuccess, + assertSuccessPartial, +) +from documentdb_tests.framework.error_codes import LET_UNDEFINED_VARIABLE_ERROR +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +BULKWRITE_LET_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "let_variables_in_filter", + docs=[{"_id": 1, "x": 10}, {"_id": 2, "x": 20}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"$expr": {"$eq": ["$x", "$$target"]}}, + "updateMods": {"$set": {"matched": True}}, + } + ], + "let": {"target": 10}, + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite should resolve let variables referenced in filters", + ), + CommandTestCase( + "let_empty_document", + docs=[{"_id": 1, "x": 1}], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": 2}}}], + "let": {}, + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite should accept an empty let document", + ), + CommandTestCase( + "let_variable_in_delete_filter", + docs=[{"_id": 1, "x": 10}, {"_id": 2, "x": 20}], + command={ + "bulkWrite": 1, + "ops": [{"delete": 0, "filter": {"$expr": {"$eq": ["$x", "$$delTarget"]}}}], + "let": {"delTarget": 10}, + }, + expected={"ok": 1.0, "nDeleted": 1}, + msg="bulkWrite should resolve let variables referenced in delete filters", + ), + CommandTestCase( + "constants_override_let_in_updateMods", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 1}, + "updateMods": [{"$set": {"x": "$$val"}}], + "constants": {"val": 99}, + } + ], + "let": {"val": 10}, + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite constants should take precedence over let for the same variable", + ), + CommandTestCase( + "let_variable_name_collision_with_field", + docs=[{"_id": 1, "x": 10, "target": 999}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"$expr": {"$eq": ["$x", "$$target"]}}, + "updateMods": {"$set": {"matched": True}}, + } + ], + "let": {"target": 10}, + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite let variable should take precedence over a same-named field in $expr", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(BULKWRITE_LET_TESTS)) +def test_bulkWrite_let_variables(database_client, collection, test): + """Test bulkWrite let variables, per-statement constants, and constants override behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = test.build_command(ctx) + if "nsInfo" not in command: + command = {**command, "nsInfo": [{"ns": ctx.namespace}]} + result = execute_admin_command(collection, command) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + + +def test_bulkWrite_constants_override_let_writes_constants_value(collection): + """Test constants takes precedence over let: the constants value 99, not let 10, is written.""" + collection.insert_one({"_id": 1, "x": 10}) + ns = f"{collection.database.name}.{collection.name}" + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 1}, + "updateMods": [{"$set": {"x": "$$val"}}], + "constants": {"val": 99}, + } + ], + "nsInfo": [{"ns": ns}], + "let": {"val": 10}, + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess(result, [{"_id": 1, "x": 99}]) + + +def test_bulkWrite_let_variable_resolved_in_pipeline_updateMods(collection): + """Test a let $$var in a pipeline updateMods resolves to its value (read-back x==99).""" + collection.insert_one({"_id": 1, "x": 10}) + ns = f"{collection.database.name}.{collection.name}" + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"update": 0, "filter": {"_id": 1}, "updateMods": [{"$set": {"x": "$$newVal"}}]} + ], + "nsInfo": [{"ns": ns}], + "let": {"newVal": 99}, + }, + ) + assertSuccess( + execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}), + [{"_id": 1, "x": 99}], + msg="bulkWrite should resolve a let variable in a pipeline updateMods to its value", + ) + + +def test_bulkWrite_constants_resolved_in_pipeline_update(collection): + """Test a per-statement constant in a pipeline update resolves to x==42 (read-back).""" + collection.insert_one({"_id": 1, "x": 10}) + ns = f"{collection.database.name}.{collection.name}" + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 1}, + "updateMods": [{"$set": {"x": "$$myConst"}}], + "constants": {"myConst": 42}, + } + ], + "nsInfo": [{"ns": ns}], + }, + ) + assertSuccess( + execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}), + [{"_id": 1, "x": 42}], + msg="bulkWrite should resolve a per-statement constant in a pipeline update to its value", + ) + + +def test_bulkWrite_let_not_resolved_in_modifier_update(collection): + """Test a let $$var is stored literally (not resolved) in a non-pipeline $set update.""" + collection.insert_one({"_id": 1, "x": 1}) + ns = f"{collection.database.name}.{collection.name}" + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": "$$v"}}}], + "nsInfo": [{"ns": ns}], + "let": {"v": 99}, + }, + ) + assertSuccess( + execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}), + [{"_id": 1, "x": "$$v"}], + msg="bulkWrite modifier update should store the literal '$$v', not resolve the let var", + ) + + +def test_bulkWrite_undefined_let_variable_op_error(collection): + """Test referencing an undefined let variable yields an op-level error (code 17276).""" + collection.insert_one({"_id": 1, "x": 1}) + ns = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"$expr": {"$eq": ["$x", "$$missing"]}}, + "updateMods": {"$set": {"hit": True}}, + } + ], + "nsInfo": [{"ns": ns}], + }, + ) + assertResult( + result, + expected={ + "ok": Eq(1.0), + "nErrors": Eq(1), + "cursor.firstBatch.0.code": Eq(LET_UNDEFINED_VARIABLE_ERROR), + }, + raw_res=True, + msg="bulkWrite should report an undefined let variable as op error code 17276", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_mixed_operations.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_mixed_operations.py new file mode 100644 index 000000000..77d1d3ea5 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_mixed_operations.py @@ -0,0 +1,208 @@ +"""Tests for bulkWrite mixed and multi-namespace operations.""" + +from documentdb_tests.framework.assertions import assertSuccess, assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command, execute_command + + +def test_bulkWrite_insert_update_delete_mixed(collection): + """Test bulkWrite combines insert, update, and delete in one command.""" + collection.insert_one({"_id": 1, "x": 10}) + ns = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 2, "x": 20}}, + {"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": 99}}}, + {"delete": 0, "filter": {"_id": 2}}, + ], + "nsInfo": [{"ns": ns}], + }, + ) + assertSuccessPartial( + result, + {"ok": 1.0, "nInserted": 1, "nMatched": 1, "nModified": 1, "nDeleted": 1}, + msg="bulkWrite should combine insert, update, and delete in one command", + ) + + +def test_bulkWrite_operations_across_multiple_namespaces(collection): + """Test bulkWrite operates across multiple namespaces listed in the nsInfo array.""" + sibling = collection.database[f"{collection.name}_b"] + sibling.drop() + ns = f"{collection.database.name}.{collection.name}" + ns_b = f"{collection.database.name}.{sibling.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1, "src": "a"}}, + {"insert": 1, "document": {"_id": 1, "src": "b"}}, + ], + "nsInfo": [{"ns": ns}, {"ns": ns_b}], + }, + ) + assertSuccessPartial( + result, + {"ok": 1.0, "nInserted": 2}, + msg="bulkWrite should operate across multiple namespaces in the nsInfo array", + ) + sibling.drop() + + +def test_bulkWrite_ops_reference_correct_namespace_by_index(collection): + """Test bulkWrite ops target the namespace selected by their index into nsInfo.""" + sibling = collection.database[f"{collection.name}_b"] + sibling.drop() + collection.insert_one({"_id": 1, "x": 1}) + ns = f"{collection.database.name}.{collection.name}" + ns_b = f"{collection.database.name}.{sibling.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"insert": 1, "document": {"_id": 10, "y": 1}}, + {"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": 99}}}, + ], + "nsInfo": [{"ns": ns}, {"ns": ns_b}], + }, + ) + assertSuccessPartial( + result, + {"ok": 1.0, "nInserted": 1, "nMatched": 1, "nModified": 1}, + msg="bulkWrite ops should reference the correct namespace by index", + ) + sibling.drop() + + +def test_bulkWrite_interleaved_namespaces(collection): + """Test bulkWrite executes operations interleaved across multiple namespaces.""" + sibling = collection.database[f"{collection.name}_b"] + sibling.drop() + ns = f"{collection.database.name}.{collection.name}" + ns_b = f"{collection.database.name}.{sibling.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 1, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + {"insert": 1, "document": {"_id": 2}}, + ], + "nsInfo": [{"ns": ns}, {"ns": ns_b}], + }, + ) + assertSuccessPartial( + result, + {"ok": 1.0, "nInserted": 4}, + msg="bulkWrite should execute operations interleaved across namespaces", + ) + sibling.drop() + + +def _run_routing_bulkwrite(collection, sibling): + """Run a 2-namespace bulkWrite with overlapping _ids to catch mis-routing via content.""" + sibling.drop() + collection.insert_one({"_id": 1, "x": 1}) # seeds ns0 only + ns = f"{collection.database.name}.{collection.name}" + ns_b = f"{collection.database.name}.{sibling.name}" + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 2, "tag": "a"}}, # ns0 + {"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": 99}}}, # ns0 + {"insert": 1, "document": {"_id": 1, "tag": "b"}}, # ns1 (same _id as ns0) + {"insert": 1, "document": {"_id": 2, "tag": "b2"}}, # ns1 (same _id as ns0) + ], + "nsInfo": [{"ns": ns}, {"ns": ns_b}], + }, + ) + + +def test_bulkWrite_routes_index0_ops_to_namespace0_read_back(collection): + """Test index-0 ops land only in namespace 0, verified by read-back.""" + sibling = collection.database[f"{collection.name}_b"] + _run_routing_bulkwrite(collection, sibling) + assertSuccess( + execute_command(collection, {"find": collection.name, "filter": {}, "sort": {"_id": 1}}), + [{"_id": 1, "x": 99}, {"_id": 2, "tag": "a"}], + msg="bulkWrite ops with namespace index 0 should land only in namespace 0", + ) + + +def test_bulkWrite_routes_index1_ops_to_namespace1_read_back(collection): + """Test index-1 ops land only in namespace 1, verified by read-back.""" + sibling = collection.database[f"{collection.name}_b"] + _run_routing_bulkwrite(collection, sibling) + assertSuccess( + execute_command(sibling, {"find": sibling.name, "filter": {}, "sort": {"_id": 1}}), + [{"_id": 1, "tag": "b"}, {"_id": 2, "tag": "b2"}], + msg="bulkWrite ops with namespace index 1 should land only in namespace 1", + ) + + +def _seed_two_namespaces(collection, sibling): + """Seed ns0 with {_id:1,x:1} and {_id:50}, ns1 with {_id:1,y:1}; return (ns0, ns1).""" + sibling.drop() + collection.insert_many([{"_id": 1, "x": 1}, {"_id": 50}]) + sibling.insert_one({"_id": 1, "y": 1}) + ns = f"{collection.database.name}.{collection.name}" + ns_b = f"{collection.database.name}.{sibling.name}" + return ns, ns_b + + +def _mixed_cross_ns_ops(): + """Insert+update+delete across two namespaces, with a dup-key failure at idx 2.""" + return [ + {"insert": 0, "document": {"_id": 2, "v": "i0"}}, # ns0 insert — good + {"update": 1, "filter": {"_id": 1}, "updateMods": {"$set": {"y": 99}}}, # ns1 update — good + {"insert": 0, "document": {"_id": 1, "v": "dup"}}, # ns0 insert — dup key, FAILS (idx 2) + {"delete": 0, "filter": {"_id": 50}}, # ns0 delete — good, but after the failure + ] + + +def test_bulkWrite_ordered_true_skips_delete_after_mid_batch_failure(collection): + """Test ordered:true mixed-op/multi-ns batch stops at the failure, skipping the later delete.""" + sibling = collection.database[f"{collection.name}_b"] + ns, ns_b = _seed_two_namespaces(collection, sibling) + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": _mixed_cross_ns_ops(), + "nsInfo": [{"ns": ns}, {"ns": ns_b}], + "ordered": True, + }, + ) + assertSuccess( + execute_command(collection, {"find": collection.name, "filter": {}, "sort": {"_id": 1}}), + [{"_id": 1, "x": 1}, {"_id": 2, "v": "i0"}, {"_id": 50}], + msg="ordered:true should skip the delete after the mid-batch failure (_id:50 kept)", + ) + + +def test_bulkWrite_ordered_false_runs_delete_after_mid_batch_failure(collection): + """Test ordered:false mixed-op/multi-ns batch runs the later delete despite the failure.""" + sibling = collection.database[f"{collection.name}_b"] + ns, ns_b = _seed_two_namespaces(collection, sibling) + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": _mixed_cross_ns_ops(), + "nsInfo": [{"ns": ns}, {"ns": ns_b}], + "ordered": False, + }, + ) + assertSuccess( + execute_command(collection, {"find": collection.name, "filter": {}, "sort": {"_id": 1}}), + [{"_id": 1, "x": 1}, {"_id": 2, "v": "i0"}], + msg="ordered:false should run the delete after the mid-batch failure (_id:50 removed)", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_response_structure.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_response_structure.py new file mode 100644 index 000000000..819a91b04 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_response_structure.py @@ -0,0 +1,200 @@ +"""Tests for bulkWrite response structure and cursor behavior.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Gt, IsType, Len + +BULKWRITE_RESPONSE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "response_contains_cursor_id", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1}}]}, + expected={"cursor.id": IsType("long")}, + msg="bulkWrite response should contain a long cursor.id", + ), + CommandTestCase( + "response_contains_cursor_ns", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1}}]}, + expected={"ok": Eq(1.0), "cursor.ns": Eq("admin.$cmd.bulkWrite")}, + msg="bulkWrite response cursor.ns should be admin.$cmd.bulkWrite", + ), + CommandTestCase( + "response_contains_firstBatch", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1}}]}, + expected={ + "ok": Eq(1.0), + "cursor.firstBatch.0.ok": Eq(1.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.n": Eq(1), + }, + msg="bulkWrite response should contain a cursor.firstBatch array", + ), + CommandTestCase( + "response_nErrors_zero_on_success", + command={"bulkWrite": 1, "ops": [{"insert": 0, "document": {"_id": 1}}]}, + expected={"ok": Eq(1.0), "nErrors": Eq(0)}, + msg="bulkWrite response should contain nErrors:0 on success", + ), + CommandTestCase( + "response_nInserted", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + ], + }, + expected={"ok": Eq(1.0), "nInserted": Eq(2)}, + msg="bulkWrite response should contain nInserted", + ), + CommandTestCase( + "response_nMatched_nModified", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [{"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": 20}}}], + }, + expected={"ok": Eq(1.0), "nMatched": Eq(1), "nModified": Eq(1)}, + msg="bulkWrite response should contain nMatched and nModified", + ), + CommandTestCase( + "response_matched_but_unmodified", + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + # $set to the SAME value: matches the doc but changes nothing. + "ops": [{"update": 0, "filter": {"_id": 1}, "updateMods": {"$set": {"x": 10}}}], + }, + expected={ + "ok": Eq(1.0), + "nMatched": Eq(1), + "nModified": Eq(0), + "cursor.firstBatch.0.ok": Eq(1.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.n": Eq(1), + "cursor.firstBatch.0.nModified": Eq(0), + }, + msg="bulkWrite no-op update should report nMatched:1/nModified:0 and per-op nModified:0", + ), + CommandTestCase( + "response_nDeleted", + docs=[{"_id": 1}], + command={"bulkWrite": 1, "ops": [{"delete": 0, "filter": {"_id": 1}}]}, + expected={"ok": Eq(1.0), "nDeleted": Eq(1)}, + msg="bulkWrite response should contain nDeleted", + ), + CommandTestCase( + "errorsOnly_false_returns_all_results", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + ], + "errorsOnly": False, + }, + expected={ + "ok": Eq(1.0), + "nInserted": Eq(2), + "cursor.firstBatch.0.ok": Eq(1.0), + "cursor.firstBatch.0.idx": Eq(0), + "cursor.firstBatch.0.n": Eq(1), + "cursor.firstBatch.1.ok": Eq(1.0), + "cursor.firstBatch.1.idx": Eq(1), + "cursor.firstBatch.1.n": Eq(1), + }, + msg="bulkWrite errorsOnly:false should return all operation results", + ), + CommandTestCase( + "errorsOnly_true_full_success_empty_firstBatch", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + ], + "errorsOnly": True, + }, + expected={"ok": Eq(1.0), "nInserted": Eq(2), "nErrors": Eq(0), "cursor.firstBatch": Len(0)}, + msg="bulkWrite errorsOnly:true on full success should return an empty firstBatch", + ), + CommandTestCase( + "cursor_batchSize_1", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + {"insert": 0, "document": {"_id": 3}}, + ], + "cursor": {"batchSize": 1}, + "errorsOnly": False, + }, + expected={"cursor.firstBatch": Len(1)}, + msg="bulkWrite cursor.batchSize:1 should limit firstBatch to one result", + ), + CommandTestCase( + "cursor_batchSize_zero", + command={ + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + ], + "cursor": {"batchSize": 0}, + "errorsOnly": False, + }, + expected={"cursor.firstBatch": Len(0), "cursor.id": Gt(0)}, + msg="bulkWrite cursor.batchSize:0 should return an empty firstBatch with a live cursor", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(BULKWRITE_RESPONSE_TESTS)) +def test_bulkWrite_response_structure(database_client, collection, test): + """Test bulkWrite response structure and cursor behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = test.build_command(ctx) + if "nsInfo" not in command: + command = {**command, "nsInfo": [{"ns": ctx.namespace}]} + result = execute_admin_command(collection, command) + assertResult(result, expected=test.build_expected(ctx), msg=test.msg, raw_res=True) + + +def test_bulkWrite_getMore_drains_remaining_results(collection): + """Test a batched bulkWrite cursor (batchSize:1) is drained with getMore into nextBatch.""" + ns = f"{collection.database.name}.{collection.name}" + result = execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + {"insert": 0, "document": {"_id": 1}}, + {"insert": 0, "document": {"_id": 2}}, + {"insert": 0, "document": {"_id": 3}}, + ], + "nsInfo": [{"ns": ns}], + "errorsOnly": False, + "cursor": {"batchSize": 1}, + }, + ) + cursor_id = result["cursor"]["id"] + more = execute_admin_command(collection, {"getMore": cursor_id, "collection": "$cmd.bulkWrite"}) + assertResult( + more, + expected={ + "ok": Eq(1.0), + "cursor.nextBatch": Len(2), + "cursor.nextBatch.0.idx": Eq(1), + "cursor.nextBatch.1.idx": Eq(2), + }, + raw_res=True, + msg="bulkWrite getMore should drain the remaining op results into nextBatch", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_sub_features.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_sub_features.py new file mode 100644 index 000000000..d274a35b7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/bulkWrite/test_bulkWrite_sub_features.py @@ -0,0 +1,174 @@ +"""Tests for bulkWrite sub-features: arrayFilters, hint, collation, and bypassDocumentValidation.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, + IndexModel, +) +from documentdb_tests.framework.assertions import assertSuccess, assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.target_collection import CustomCollection + +BULKWRITE_SUB_FEATURE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "arrayFilters_modifies_matching_elements", + docs=[{"_id": 1, "items": [{"x": 1}, {"x": 2}, {"x": 3}]}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 1}, + "updateMods": {"$set": {"items.$[elem].x": 99}}, + "arrayFilters": [{"elem.x": {"$gt": 1}}], + } + ], + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite arrayFilters should modify matching array elements", + ), + CommandTestCase( + "update_with_hint", + indexes=[IndexModel([("x", 1)], name="x_1")], + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"x": 10}, + "updateMods": {"$set": {"x": 20}}, + "hint": "x_1", + } + ], + }, + expected={"ok": 1.0, "nMatched": 1, "nModified": 1}, + msg="bulkWrite update with a valid hint should succeed", + ), + CommandTestCase( + "delete_with_hint", + indexes=[IndexModel([("x", 1)], name="x_1")], + docs=[{"_id": 1, "x": 10}], + command={ + "bulkWrite": 1, + "ops": [{"delete": 0, "filter": {"x": 10}, "hint": "x_1"}], + }, + expected={"ok": 1.0, "nDeleted": 1}, + msg="bulkWrite delete with a valid hint should succeed", + ), + CommandTestCase( + "update_with_collation", + # Comparison semantics are owned by tests/core/collation/; this only checks wiring. + docs=[{"_id": 1, "name": "café"}, {"_id": 2, "name": "cafe"}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"name": "cafe"}, + "updateMods": {"$set": {"matched": True}}, + "collation": {"locale": "en", "strength": 1}, + "multi": True, + } + ], + }, + expected={"ok": 1.0, "nMatched": 2, "nModified": 2}, + msg="bulkWrite should forward a per-op collation to the update (wiring check)", + ), + CommandTestCase( + "bypassDocumentValidation_true", + target_collection=CustomCollection( + options={"validator": {"$jsonSchema": {"required": ["name"]}}} + ), + command={ + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1, "x": 1}}], # missing "name" + "bypassDocumentValidation": True, + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite bypassDocumentValidation:true should allow a validator-violating write", + ), + CommandTestCase( + "update_mixed_collation", + docs=[{"_id": 1, "name": "Apple", "v": 1}, {"_id": 2, "name": "apple", "v": 1}], + command={ + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"name": "apple"}, + "updateMods": {"$set": {"v": 2}}, + "collation": {"locale": "en", "strength": 2}, + "multi": True, + }, + { + "update": 0, + "filter": {"name": "Apple"}, + "updateMods": {"$set": {"v": 3}}, + }, + ], + }, + # First op matches both (case-insensitive), second matches only exact "Apple". + expected={"ok": 1.0, "nMatched": 3, "nModified": 3}, + msg="bulkWrite collation on one op should not leak to another op", + ), + CommandTestCase( + "collection_uuid_match", + docs=[{"_id": 0}], + command=lambda ctx: { + "bulkWrite": 1, + "ops": [{"insert": 0, "document": {"_id": 1}}], + "nsInfo": [{"ns": ctx.namespace, "collectionUUID": ctx.uuids[ctx.collection]}], + }, + expected={"ok": 1.0, "nInserted": 1}, + msg="bulkWrite with a collectionUUID matching the target collection should succeed", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(BULKWRITE_SUB_FEATURE_TESTS)) +def test_bulkWrite_sub_features(database_client, collection, test): + """Test bulkWrite sub-features: arrayFilters, hint, collation, and bypassDocumentValidation.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = test.build_command(ctx) + if "nsInfo" not in command: + command = {**command, "nsInfo": [{"ns": ctx.namespace}]} + result = execute_admin_command(collection, command) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + + +def test_bulkWrite_collation_affects_arrayFilters_selection(collection): + """Test op-level collation makes arrayFilters match array elements case-insensitively. + + Which array elements arrayFilters changed is only observable via read-back. With a + strength:2 (case-insensitive) collation, the filter {e: "apple"} matches BOTH "Apple" and + "apple"; without collation it would match only the exact "apple". This interaction is not + exercised by the sibling arrayFilters suite. + """ + collection.insert_one({"_id": 1, "items": ["Apple", "apple", "banana"]}) + ns = f"{collection.database.name}.{collection.name}" + execute_admin_command( + collection, + { + "bulkWrite": 1, + "ops": [ + { + "update": 0, + "filter": {"_id": 1}, + "updateMods": {"$set": {"items.$[e]": "X"}}, + "arrayFilters": [{"e": "apple"}], + "collation": {"locale": "en", "strength": 2}, + } + ], + "nsInfo": [{"ns": ns}], + }, + ) + assertSuccess( + execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}), + [{"_id": 1, "items": ["X", "X", "banana"]}], + msg="bulkWrite collation should make arrayFilters match case-insensitively (both Apples)", + )