From 655918599716bf8c81145c26c9f9fec68764e38b Mon Sep 17 00:00:00 2001 From: Victor Tsang Date: Thu, 18 Jun 2026 09:59:21 -0700 Subject: [PATCH 1/2] Add query and write tests for findAndModify Signed-off-by: Victor Tsang --- .../test_findAndModify_argument_validation.py | 190 ++++++ ...test_findAndModify_bson_type_validation.py | 238 ++++++++ .../test_findAndModify_core_behavior.py | 539 ++++++++++++++++++ .../test_findAndModify_data_types.py | 188 ++++++ ...st_findAndModify_dollar_prefixed_fields.py | 77 +++ .../test_findAndModify_errors.py | 514 +++++++++++++++++ .../test_findAndModify_expr_let.py | 116 ++++ .../test_findAndModify_projection.py | 170 ++++++ .../test_findAndModify_update_modes.py | 332 +++++++++++ .../test_findAndModify_upsert.py | 367 ++++++++++++ .../test_findAndModify_with_expr.py | 46 -- 11 files changed, 2731 insertions(+), 46 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_argument_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_bson_type_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_data_types.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_dollar_prefixed_fields.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_expr_let.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_projection.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_update_modes.py create mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_upsert.py delete mode 100644 documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_with_expr.py diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_argument_validation.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_argument_validation.py new file mode 100644 index 000000000..53b7b02d6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_argument_validation.py @@ -0,0 +1,190 @@ +"""Tests for findAndModify argument handling — success cases for valid parameter variants.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, + IndexModel, +) +from documentdb_tests.framework.assertions import assertResult, assertSuccessPartial +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +ALL_TESTS = [ + CommandTestCase( + "hint-string-existing-index", + docs=[{"_id": 1, "x": 10}], + indexes=[IndexModel("x", name="x_1")], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "hint": "x_1", + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 20})}, + msg="findAndModify with hint as index-name string succeeds", + ), + CommandTestCase( + "hint-spec-document", + docs=[{"_id": 1, "x": 10}], + indexes=[IndexModel("x")], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "hint": {"x": 1}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 20})}, + msg="findAndModify with hint as index-specification document succeeds", + ), + CommandTestCase( + "empty-query-selects-document", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {}, + "update": {"$set": {"x": 20}}, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="findAndModify with empty query {} selects a document", + ), + CommandTestCase( + "write-concern-w1", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "writeConcern": {"w": 1}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 20})}, + msg="findAndModify with writeConcern w:1 succeeds", + ), + CommandTestCase( + "comment-string", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "comment": "test comment", + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + "value": Eq({"_id": 1, "x": 10}), + }, + msg="findAndModify with comment as string succeeds", + ), + CommandTestCase( + "maxTimeMS-zero", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "maxTimeMS": 0, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + "value": Eq({"_id": 1, "x": 10}), + }, + msg="findAndModify with maxTimeMS:0 succeeds (unbounded)", + ), + CommandTestCase( + "sort-empty-document", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "sort": {}, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="findAndModify with sort:{} is accepted (no sort applied)", + ), + CommandTestCase( + "fields-empty-document-returns-full-doc", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 99}}, + "fields": {}, + }, + expected={"value": Eq({"_id": 1, "x": 10, "y": 20})}, + msg="findAndModify with fields:{} returns the full document", + ), + CommandTestCase( + "hint-empty-document", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "hint": {}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 20})}, + msg="findAndModify with hint:{} empty document succeeds", + ), + CommandTestCase( + "bypass-validation-as-int", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "bypassDocumentValidation": 1, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + "value": Eq({"_id": 1, "x": 10}), + }, + msg="findAndModify accepts bypassDocumentValidation as integer", + ), + CommandTestCase( + "bypass-validation-as-null", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "bypassDocumentValidation": None, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + "value": Eq({"_id": 1, "x": 10}), + }, + msg="findAndModify accepts bypassDocumentValidation as null", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_findAndModify_argument_validation(database_client, collection, test): + """Test findAndModify argument handling - success cases for valid parameter variants.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_command(collection, command) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +def test_findAndModify_bypass_validation_allows_invalid_write(database_client, request): + """Test bypassDocumentValidation:true allows write that violates validator.""" + db = database_client + coll_name = f"{request.node.name}_validated" + db.create_collection(coll_name, validator={"$jsonSchema": {"required": ["name"]}}) + coll = db[coll_name] + coll.insert_one({"_id": 1, "name": "test"}) + result = execute_command( + coll, + { + "findAndModify": coll.name, + "query": {"_id": 1}, + "update": {"$unset": {"name": ""}}, + "bypassDocumentValidation": True, + "new": True, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1}}) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_bson_type_validation.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_bson_type_validation.py new file mode 100644 index 000000000..d8a7cdd61 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_bson_type_validation.py @@ -0,0 +1,238 @@ +""" +BSON type validation tests for findAndModify command parameters. + +Verifies that findAndModify correctly rejects invalid BSON types for all +parameters and accepts valid types. +""" + +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, + INVALID_NAMESPACE_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command + +BSON_PARAMS = [ + BsonTypeTestCase( + id="findAndModify", + msg="findAndModify collection name should reject non-string types", + keyword="findAndModify", + valid_types=[BsonType.STRING], + default_error_code=INVALID_NAMESPACE_ERROR, + ), + BsonTypeTestCase( + id="query", + msg="findAndModify query should reject non-document types", + keyword="query", + valid_types=[BsonType.OBJECT, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="sort", + msg="findAndModify sort should reject non-document types", + keyword="sort", + valid_types=[BsonType.OBJECT, BsonType.NULL], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="update", + msg="findAndModify update should reject non-document/array types", + keyword="update", + valid_types=[BsonType.OBJECT, BsonType.ARRAY], + valid_inputs={ + BsonType.OBJECT: {"$set": {"x": 1}}, + BsonType.ARRAY: [{"$set": {"x": 1}}], + }, + default_error_code=FAILED_TO_PARSE_ERROR, + ), + BsonTypeTestCase( + id="fields", + msg="findAndModify fields should reject non-document types", + keyword="fields", + valid_types=[BsonType.OBJECT, BsonType.NULL], + valid_inputs={BsonType.OBJECT: {"x": 1}, BsonType.NULL: None}, + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="remove", + msg="findAndModify remove should reject non-numeric/non-bool types", + keyword="remove", + valid_types=[ + BsonType.BOOL, + BsonType.INT, + BsonType.LONG, + BsonType.DOUBLE, + BsonType.DECIMAL, + BsonType.NULL, + ], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="new", + msg="findAndModify new should reject non-numeric/non-bool types", + keyword="new", + valid_types=[ + BsonType.BOOL, + BsonType.INT, + BsonType.LONG, + BsonType.DOUBLE, + BsonType.DECIMAL, + BsonType.NULL, + ], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="upsert", + msg="findAndModify upsert should reject non-numeric/non-bool types", + keyword="upsert", + valid_types=[ + BsonType.BOOL, + BsonType.INT, + BsonType.LONG, + BsonType.DOUBLE, + BsonType.DECIMAL, + BsonType.NULL, + ], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="bypassDocumentValidation", + msg="findAndModify bypassDocumentValidation should reject non-numeric/non-bool types", + keyword="bypassDocumentValidation", + valid_types=[ + BsonType.BOOL, + BsonType.INT, + BsonType.LONG, + BsonType.DOUBLE, + BsonType.DECIMAL, + BsonType.NULL, + ], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="maxTimeMS", + msg="findAndModify maxTimeMS should reject non-numeric types", + keyword="maxTimeMS", + valid_types=[ + BsonType.INT, + BsonType.LONG, + BsonType.DOUBLE, + BsonType.DECIMAL, + BsonType.NULL, + ], + valid_inputs={ + BsonType.INT: 100, + BsonType.LONG: 100, + BsonType.DOUBLE: 100.0, + BsonType.DECIMAL: 100, + BsonType.NULL: None, + }, + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="collation", + msg="findAndModify collation should reject non-document types", + keyword="collation", + valid_types=[BsonType.OBJECT, BsonType.NULL], + valid_inputs={BsonType.OBJECT: {"locale": "en"}, BsonType.NULL: None}, + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="writeConcern", + msg="findAndModify writeConcern should reject non-document types", + keyword="writeConcern", + valid_types=[BsonType.OBJECT, BsonType.NULL], + valid_inputs={BsonType.OBJECT: {"w": 1}, BsonType.NULL: None}, + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="let", + msg="findAndModify let should reject non-document types", + keyword="let", + valid_types=[BsonType.OBJECT, BsonType.NULL], + valid_inputs={BsonType.OBJECT: {"a": 1}, BsonType.NULL: None}, + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="arrayFilters", + msg="findAndModify arrayFilters should reject non-array types", + keyword="arrayFilters", + valid_types=[BsonType.ARRAY, BsonType.NULL], + valid_inputs={BsonType.ARRAY: [], BsonType.NULL: None}, + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="hint", + msg="findAndModify hint should reject non-document/non-string types", + keyword="hint", + valid_types=[BsonType.OBJECT, BsonType.STRING], + valid_inputs={ + BsonType.OBJECT: {"_id": 1}, + BsonType.STRING: "_id_", + }, + default_error_code=FAILED_TO_PARSE_ERROR, + ), + BsonTypeTestCase( + id="comment", + msg="findAndModify comment should accept all BSON types", + keyword="comment", + valid_types=list(BsonType), + ), +] + +REJECTION_TESTS = generate_bson_rejection_test_cases(BSON_PARAMS) +ACCEPTANCE_TESTS = generate_bson_acceptance_test_cases(BSON_PARAMS) + + +def _build_command(collection_name, spec, sample_value): + """Build a findAndModify command with sample_value placed at spec.keyword.""" + cmd = { + "findAndModify": collection_name, + "query": {"_id": 1}, + "update": {"$set": {"x": 1}}, + spec.keyword: sample_value, + } + if spec.id == "remove" and sample_value: + cmd.pop("update", None) + return cmd + + +def _build_expected(spec, sample_value): + """Build expected partial result based on which parameter is being tested.""" + # These keywords replace query or collection name, so the doc may not be found + if spec.keyword in ("query", "findAndModify"): + return {"ok": 1.0} + # remove with truthy value deletes the matched doc + if spec.id == "remove" and sample_value: + return {"ok": 1.0, "lastErrorObject": {"n": 1}, "value": {"_id": 1, "x": 10}} + # All other keywords: query is {_id:1} which matches, update runs + return {"ok": 1.0, "lastErrorObject": {"n": 1, "updatedExisting": True}} + + +@pytest.mark.parametrize("bson_type,sample_value,spec", REJECTION_TESTS) +def test_findAndModify_bson_type_rejected(collection, bson_type, sample_value, spec): + """Verifies findAndModify rejects invalid BSON types for each parameter.""" + collection.insert_one({"_id": 1, "x": 10}) + result = execute_command(collection, _build_command(collection.name, spec, sample_value)) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ACCEPTANCE_TESTS) +def test_findAndModify_bson_type_accepted(collection, bson_type, sample_value, spec): + """Verifies findAndModify accepts valid BSON types for each parameter.""" + collection.insert_one({"_id": 1, "x": 10}) + result = execute_command(collection, _build_command(collection.name, spec, sample_value)) + assertSuccessPartial( + result, + _build_expected(spec, sample_value), + msg=f"findAndModify should accept {bson_type.value} for {spec.keyword}", + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_core_behavior.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_core_behavior.py new file mode 100644 index 000000000..a110254ee --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_core_behavior.py @@ -0,0 +1,539 @@ +""" +Tests for findAndModify core behavior: update, remove, upsert, new flag, +sort selection, and response structure. +""" + +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_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +NEW_FLAG_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "update-new-false-returns-pre-image", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "new": False, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + "value": Eq({"_id": 1, "x": 10}), + "ok": Eq(1.0), + }, + msg="update with new:false returns pre-modification document", + ), + CommandTestCase( + "update-new-true-returns-post-image", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "new": True, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + "value": Eq({"_id": 1, "x": 20}), + "ok": Eq(1.0), + }, + msg="update with new:true returns post-modification document", + ), + CommandTestCase( + "new-omitted-defaults-to-pre-image", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="new omitted defaults to returning pre-modification document", + ), + CommandTestCase( + "new-true-noop-update-returns-doc", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 10}}, + "new": True, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + "value": Eq({"_id": 1, "x": 10}), + }, + msg="new:true on noop update still returns doc with updatedExisting", + ), +] + +REMOVE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "remove-returns-removed-document", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "remove": True, + }, + expected={ + "lastErrorObject": Eq({"n": 1}), + "value": Eq({"_id": 1, "x": 10}), + "ok": Eq(1.0), + }, + msg="remove returns the removed document", + ), + CommandTestCase( + "remove-no-match-returns-null", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 999}, + "remove": True, + }, + expected={ + "lastErrorObject": Eq({"n": 0}), + "value": Eq(None), + "ok": Eq(1.0), + }, + msg="remove with no match returns value:null", + ), + CommandTestCase( + "remove-empty-query-deletes-one", + docs=[{"_id": 1, "x": 10}, {"_id": 2, "x": 20}], + command={ + "query": {}, + "remove": True, + }, + expected={"lastErrorObject": Eq({"n": 1}), "ok": Eq(1.0)}, + msg="remove with empty query deletes one document", + ), + CommandTestCase( + "remove-descending-id-sort", + docs=[{"_id": 1, "x": 10}, {"_id": 2, "x": 20}, {"_id": 3, "x": 30}], + command={ + "query": {}, + "remove": True, + "sort": {"_id": -1}, + }, + expected={"value": Eq({"_id": 3, "x": 30})}, + msg="remove with sort:{_id:-1} removes highest _id", + ), + CommandTestCase( + "remove-ascending-id-sort", + docs=[{"_id": 1, "x": 10}, {"_id": 2, "x": 20}, {"_id": 3, "x": 30}], + command={ + "query": {}, + "remove": True, + "sort": {"_id": 1}, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="remove with sort:{_id:1} removes lowest _id", + ), + CommandTestCase( + "remove-nested-field-sort", + docs=[ + {"_id": 1, "a": {"b": 30}}, + {"_id": 2, "a": {"b": 10}}, + {"_id": 3, "a": {"b": 20}}, + ], + command={ + "query": {}, + "remove": True, + "sort": {"a.b": -1}, + }, + expected={"value": Eq({"_id": 1, "a": {"b": 30}})}, + msg="remove with sort on nested field path selects correct doc", + ), + CommandTestCase( + "remove-with-query-and-sort", + docs=[ + {"_id": 1, "x": 5}, + {"_id": 2, "x": 15}, + {"_id": 3, "x": 25}, + ], + command={ + "query": {"x": {"$gt": 10}}, + "remove": True, + "sort": {"x": -1}, + }, + expected={"value": Eq({"_id": 3, "x": 25})}, + msg="remove with range query and descending sort selects highest match", + ), + CommandTestCase( + "truthy-numeric-remove", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "remove": 1, + }, + expected={ + "value": Eq({"_id": 1, "x": 10}), + "lastErrorObject": Eq({"n": 1}), + }, + msg="accepts truthy numeric value for remove flag", + ), +] + +UPSERT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "upsert-inserts-new-document", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 10}}, + "upsert": True, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": False, "upserted": 1}), + "ok": Eq(1.0), + }, + msg="upsert inserts when no match; lastErrorObject has upserted", + ), +] + +SORT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "sort-ascending-selects-first", + docs=[{"_id": 1, "x": 30}, {"_id": 2, "x": 10}, {"_id": 3, "x": 20}], + command={ + "query": {}, + "update": {"$set": {"modified": True}}, + "sort": {"x": 1}, + }, + expected={"value": Eq({"_id": 2, "x": 10})}, + msg="sort:{x:1} selects lowest x value", + ), + CommandTestCase( + "sort-descending-selects-highest", + docs=[{"_id": 1, "x": 30}, {"_id": 2, "x": 10}, {"_id": 3, "x": 20}], + command={ + "query": {}, + "update": {"$set": {"modified": True}}, + "sort": {"x": -1}, + }, + expected={"value": Eq({"_id": 1, "x": 30})}, + msg="sort:{x:-1} selects highest x value", + ), + CommandTestCase( + "sort-with-update-returns-pre-image", + docs=[ + {"_id": 1, "priority": 1, "count": 0}, + {"_id": 2, "priority": 5, "count": 0}, + {"_id": 3, "priority": 3, "count": 0}, + ], + command={ + "query": {}, + "sort": {"priority": -1}, + "update": {"$inc": {"count": 1}, "$set": {"active": True}}, + }, + expected={"value": Eq({"_id": 2, "priority": 5, "count": 0})}, + msg="sort with multi-operator update returns pre-image by default", + ), + CommandTestCase( + "sort-descending-updates-highest", + docs=[ + {"_id": 1, "priority": 1}, + {"_id": 2, "priority": 2}, + {"_id": 3, "priority": 3}, + ], + command={ + "query": {}, + "sort": {"priority": -1}, + "update": {"$set": {"done": True}}, + }, + expected={"value": Eq({"_id": 3, "priority": 3})}, + msg="descending sort updates highest-ranked document", + ), + CommandTestCase( + "compound-sort", + docs=[ + {"_id": 1, "a": 1, "b": 2}, + {"_id": 2, "a": 1, "b": 1}, + {"_id": 3, "a": 2, "b": 1}, + ], + command={ + "query": {}, + "sort": {"a": 1, "b": 1}, + "update": {"$set": {"done": True}}, + }, + expected={"value": Eq({"_id": 2, "a": 1, "b": 1})}, + msg="sort with multiple fields (compound sort) selects correctly", + ), + CommandTestCase( + "sort-id-ascending-selects-min", + docs=[{"_id": 3, "x": 1}, {"_id": 1, "x": 1}, {"_id": 2, "x": 1}], + command={ + "query": {}, + "sort": {"_id": 1}, + "update": {"$set": {"done": True}}, + }, + expected={"value": Eq({"_id": 1, "x": 1})}, + msg="sort:{_id:1} selects minimum _id", + ), + CommandTestCase( + "sort-id-descending-selects-max", + docs=[{"_id": 3, "x": 1}, {"_id": 1, "x": 1}, {"_id": 2, "x": 1}], + command={ + "query": {}, + "sort": {"_id": -1}, + "update": {"$set": {"done": True}}, + }, + expected={"value": Eq({"_id": 3, "x": 1})}, + msg="sort:{_id:-1} selects maximum _id", + ), + CommandTestCase( + "sort-on-field-absent-in-some-docs", + docs=[ + {"_id": 1, "x": 10}, + {"_id": 2}, + {"_id": 3, "x": 5}, + {"_id": 4}, + ], + command={ + "query": {}, + "sort": {"x": 1}, + "update": {"$set": {"done": True}}, + }, + expected={"value": Eq({"_id": 2})}, + msg="sort on field absent in some docs: missing field sorts before present values", + ), + CommandTestCase( + "sort-descending-field-absent-selects-highest", + docs=[ + {"_id": 1, "x": 10}, + {"_id": 2}, + {"_id": 3, "x": 5}, + ], + command={ + "query": {}, + "sort": {"x": -1}, + "update": {"$set": {"done": True}}, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="sort descending on field absent in some docs selects highest value", + ), + CommandTestCase( + "sort-mixed-bson-types-ascending", + docs=[ + {"_id": 1, "x": "hello"}, + {"_id": 2, "x": 10}, + {"_id": 3, "x": None}, + {"_id": 4, "x": True}, + ], + command={ + "query": {}, + "sort": {"x": 1}, + "update": {"$set": {"done": True}}, + }, + expected={"value": Eq({"_id": 3, "x": None})}, + msg="sort ascending on mixed BSON types follows BSON comparison order", + ), +] + +UPDATE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "update-no-match-returns-null", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 999}, + "update": {"$set": {"x": 20}}, + }, + expected={ + "lastErrorObject": Eq({"n": 0, "updatedExisting": False}), + "value": Eq(None), + "ok": Eq(1.0), + }, + msg="update with no match returns value:null", + ), + CommandTestCase( + "update-empty-collection-returns-null", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 1}}, + }, + expected={ + "lastErrorObject": Eq({"n": 0, "updatedExisting": False}), + "value": Eq(None), + "ok": Eq(1.0), + }, + msg="update on empty collection returns null", + ), + CommandTestCase( + "update-operators-inc-push", + docs=[{"_id": 1, "x": 10, "tags": ["a"]}], + command={ + "query": {"_id": 1}, + "update": {"$inc": {"x": 5}, "$push": {"tags": "b"}}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 15, "tags": ["a", "b"]})}, + msg="update operators ($inc/$push) apply correctly", + ), +] + +RESPONSE_STRUCTURE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "response-remove-n-is-1", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "remove": True, + }, + expected={"lastErrorObject": Eq({"n": 1}), "ok": Eq(1.0)}, + msg="remove response: lastErrorObject.n==1", + ), + CommandTestCase( + "response-no-match-value-null-n-zero", + docs=[], + command={ + "query": {"_id": 999}, + "update": {"$set": {"x": 1}}, + }, + expected={ + "lastErrorObject": Eq({"n": 0, "updatedExisting": False}), + "value": Eq(None), + "ok": Eq(1.0), + }, + msg="no-match response: value==null, lastErrorObject.n==0", + ), +] + +ALL_TESTS: list[CommandTestCase] = ( + NEW_FLAG_TESTS + + REMOVE_TESTS + + UPSERT_TESTS + + SORT_TESTS + + UPDATE_TESTS + + RESPONSE_STRUCTURE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_findAndModify_core(database_client, collection, test): + """Test findAndModify core behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_command(collection, command) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +def test_findAndModify_remove_actually_deletes_document(collection): + """Test findAndModify remove deletes the document from the collection.""" + collection.insert_one({"_id": 1, "x": 10}) + execute_command( + collection, + {"findAndModify": collection.name, "query": {"_id": 1}, "remove": True}, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess(result, []) + + +def test_findAndModify_upsert_new_false_still_inserts(collection): + """Test findAndModify upsert with new:false still inserts the document.""" + execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$set": {"x": 10}}, + "upsert": True, + "new": False, + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess(result, [{"_id": 1, "x": 10}]) + + +def test_findAndModify_updates_exactly_one_document(collection): + """Test findAndModify updates only one document when query matches multiple.""" + collection.insert_many([{"_id": i, "status": "active", "x": i} for i in range(1, 6)]) + execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"status": "active"}, + "sort": {"_id": 1}, + "update": {"$set": {"status": "done"}}, + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"status": "done"}}) + assertSuccess(result, [{"_id": 1, "status": "done", "x": 1}]) + + +def test_findAndModify_empty_query_no_sort_single_mod(collection): + """Test findAndModify with empty query and no sort modifies exactly one document.""" + collection.insert_many([{"_id": i, "x": i} for i in range(1, 6)]) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {}, + "update": {"$set": {"done": True}}, + }, + ) + assertSuccessPartial(result, {"lastErrorObject": {"n": 1, "updatedExisting": True}}) + + +def test_findAndModify_remove_nonexistent_collection(database_client, request): + """Test findAndModify remove on non-existent collection returns null, ok:1.""" + coll = database_client[f"{request.node.name}_nonexistent"] + result = execute_command( + coll, + { + "findAndModify": coll.name, + "query": {"_id": 1}, + "remove": True, + }, + ) + assertSuccess( + result, + {"lastErrorObject": {"n": 0}, "value": None, "ok": 1.0}, + raw_res=True, + ) + + +def test_findAndModify_upsert_creates_collection(database_client, request): + """Test findAndModify upsert on non-existent collection creates it.""" + coll = database_client[f"{request.node.name}_new"] + result = execute_command( + coll, + { + "findAndModify": coll.name, + "query": {"_id": 1}, + "update": {"$set": {"x": 1}}, + "upsert": True, + "new": True, + }, + ) + assertResult( + result, + expected={"value": Eq({"_id": 1, "x": 1}), "ok": Eq(1.0)}, + raw_res=True, + ) + + +@pytest.mark.smoke +def test_smoke_findAndModify(collection): + """Test basic findAndModify command behavior.""" + collection.insert_one({"_id": 1, "name": "Alice", "count": 10}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$inc": {"count": 5}}, + "new": True, + }, + ) + assertSuccessPartial(result, {"ok": 1.0, "value": {"_id": 1, "name": "Alice", "count": 15}}) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_data_types.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_data_types.py new file mode 100644 index 000000000..c15c22e98 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_data_types.py @@ -0,0 +1,188 @@ +""" +Tests for findAndModify data type coverage: BSON types, null semantics, +numeric equivalence. +""" + +import pytest +from bson import Binary, Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult, assertSuccessPartial +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.test_constants import BSON_TYPE_SAMPLES, BsonType + +NULL_SEMANTICS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "query-null-matches-null-and-missing", + docs=[{"_id": 1, "f": None}, {"_id": 2, "f": 10}, {"_id": 3}], + command={ + "query": {"f": None}, + "update": {"$set": {"matched": True}}, + "sort": {"_id": 1}, + }, + expected={"value": Eq({"_id": 1, "f": None})}, + msg="query {f:null} matches docs where f is null AND where f is missing", + ), + CommandTestCase( + "set-null-distinct-from-unset", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": None}}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": None})}, + msg="$set:{f:null} stores null (distinct from removing field)", + ), + CommandTestCase( + "query-false-does-not-match-zero", + docs=[{"_id": 1, "f": 0}, {"_id": 2, "f": False}], + command={ + "query": {"f": False}, + "update": {"$set": {"matched": True}}, + "sort": {"_id": 1}, + }, + expected={"value": Eq({"_id": 2, "f": False})}, + msg="query {f:false} does NOT match document with f:0", + ), + CommandTestCase( + "query-empty-string-does-not-match-null", + docs=[{"_id": 1, "f": None}, {"_id": 2, "f": ""}], + command={ + "query": {"f": ""}, + "update": {"$set": {"matched": True}}, + "sort": {"_id": 1}, + }, + expected={"value": Eq({"_id": 2, "f": ""})}, + msg='query {f:""} does NOT match document with f:null', + ), + CommandTestCase( + "decimal128-query-match-and-roundtrip", + docs=[{"_id": 1, "f": Decimal128("123.456")}], + command={ + "query": {"f": Decimal128("123.456")}, + "update": {"$set": {"matched": True}}, + }, + expected={"value": Eq({"_id": 1, "f": Decimal128("123.456")})}, + msg="Decimal128 query matches and preserves type", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(NULL_SEMANTICS_TESTS)) +def test_findAndModify_data_types(database_client, collection, test): + """Test findAndModify data type semantics.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_command(collection, command) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +@pytest.mark.parametrize( + "stored,query_value", + [ + pytest.param(1, 1, id="int32_matches_1"), + pytest.param(1, Int64(1), id="int64_matches_1"), + pytest.param(1, 1.0, id="double_matches_1"), + pytest.param(1, Decimal128("1"), id="decimal_matches_1"), + pytest.param(0, 0, id="int32_matches_0"), + pytest.param(0, Int64(0), id="int64_matches_0"), + pytest.param(0, 0.0, id="double_matches_0"), + pytest.param(0, Decimal128("0"), id="decimal_matches_0"), + ], +) +def test_findAndModify_numeric_equivalence(collection, stored, query_value): + """Test numeric equivalence: different numeric types match the same stored value.""" + collection.insert_one({"_id": 1, "f": stored}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"f": query_value}, + "update": {"$set": {"matched": True}}, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1, "f": stored}, "lastErrorObject": {"n": 1}}) + + +BSON_TYPE_VALUES = [ + pytest.param(BSON_TYPE_SAMPLES[BsonType.DOUBLE], id="double"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.STRING], id="string"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.OBJECT], id="object"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.ARRAY], id="array"), + pytest.param(Binary(b"\x00\x01\x02", 128), id="binary"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.OBJECT_ID], id="objectid"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.BOOL], id="bool"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.DATE], id="date"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.NULL], id="null"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.REGEX], id="regex"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.INT], id="int32"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.TIMESTAMP], id="timestamp"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.LONG], id="int64"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.DECIMAL], id="decimal128"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.MIN_KEY], id="minkey"), + pytest.param(BSON_TYPE_SAMPLES[BsonType.MAX_KEY], id="maxkey"), +] + + +@pytest.mark.parametrize("value", BSON_TYPE_VALUES) +def test_findAndModify_bson_type_query_match(collection, value): + """Test findAndModify query equality match works for each BSON type.""" + collection.insert_one({"_id": 1, "f": value}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"f": value}, + "update": {"$set": {"matched": True}}, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1, "f": value}, "lastErrorObject": {"n": 1}}) + + +@pytest.mark.parametrize("value", BSON_TYPE_VALUES) +def test_findAndModify_bson_type_set_roundtrip(collection, value): + """Test findAndModify $set stores BSON type and round-trips it.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$set": {"f": value}}, + "new": True, + }, + ) + assertSuccessPartial( + result, {"value": {"_id": 1, "f": value}, "lastErrorObject": {"updatedExisting": True}} + ) + + +@pytest.mark.parametrize("value", BSON_TYPE_VALUES) +def test_findAndModify_bson_type_replacement_roundtrip(collection, value): + """Test findAndModify replacement document preserves BSON type on round-trip.""" + collection.insert_one({"_id": 1, "f": "original"}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"_id": 1, "f": value}, + "new": True, + }, + ) + assertSuccessPartial( + result, {"value": {"_id": 1, "f": value}, "lastErrorObject": {"updatedExisting": True}} + ) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_dollar_prefixed_fields.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_dollar_prefixed_fields.py new file mode 100644 index 000000000..102a71367 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_dollar_prefixed_fields.py @@ -0,0 +1,77 @@ +"""Tests for findAndModify with nested dollar-prefixed field names.""" + +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_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +DOLLAR_PREFIXED_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "set_nested_dollar_field_succeeds", + docs=[{"_id": 1, "a": {"$x": 1}}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"a.$x": 99}}, + "new": True, + }, + expected={"value": {"_id": Eq(1), "a": Eq({"$x": 99})}}, + msg="$set on nested dollar-prefixed field should succeed", + ), + CommandTestCase( + "inc_nested_dollar_field_succeeds", + docs=[{"_id": 1, "a": {"$count": 5}}], + command={ + "query": {"_id": 1}, + "update": {"$inc": {"a.$count": 1}}, + "new": True, + }, + expected={"value": {"_id": Eq(1), "a": Eq({"$count": 6})}}, + msg="$inc on nested dollar-prefixed field should succeed", + ), + CommandTestCase( + "mul_nested_dollar_field_succeeds", + docs=[{"_id": 1, "a": {"$val": 5}}], + command={ + "query": {"_id": 1}, + "update": {"$mul": {"a.$val": 2}}, + "new": True, + }, + expected={"value": {"_id": Eq(1), "a": Eq({"$val": 10})}}, + msg="$mul on nested dollar-prefixed field should succeed", + ), + CommandTestCase( + "max_nested_dollar_field_succeeds", + docs=[{"_id": 1, "a": {"$val": 5}}], + command={ + "query": {"_id": 1}, + "update": {"$max": {"a.$val": 10}}, + "new": True, + }, + expected={"value": {"_id": Eq(1), "a": Eq({"$val": 10})}}, + msg="$max on nested dollar-prefixed field should succeed", + ), +] + +ALL_TESTS = DOLLAR_PREFIXED_SUCCESS_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_findAndModify_dollar_prefixed_fields(database_client, collection, test): + """Test findAndModify with dollar-prefixed and dotted field names.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_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/findAndModify/test_findAndModify_errors.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_errors.py new file mode 100644 index 000000000..240ca31af --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_errors.py @@ -0,0 +1,514 @@ +""" +Tests for findAndModify error cases: argument conflicts, immutable field +violations, invalid projections, constraint violations, and arrayFilters errors. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, + IndexModel, +) +from documentdb_tests.framework.assertions import assertFailureCode, assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + DOCUMENT_VALIDATION_FAILURE_ERROR, + DOLLAR_PREFIXED_FIELD_NAME_ERROR, + DUPLICATE_KEY_ERROR, + FAILED_TO_PARSE_ERROR, + IMMUTABLE_FIELD_ERROR, + INVALID_OPTIONS_ERROR, + LET_UNDEFINED_VARIABLE_ERROR, + PROJECT_EXCLUSION_IN_INCLUSION_ERROR, + PROJECT_UNKNOWN_EXPRESSION_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +ARGUMENT_CONFLICT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "neither-update-nor-remove", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="findAndModify with neither update nor remove should fail", + ), + CommandTestCase( + "both-remove-and-update", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "remove": True, + "update": {"$set": {"x": 1}}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="findAndModify with both remove and update should fail", + ), + CommandTestCase( + "remove-and-new-true", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "remove": True, + "new": True, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="findAndModify with remove:true and new:true should fail", + ), + CommandTestCase( + "remove-and-upsert", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "remove": True, + "upsert": True, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="findAndModify with remove:true and upsert:true should fail", + ), + CommandTestCase( + "remove-and-update-with-sort", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "remove": True, + "update": {"$set": {"x": 1}}, + "sort": {"_id": 1}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="findAndModify with both remove and update plus sort should fail", + ), + CommandTestCase( + "remove-update-and-upsert", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "remove": True, + "update": {"$set": {"x": 1}}, + "upsert": True, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="findAndModify with remove, update and upsert all set should fail", + ), + CommandTestCase( + "unknown-field", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 1}}, + "unknownField": True, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="findAndModify with unrecognized top-level field should fail", + ), + CommandTestCase( + "mixed-dollar-and-plain-keys", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}, "y": 30}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="update mixing dollar-prefixed and plain keys should fail", + ), + CommandTestCase( + "pipeline-disallowed-stage", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": [{"$match": {"x": 10}}], + }, + error_code=INVALID_OPTIONS_ERROR, + msg="pipeline update with disallowed stage ($match) should fail", + ), +] + +BAD_VALUE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "hint-nonexistent-index", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "hint": "nonexistent_index", + }, + error_code=BAD_VALUE_ERROR, + msg="hint referencing non-existent index should fail with BadValue", + ), + CommandTestCase( + "maxTimeMS-negative", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 1}}, + "maxTimeMS": -1, + }, + error_code=BAD_VALUE_ERROR, + msg="negative maxTimeMS should fail with BadValue", + ), +] + +PARSE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "maxTimeMS-fractional-double", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 1}}, + "maxTimeMS": 100.5, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="maxTimeMS as fractional double should fail", + ), +] + +IMMUTABLE_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "replacement-change-id", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"_id": 2, "x": 20}, + }, + error_code=IMMUTABLE_FIELD_ERROR, + msg="replacement attempting to change _id should fail", + ), + CommandTestCase( + "update-change-id-via-operator", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"_id": 2}}, + }, + error_code=IMMUTABLE_FIELD_ERROR, + msg="changing _id via $set operator should fail", + ), + CommandTestCase( + "setOnInsert-duplicate-id", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 2}, + "update": {"$setOnInsert": {"_id": 1}}, + "upsert": True, + }, + error_code=IMMUTABLE_FIELD_ERROR, + msg="upsert with $setOnInsert producing duplicate _id should fail", + ), + CommandTestCase( + "setOnInsert-id-mismatch", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$setOnInsert": {"_id": 2, "x": 10}}, + "upsert": True, + }, + error_code=IMMUTABLE_FIELD_ERROR, + msg="upsert with selector _id differing from $setOnInsert _id should fail", + ), + CommandTestCase( + "setOnInsert-document-id-different-values", + docs=[], + command={ + "query": {"_id": {"a": 1, "b": 2}}, + "update": {"$setOnInsert": {"_id": {"a": 1, "b": 99}, "x": 10}}, + "upsert": True, + }, + error_code=IMMUTABLE_FIELD_ERROR, + msg="upsert with document-typed _id having different field values should fail", + ), + CommandTestCase( + "setOnInsert-dotted-id-mismatch", + docs=[], + command={ + "query": {"_id.a": 1}, + "update": {"$setOnInsert": {"_id": {"a": 2}, "x": 10}}, + "upsert": True, + }, + error_code=IMMUTABLE_FIELD_ERROR, + msg="upsert with dotted id values differing should fail", + ), + CommandTestCase( + "setOnInsert-array-id-different-order", + docs=[], + command={ + "query": {"_id": [1, 2, 3]}, + "update": {"$setOnInsert": {"_id": [3, 2, 1], "x": 10}}, + "upsert": True, + }, + error_code=IMMUTABLE_FIELD_ERROR, + msg="upsert with array id having different element ordering should fail", + ), +] + +PROJECTION_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "projection-mixed-inclusion-exclusion", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 99}}, + "fields": {"x": 1, "y": 0}, + }, + error_code=PROJECT_EXCLUSION_IN_INCLUSION_ERROR, + msg="fields projection mixing inclusion and exclusion should fail", + ), + CommandTestCase( + "projection-invalid-operator-no-flags", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "fields": {"x": {"$inc": 1}}, + }, + error_code=PROJECT_UNKNOWN_EXPRESSION_ERROR, + msg="update-style operator in projection should fail (no upsert/new flags)", + ), + CommandTestCase( + "projection-invalid-operator-upsert-only", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "upsert": True, + "fields": {"x": {"$inc": 1}}, + }, + error_code=PROJECT_UNKNOWN_EXPRESSION_ERROR, + msg="update-style operator in projection should fail (upsert only)", + ), + CommandTestCase( + "projection-invalid-operator-new-only", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "new": True, + "fields": {"x": {"$inc": 1}}, + }, + error_code=PROJECT_UNKNOWN_EXPRESSION_ERROR, + msg="update-style operator in projection should fail (new only)", + ), + CommandTestCase( + "projection-invalid-operator-upsert-and-new", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}}, + "upsert": True, + "new": True, + "fields": {"x": {"$inc": 1}}, + }, + error_code=PROJECT_UNKNOWN_EXPRESSION_ERROR, + msg="update-style operator in projection should fail (upsert + new)", + ), + CommandTestCase( + "projection-invalid-operator-insert-no-match", + docs=[], + command={ + "query": {"_id": 99}, + "update": {"$set": {"x": 20}}, + "upsert": True, + "new": True, + "fields": {"x": {"$inc": 1}}, + }, + error_code=PROJECT_UNKNOWN_EXPRESSION_ERROR, + msg="update-style operator in projection should fail (upsert insert path)", + ), +] + +DUPLICATE_KEY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "duplicate-key-violation", + docs=[{"_id": 1, "key": "a"}, {"_id": 2, "key": "b"}], + indexes=[IndexModel("key", unique=True)], + command={ + "query": {"_id": 2}, + "update": {"$set": {"key": "a"}}, + }, + error_code=DUPLICATE_KEY_ERROR, + msg="update causing unique index violation should fail with DuplicateKey", + ), +] + +ARGUMENT_ERROR_TESTS: list[CommandTestCase] = ( + ARGUMENT_CONFLICT_TESTS + + BAD_VALUE_TESTS + + PARSE_ERROR_TESTS + + IMMUTABLE_FIELD_TESTS + + PROJECTION_ERROR_TESTS + + DUPLICATE_KEY_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ARGUMENT_ERROR_TESTS)) +def test_findAndModify_argument_errors(database_client, collection, test): + """Test findAndModify argument validation errors.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_command(collection, command) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +ARRAY_FILTERS_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unreferenced_identifier_fails", + docs=[{"_id": 1, "grades": [85, 92]}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"grades.0": 100}}, + "arrayFilters": [{"elem": {"$gte": 90}}], + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="arrayFilters identifier not referenced in update should produce an error", + ), + CommandTestCase( + "identifier_no_corresponding_filter_fails", + docs=[{"_id": 1, "grades": [85, 92]}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"grades.$[elem]": 100}}, + }, + error_code=BAD_VALUE_ERROR, + msg="update with $[identifier] but no matching arrayFilters entry should produce an error", + ), + CommandTestCase( + "duplicate_identifier_fails", + docs=[{"_id": 1, "grades": [85, 92]}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"grades.$[elem]": 100}}, + "arrayFilters": [{"elem": {"$gte": 90}}, {"elem": {"$lte": 80}}], + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="two arrayFilters for the same identifier should produce an error", + ), + CommandTestCase( + "with_pipeline_update_fails", + docs=[{"_id": 1, "grades": [85, 92]}], + command={ + "query": {"_id": 1}, + "update": [{"$set": {"modified": True}}], + "arrayFilters": [{"elem": {"$gte": 90}}], + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="arrayFilters with aggregation-pipeline update should produce an error", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ARRAY_FILTERS_ERROR_TESTS)) +def test_findAndModify_array_filters_errors(database_client, collection, test): + """Test findAndModify arrayFilters validation errors.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_command(collection, command) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +DOLLAR_PREFIXED_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "set_top_level_dollar_field_fails", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"$bad": 1}}, + }, + error_code=DOLLAR_PREFIXED_FIELD_NAME_ERROR, + msg="$set on top-level dollar-prefixed field should fail", + ), + CommandTestCase( + "inc_top_level_dollar_field_fails", + docs=[{"_id": 1, "$val": 10}], + command={ + "query": {"_id": 1}, + "update": {"$inc": {"$val": 1}}, + }, + error_code=DOLLAR_PREFIXED_FIELD_NAME_ERROR, + msg="$inc on top-level dollar-prefixed field should fail", + ), + CommandTestCase( + "mul_top_level_dollar_field_fails", + docs=[{"_id": 1, "$val": 10}], + command={ + "query": {"_id": 1}, + "update": {"$mul": {"$val": 2}}, + }, + error_code=DOLLAR_PREFIXED_FIELD_NAME_ERROR, + msg="$mul on top-level dollar-prefixed field should fail", + ), + CommandTestCase( + "max_top_level_dollar_field_fails", + docs=[{"_id": 1, "$val": 10}], + command={ + "query": {"_id": 1}, + "update": {"$max": {"$val": 20}}, + }, + error_code=DOLLAR_PREFIXED_FIELD_NAME_ERROR, + msg="$max on top-level dollar-prefixed field should fail", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(DOLLAR_PREFIXED_ERROR_TESTS)) +def test_findAndModify_dollar_prefixed_errors(database_client, collection, test): + """Test findAndModify dollar-prefixed field validation errors.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_command(collection, command) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +def test_findAndModify_validation_rejects_invalid_write(database_client, request): + """Test bypassDocumentValidation:false rejects non-conforming update.""" + db = database_client + coll_name = f"{request.node.name}_validated" + db.create_collection(coll_name, validator={"$jsonSchema": {"required": ["name"]}}) + coll = db[coll_name] + coll.insert_one({"_id": 1, "name": "test"}) + result = execute_command( + coll, + { + "findAndModify": coll.name, + "query": {"_id": 1}, + "update": {"$unset": {"name": ""}}, + }, + ) + assertFailureCode(result, DOCUMENT_VALIDATION_FAILURE_ERROR) + + +def test_findAndModify_expr_undefined_variable_fails(collection): + """Test $expr referencing undefined variable produces an error.""" + collection.insert_one({"_id": 1, "a": 10}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"$expr": {"$eq": ["$a", "$$undefined_var"]}}, + "update": {"$set": {"x": 1}}, + }, + ) + assertFailureCode(result, LET_UNDEFINED_VARIABLE_ERROR) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_expr_let.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_expr_let.py new file mode 100644 index 000000000..d0df1fbba --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_expr_let.py @@ -0,0 +1,116 @@ +"""Tests for findAndModify with $expr in query and let variables.""" + +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_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +EXPR_LET_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "expr_comparison_selects_correct_doc", + docs=[ + {"_id": 1, "a": 5, "b": 3}, + {"_id": 2, "a": 1, "b": 10}, + ], + command={ + "query": {"$expr": {"$gt": ["$a", "$b"]}}, + "update": {"$set": {"matched": True}}, + "sort": {"_id": 1}, + }, + expected={"value": {"_id": Eq(1), "a": Eq(5), "b": Eq(3)}}, + msg="findAndModify query using $expr with $gt should select correct document", + ), + CommandTestCase( + "expr_with_let_variable", + docs=[ + {"_id": 1, "a": 10}, + {"_id": 2, "a": 20}, + ], + command={ + "query": {"$expr": {"$eq": ["$a", "$$target"]}}, + "update": {"$set": {"found": True}}, + "let": {"target": 20}, + }, + expected={"value": {"_id": Eq(2), "a": Eq(20)}}, + msg="$expr referencing a let variable should select correct document", + ), + CommandTestCase( + "let_variable_in_pipeline_update", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": [{"$set": {"y": "$$bonus"}}], + "let": {"bonus": 100}, + "new": True, + }, + expected={"value": {"_id": Eq(1), "x": Eq(10), "y": Eq(100)}}, + msg="findAndModify with let variable used in update pipeline should apply variable value", + ), + CommandTestCase( + "expr_with_sort_composes_correctly", + docs=[ + {"_id": 1, "a": 5, "b": 3}, + {"_id": 2, "a": 8, "b": 2}, + {"_id": 3, "a": 1, "b": 10}, + ], + command={ + "query": {"$expr": {"$gt": ["$a", "$b"]}}, + "update": {"$set": {"matched": True}}, + "sort": {"_id": -1}, + }, + # Both _id:1 (a=5>b=3) and _id:2 (a=8>b=2) match; sort desc picks _id:2 + expected={"value": {"_id": Eq(2), "a": Eq(8), "b": Eq(2)}}, + msg="$expr filtering combined with descending sort picks highest _id among matches", + ), + CommandTestCase( + "let_in_replacement_update", + docs=[{"_id": 1, "a": 5}, {"_id": 2, "a": 10}], + command={ + "query": {"$expr": {"$eq": ["$a", "$$val"]}}, + "update": {"a": 99, "replaced": True}, + "let": {"val": 10}, + "new": True, + }, + expected={"value": {"_id": Eq(2), "a": Eq(99), "replaced": Eq(True)}}, + msg="let variable in $expr query with replacement update should select and replace", + ), + CommandTestCase( + "expr_literal_true_matches_all", + docs=[ + {"_id": 1, "a": 5, "b": 3}, + {"_id": 2, "a": 1, "b": 10}, + {"_id": 3, "a": -1, "b": 0}, + ], + command={ + "query": {"$expr": True}, + "update": {"$set": {"touched": True}}, + "sort": {"_id": 1}, + }, + expected={"value": {"_id": Eq(1), "a": Eq(5), "b": Eq(3)}}, + msg="$expr with literal true should match all and return first by sort", + ), +] + +ALL_TESTS = EXPR_LET_SUCCESS_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_findAndModify_expr_let(database_client, collection, test): + """Test findAndModify $expr in query and let variables.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_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/findAndModify/test_findAndModify_projection.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_projection.py new file mode 100644 index 000000000..e6e2e0324 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_projection.py @@ -0,0 +1,170 @@ +""" +Tests for findAndModify fields projection 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_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +UPDATE_PROJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "new-true-returns-post-image-projection", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 99}}, + "fields": {"x": 1}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 99})}, + msg="fields projection with new:true returns projection of post-modification doc", + ), + CommandTestCase( + "new-false-returns-pre-image-projection", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 99}}, + "fields": {"x": 1}, + "new": False, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="fields projection with new:false returns projection of pre-modification doc", + ), + CommandTestCase( + "exclude-id-from-projection", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 99}}, + "fields": {"_id": 0, "x": 1}, + }, + expected={"value": Eq({"x": 10})}, + msg="fields projection with _id:0 omits _id from returned value", + ), +] + +REMOVE_PROJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "remove-inclusion-projection", + docs=[{"_id": 1, "x": 10, "y": 20, "z": 30}], + command={ + "query": {"_id": 1}, + "remove": True, + "fields": {"x": 1}, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="remove with inclusion projection returns _id plus included field", + ), + CommandTestCase( + "remove-exclusion-projection", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "remove": True, + "fields": {"y": 0}, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="remove with exclusion projection returns document without excluded field", + ), + CommandTestCase( + "remove-exclude-id-and-field", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "remove": True, + "fields": {"_id": 0, "x": 0}, + }, + expected={"value": Eq({"y": 20})}, + msg="remove with exclusion of both _id and a field", + ), + CommandTestCase( + "remove-combined-query-sort-projection", + docs=[ + {"_id": 1, "x": 5, "y": "a"}, + {"_id": 2, "x": 15, "y": "b"}, + {"_id": 3, "x": 25, "y": "c"}, + ], + command={ + "query": {"x": {"$gt": 10}}, + "remove": True, + "sort": {"x": -1}, + "fields": {"y": 0}, + }, + expected={"value": Eq({"_id": 3, "x": 25})}, + msg="remove combining range query, exclusion projection, and descending sort", + ), +] + +UPSERT_PROJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "projection-on-upsert-new-true", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 10, "y": 20}}, + "upsert": True, + "new": True, + "fields": {"x": 1}, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="projection on upserted document returns only projected fields", + ), +] + +COMPUTED_PROJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "computed-expression-new-true", + docs=[{"_id": 1, "x": 5}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 10}}, + "fields": {"doubled": {"$multiply": ["$x", 2]}, "_id": 0}, + "new": True, + }, + expected={"value": Eq({"doubled": 20})}, + msg="computed expression in fields evaluates against post-image when new:true", + ), + CommandTestCase( + "computed-expression-new-false", + docs=[{"_id": 1, "x": 5}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 10}}, + "fields": {"doubled": {"$multiply": ["$x", 2]}, "_id": 0}, + "new": False, + }, + expected={"value": Eq({"doubled": 10})}, + msg="computed expression in fields evaluates against pre-image when new:false", + ), +] + +ALL_TESTS: list[CommandTestCase] = ( + UPDATE_PROJECTION_TESTS + + REMOVE_PROJECTION_TESTS + + UPSERT_PROJECTION_TESTS + + COMPUTED_PROJECTION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_findAndModify_projection(database_client, collection, test): + """Test findAndModify fields projection behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_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/findAndModify/test_findAndModify_update_modes.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_update_modes.py new file mode 100644 index 000000000..56604b889 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_update_modes.py @@ -0,0 +1,332 @@ +""" +Tests for findAndModify update modes: operator updates, replacements, pipelines, +arrayFilters, boundary inputs, and input interactions. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult, assertSuccessPartial +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +UPDATE_MODE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "all-dollar-keys-is-update-operator", + docs=[{"_id": 1, "x": 10, "y": 5}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 20}, "$inc": {"y": 1}}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 20, "y": 6})}, + msg="update with all dollar-prefixed keys treated as update-operator form", + ), + CommandTestCase( + "no-dollar-keys-is-replacement", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {"z": 30}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "z": 30})}, + msg="update with no dollar-prefixed keys treated as replacement", + ), + CommandTestCase( + "pipeline-references-existing-fields", + docs=[{"_id": 1, "a": 3, "b": 7}], + command={ + "query": {"_id": 1}, + "update": [{"$set": {"total": {"$add": ["$a", "$b"]}}}], + "new": True, + }, + expected={"value": Eq({"_id": 1, "a": 3, "b": 7, "total": 10})}, + msg="pipeline update can reference existing field values", + ), +] + +BOUNDARY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty-set-is-noop", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$set": {}}, + "new": True, + }, + expected={ + "value": Eq({"_id": 1, "x": 10}), + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + }, + msg="update {$set:{}} succeeds with no field change", + ), + CommandTestCase( + "empty-replacement", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {}, + "new": True, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + "value": Eq({"_id": 1}), + "ok": Eq(1.0), + }, + msg="replacement with empty document {} leaves only _id", + ), + CommandTestCase( + "unset-removes-field", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {"$unset": {"y": ""}}, + "new": True, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + "value": Eq({"_id": 1, "x": 10}), + "ok": Eq(1.0), + }, + msg="$unset removes field and new:true doc omits it", + ), + CommandTestCase( + "deeply-nested-set-creates-intermediates", + docs=[{"_id": 1}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"a.b.c.d.e": 1}}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "a": {"b": {"c": {"d": {"e": 1}}}}})}, + msg="$set with deeply nested path creates intermediate documents", + ), +] + +DOTTED_PATH_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "set-dotted-index-on-array", + docs=[{"_id": 1, "a": [10, 20, 30]}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"a.0": 99}}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "a": [99, 20, 30]})}, + msg="$set with dotted numeric index on array updates the element at that index", + ), + CommandTestCase( + "set-dotted-index-on-object", + docs=[{"_id": 1, "a": {"0": "old", "1": "keep"}}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"a.0": "new"}}, + "new": True, + }, + expected={"value": Eq({"_id": 1, "a": {"0": "new", "1": "keep"}})}, + msg="$set with dotted numeric key on object updates the field named '0'", + ), +] + +INTERACTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "upsert-with-sort-no-match-still-inserts", + docs=[], + command={ + "query": {"_id": 1}, + "sort": {"x": -1}, + "update": {"$set": {"x": 10}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="upsert:true + sort: when no match, insert still occurs", + ), + CommandTestCase( + "projection-on-removed-field-new-true", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {"$unset": {"x": ""}}, + "fields": {"x": 1}, + "new": True, + }, + expected={"value": Eq({"_id": 1})}, + msg="fields projection on a field removed by update (new:true) -- absent", + ), + CommandTestCase( + "atomic-pre-image-consistent", + docs=[{"_id": 1, "x": 10, "y": 20}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 99, "y": 99}}, + }, + expected={"value": Eq({"_id": 1, "x": 10, "y": 20})}, + msg="findAndModify pre-image is self-consistent", + ), +] + +ALL_TESTS: list[CommandTestCase] = ( + UPDATE_MODE_TESTS + BOUNDARY_TESTS + DOTTED_PATH_TESTS + INTERACTION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_findAndModify_update_modes(database_client, collection, test): + """Test findAndModify update modes.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_command(collection, command) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +def test_findAndModify_upsert_unique_index_updates_instead_of_dup(collection): + """Test upsert with unique index: second upsert updates rather than duplicates.""" + collection.create_index("key", unique=True) + execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"key": "abc"}, + "update": {"$set": {"val": 1}}, + "upsert": True, + }, + ) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"key": "abc"}, + "update": {"$set": {"val": 2}}, + "upsert": True, + "new": True, + }, + ) + assertSuccessPartial( + result, {"value": {"key": "abc", "val": 2}, "lastErrorObject": {"updatedExisting": True}} + ) + + +def test_findAndModify_array_filters_updates_matching_elements(collection): + """Test arrayFilters restricts update to matching array elements.""" + collection.insert_one({"_id": 1, "grades": [85, 92, 78, 95]}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$set": {"grades.$[elem]": 100}}, + "arrayFilters": [{"elem": {"$gte": 90}}], + "new": True, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1, "grades": [85, 100, 78, 100]}}) + + +def test_findAndModify_array_filters_multiple_identifiers(collection): + """Test arrayFilters with multiple identifiers in same update.""" + collection.insert_one({"_id": 1, "scores": [5, 15, 25, 35]}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": { + "$set": {"scores.$[low]": 0, "scores.$[high]": 100}, + }, + "arrayFilters": [{"low": {"$lt": 10}}, {"high": {"$gt": 30}}], + "new": True, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1, "scores": [0, 15, 25, 100]}}) + + +def test_findAndModify_positional_operator(collection): + """Test positional $ operator updates first matching array element.""" + collection.insert_one({"_id": 1, "items": [10, 20, 30]}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1, "items": 20}, + "update": {"$set": {"items.$": 99}}, + "new": True, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1, "items": [10, 99, 30]}}) + + +def test_findAndModify_pipeline_multiple_stages(collection): + """Test pipeline update with multiple stages.""" + collection.insert_one({"_id": 1, "x": 10, "y": 20, "tmp": "remove_me"}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": [ + {"$set": {"sum": {"$add": ["$x", "$y"]}}}, + {"$unset": "tmp"}, + ], + "new": True, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1, "x": 10, "y": 20, "sum": 30}}) + + +def test_findAndModify_pull(collection): + """Test $pull with findAndModify removes matching elements and returns updated doc.""" + collection.insert_one({"_id": 1, "arr": [1, 2, 3, 2]}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$pull": {"arr": 2}}, + "new": True, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1, "arr": [1, 3]}}) + + +def test_findAndModify_addToSet(collection): + """Test $addToSet with findAndModify adds element and returns updated doc.""" + collection.insert_one({"_id": 1, "arr": ["a"]}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$addToSet": {"arr": "b"}}, + "new": True, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1, "arr": ["a", "b"]}}) + + +def test_findAndModify_upsert_array_filters_no_matching_array(collection): + """Test upsert + arrayFilters when inserted doc has no matching array elements.""" + collection.insert_one({"_id": 1, "grades": [50, 60, 70]}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$set": {"grades.$[elem]": 100}}, + "arrayFilters": [{"elem": {"$gte": 90}}], + "new": True, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1, "grades": [50, 60, 70]}}) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_upsert.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_upsert.py new file mode 100644 index 000000000..1323c4faf --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_upsert.py @@ -0,0 +1,367 @@ +""" +Tests for findAndModify upsert behavior: lastErrorObject, $setOnInsert, +id consistency, auto-increment patterns. +""" + +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.error_codes import IMMUTABLE_FIELD_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Exists, IsType, NotExists + +EQUALITY_SEEDING_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "equality-query-seeds-field", + docs=[], + command={ + "query": {"x": 10}, + "update": {"$set": {"y": 20}}, + "upsert": True, + "new": True, + }, + expected={"value": {"_id": Exists(), "x": Eq(10), "y": Eq(20)}}, + msg="upsert with equality predicate seeds field value into inserted doc", + ), + CommandTestCase( + "non-equality-does-not-seed", + docs=[], + command={ + "query": {"x": {"$gt": 5}}, + "update": {"$set": {"y": 20}}, + "upsert": True, + "new": True, + }, + expected={"value": {"_id": Exists(), "x": NotExists(), "y": Eq(20)}}, + msg="upsert with non-equality operator ($gt) does NOT seed field", + ), + CommandTestCase( + "no-id-auto-creates-id", + docs=[], + command={ + "query": {"x": 10}, + "update": {"$set": {"x": 10, "y": 20}}, + "upsert": True, + "new": True, + }, + expected={"value": {"_id": IsType("objectId"), "x": Eq(10), "y": Eq(20)}}, + msg="upsert with no _id in query/update auto-creates an ObjectId _id", + ), +] + +REPLACEMENT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "replacement-inserts-replacement", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"_id": 1, "z": 99}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": 1, "z": 99})}, + msg="upsert with replacement update inserts the replacement document", + ), +] + +LAST_ERROR_OBJECT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "lastErrorObject-upserted-id", + docs=[], + command={ + "query": {"_id": 42}, + "update": {"$set": {"x": 1}}, + "upsert": True, + }, + expected={ + "lastErrorObject": Eq({"n": 1, "updatedExisting": False, "upserted": 42}), + }, + msg="lastErrorObject.upserted is the _id of the new document", + ), + CommandTestCase( + "new-false-returns-null-value-key", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 10}}, + "upsert": True, + "new": False, + }, + expected={ + "value": Eq(None), + "lastErrorObject": Eq({"n": 1, "updatedExisting": False, "upserted": 1}), + }, + msg="upsert insert with new=false returns value key set to null", + ), + CommandTestCase( + "upsert-false-nonexistent-returns-null", + docs=[], + command={ + "query": {"_id": 999}, + "update": {"$set": {"x": 10}}, + "upsert": False, + }, + expected={ + "lastErrorObject": Eq({"n": 0, "updatedExisting": False}), + "value": Eq(None), + "ok": Eq(1.0), + }, + msg="upsert=false on non-existent document returns null and inserts nothing", + ), +] + +SET_ON_INSERT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "setOnInsert-sets-id-on-upsert", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$setOnInsert": {"x": 10}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="upsert with $setOnInsert inserts new document using _id from query", + ), + CommandTestCase( + "setOnInsert-noop-when-match-exists", + docs=[{"_id": 1, "x": 10}], + command={ + "query": {"_id": 1}, + "update": {"$setOnInsert": {"x": 99}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="$setOnInsert is a no-op when matching doc already exists", + ), + CommandTestCase( + "setOnInsert-document-id-match-succeeds", + docs=[], + command={ + "query": {"_id": {"a": 1, "b": 2}}, + "update": {"$setOnInsert": {"_id": {"a": 1, "b": 2}, "x": 10}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": {"a": 1, "b": 2}, "x": 10})}, + msg="upsert succeeds when selector and $setOnInsert specify identical document ids", + ), + CommandTestCase( + "setOnInsert-dotted-id-match-succeeds", + docs=[], + command={ + "query": {"_id.a": 1}, + "update": {"$setOnInsert": {"x": 10}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": {"a": 1}, "x": 10})}, + msg="upsert with dotted _id query constructs _id from dotted notation", + ), + CommandTestCase( + "setOnInsert-selector-has-id-setOnInsert-omits", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$setOnInsert": {"x": 10}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 10})}, + msg="upsert succeeds when selector specifies _id and $setOnInsert omits it", + ), + CommandTestCase( + "setOnInsert-omits-id-sets-unique", + docs=[], + command={ + "query": {"x": 999}, + "update": {"$setOnInsert": {"_id": 42, "y": 1}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": 42, "x": 999, "y": 1})}, + msg="upsert succeeds when selector omits _id and $setOnInsert sets a unique _id", + ), + CommandTestCase( + "setOnInsert-range-filter-no-match-inserts", + docs=[], + command={ + "query": {"_id": {"$gt": 100}}, + "update": {"$setOnInsert": {"x": 10}}, + "upsert": True, + "new": True, + }, + expected={"value": {"_id": IsType("objectId"), "x": Eq(10)}}, + msg="upsert with range filter on _id matching no docs inserts using $setOnInsert", + ), + CommandTestCase( + "setOnInsert-range-filter-match-updates", + docs=[{"_id": 200, "x": 5}], + command={ + "query": {"_id": {"$gt": 100}}, + "update": {"$setOnInsert": {"x": 99}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": 200, "x": 5})}, + msg="upsert with range filter matching existing doc is noop for $setOnInsert", + ), + CommandTestCase( + "setOnInsert-subset-id-subfields-consistent", + docs=[], + command={ + "query": {"_id": {"a": 1, "b": 2}}, + "update": {"$setOnInsert": {"_id": {"a": 1, "b": 2}, "x": 10}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": {"a": 1, "b": 2}, "x": 10})}, + msg="upsert with $setOnInsert _id sub-fields consistent with selector succeeds", + ), + CommandTestCase( + "setOnInsert-id-subfields-different-order", + docs=[], + command={ + "query": {"_id": {"a": 1, "b": 2}}, + "update": {"$setOnInsert": {"_id": {"b": 2, "a": 1}, "x": 10}}, + "upsert": True, + }, + error_code=IMMUTABLE_FIELD_ERROR, + msg="upsert with $setOnInsert _id sub-fields in different order fails", + ), +] + +AUTO_INCREMENT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "auto-increment-pattern", + docs=[], + command={ + "query": {"_id": "counter"}, + "update": {"$inc": {"val": 1}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": "counter", "val": 1})}, + msg="upsert with $inc builds auto-incrementing counter", + ), +] + +UPSERT_PROJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "upsert-new-true-id-exclusion-projection", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 10, "y": 20}}, + "upsert": True, + "new": True, + "fields": {"_id": 0, "x": 1}, + }, + expected={"value": Eq({"x": 10})}, + msg="upsert + new:true + id-exclusion projection returns doc without _id", + ), + CommandTestCase( + "upsert-new-false-excludes-all-returns-null-value", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 10}}, + "upsert": True, + "new": False, + "fields": {"x": 1}, + }, + expected={ + "value": Eq(None), + "lastErrorObject": Eq({"n": 1, "updatedExisting": False, "upserted": 1}), + }, + msg="upsert + new:false returns null even with projection (no pre-image)", + ), + CommandTestCase( + "upsert-replacement-new-true-projection", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"_id": 1, "a": 1, "b": 2}, + "upsert": True, + "new": True, + "fields": {"a": 1}, + }, + expected={"value": Eq({"_id": 1, "a": 1})}, + msg="upsert + replacement + new:true + projection returns projected post-image", + ), + CommandTestCase( + "upsert-replacement-new-false", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"_id": 1, "a": 1, "b": 2}, + "upsert": True, + "new": False, + }, + expected={ + "value": Eq(None), + "lastErrorObject": Eq({"n": 1, "updatedExisting": False, "upserted": 1}), + }, + msg="upsert + replacement + new:false returns null (pre-image of insert is nothing)", + ), +] + +ALL_TESTS: list[CommandTestCase] = ( + EQUALITY_SEEDING_TESTS + + REPLACEMENT_TESTS + + LAST_ERROR_OBJECT_TESTS + + SET_ON_INSERT_TESTS + + AUTO_INCREMENT_TESTS + + UPSERT_PROJECTION_TESTS + + [ + CommandTestCase( + "upsert-updates-existing-doc", + docs=[{"_id": 1, "x": 10, "y": 5}], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 99}}, + "upsert": True, + "new": True, + }, + expected={ + "value": Eq({"_id": 1, "x": 99, "y": 5}), + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + }, + msg="upsert with existing match updates doc instead of inserting", + ), + CommandTestCase( + "setOnInsert-combined-with-set", + docs=[], + command={ + "query": {"_id": 1}, + "update": {"$set": {"x": 10}, "$setOnInsert": {"y": 99}}, + "upsert": True, + "new": True, + }, + expected={"value": Eq({"_id": 1, "x": 10, "y": 99})}, + msg="upsert with $set + $setOnInsert applies both on insert", + ), + ] +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_findAndModify_upsert(database_client, collection, test): + """Test findAndModify upsert behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + command = {"findAndModify": collection.name, **test.build_command(ctx)} + result = execute_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/findAndModify/test_findAndModify_with_expr.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_with_expr.py deleted file mode 100644 index 261b480f1..000000000 --- a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_with_expr.py +++ /dev/null @@ -1,46 +0,0 @@ -""" -Tests for $expr in findAndModify command contexts. -""" - -from documentdb_tests.framework.assertions import assertSuccess -from documentdb_tests.framework.executor import execute_command - -BASIC_DOCS = [ - {"_id": 1, "a": 5, "b": 3}, - {"_id": 2, "a": 1, "b": 10}, - {"_id": 3, "a": -1, "b": 0}, -] - - -def test_expr_in_find_and_modify(collection): - """Test $expr in findAndModify query.""" - collection.insert_many(BASIC_DOCS) - result = execute_command( - collection, - { - "findAndModify": collection.name, - "query": {"$expr": {"$gt": ["$a", "$b"]}}, - "update": {"$set": {"modified": True}}, - "sort": {"_id": 1}, - }, - ) - assertSuccess( - result, {"_id": 1, "a": 5, "b": 3}, raw_res=True, transform=lambda r: r.get("value") - ) - - -def test_expr_findandmodify_literal_true(collection): - """Test $expr with literal true in findAndModify — matches all, returns first by sort.""" - collection.insert_many(BASIC_DOCS) - result = execute_command( - collection, - { - "findAndModify": collection.name, - "query": {"$expr": True}, - "update": {"$set": {"touched": True}}, - "sort": {"_id": 1}, - }, - ) - assertSuccess( - result, {"_id": 1, "a": 5, "b": 3}, raw_res=True, transform=lambda r: r.get("value") - ) From d2fcb1a08c63bcdb84f297e27eb6c809292a5771 Mon Sep 17 00:00:00 2001 From: Victor Tsang Date: Fri, 19 Jun 2026 17:26:24 -0700 Subject: [PATCH 2/2] add pipeline test cases based on reviewer comment and move upsert tests Signed-off-by: Victor Tsang --- .../test_findAndModify_update_modes.py | 43 --------- .../test_findAndModify_upsert.py | 96 ++++++++++++++++++- 2 files changed, 95 insertions(+), 44 deletions(-) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_update_modes.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_update_modes.py index 56604b889..2888b40c6 100644 --- a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_update_modes.py +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_update_modes.py @@ -192,33 +192,6 @@ def test_findAndModify_update_modes(database_client, collection, test): ) -def test_findAndModify_upsert_unique_index_updates_instead_of_dup(collection): - """Test upsert with unique index: second upsert updates rather than duplicates.""" - collection.create_index("key", unique=True) - execute_command( - collection, - { - "findAndModify": collection.name, - "query": {"key": "abc"}, - "update": {"$set": {"val": 1}}, - "upsert": True, - }, - ) - result = execute_command( - collection, - { - "findAndModify": collection.name, - "query": {"key": "abc"}, - "update": {"$set": {"val": 2}}, - "upsert": True, - "new": True, - }, - ) - assertSuccessPartial( - result, {"value": {"key": "abc", "val": 2}, "lastErrorObject": {"updatedExisting": True}} - ) - - def test_findAndModify_array_filters_updates_matching_elements(collection): """Test arrayFilters restricts update to matching array elements.""" collection.insert_one({"_id": 1, "grades": [85, 92, 78, 95]}) @@ -314,19 +287,3 @@ def test_findAndModify_addToSet(collection): }, ) assertSuccessPartial(result, {"value": {"_id": 1, "arr": ["a", "b"]}}) - - -def test_findAndModify_upsert_array_filters_no_matching_array(collection): - """Test upsert + arrayFilters when inserted doc has no matching array elements.""" - collection.insert_one({"_id": 1, "grades": [50, 60, 70]}) - result = execute_command( - collection, - { - "findAndModify": collection.name, - "query": {"_id": 1}, - "update": {"$set": {"grades.$[elem]": 100}}, - "arrayFilters": [{"elem": {"$gte": 90}}], - "new": True, - }, - ) - assertSuccessPartial(result, {"value": {"_id": 1, "grades": [50, 60, 70]}}) diff --git a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_upsert.py b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_upsert.py index 1323c4faf..b6f799dd8 100644 --- a/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_upsert.py +++ b/documentdb_tests/compatibility/tests/core/query_and_write/commands/findAndModify/test_findAndModify_upsert.py @@ -9,7 +9,7 @@ CommandContext, CommandTestCase, ) -from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.assertions import assertResult, assertSuccessPartial from documentdb_tests.framework.error_codes import IMMUTABLE_FIELD_ERROR from documentdb_tests.framework.executor import execute_command from documentdb_tests.framework.parametrize import pytest_params @@ -252,6 +252,56 @@ ), ] +PIPELINE_UPSERT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "pipeline-upsert-no-match-seeds-from-equality-query", + docs=[], + command={ + "query": {"_id": 1, "base": 10}, + "update": [{"$set": {"doubled": {"$multiply": ["$base", 2]}}}], + "upsert": True, + "new": True, + }, + expected={ + "value": Eq({"_id": 1, "base": 10, "doubled": 20}), + "lastErrorObject": Eq({"n": 1, "updatedExisting": False, "upserted": 1}), + }, + msg="pipeline upsert with no match: pipeline computes against the " + "equality-query-seeded document", + ), + CommandTestCase( + "pipeline-upsert-no-match-non-equality-field-absent-during-compute", + docs=[], + command={ + "query": {"_id": 1, "base": {"$gt": 5}}, + "update": [{"$set": {"doubled": {"$multiply": [{"$ifNull": ["$base", 0]}, 2]}}}], + "upsert": True, + "new": True, + }, + expected={ + "value": Eq({"_id": 1, "doubled": 0}), + "lastErrorObject": Eq({"n": 1, "updatedExisting": False, "upserted": 1}), + }, + msg="pipeline upsert with no match: non-equality ($gt) query field is NOT " + "seeded, so the pipeline sees it as missing", + ), + CommandTestCase( + "pipeline-upsert-existing-match-computes-from-stored-doc", + docs=[{"_id": 1, "base": 7}], + command={ + "query": {"_id": 1}, + "update": [{"$set": {"doubled": {"$multiply": ["$base", 2]}}}], + "upsert": True, + "new": True, + }, + expected={ + "value": Eq({"_id": 1, "base": 7, "doubled": 14}), + "lastErrorObject": Eq({"n": 1, "updatedExisting": True}), + }, + msg="pipeline upsert with existing match: pipeline computes against the " "stored document", + ), +] + UPSERT_PROJECTION_TESTS: list[CommandTestCase] = [ CommandTestCase( "upsert-new-true-id-exclusion-projection", @@ -318,6 +368,7 @@ + LAST_ERROR_OBJECT_TESTS + SET_ON_INSERT_TESTS + AUTO_INCREMENT_TESTS + + PIPELINE_UPSERT_TESTS + UPSERT_PROJECTION_TESTS + [ CommandTestCase( @@ -365,3 +416,46 @@ def test_findAndModify_upsert(database_client, collection, test): msg=test.msg, raw_res=True, ) + + +def test_findAndModify_upsert_unique_index_updates_instead_of_dup(collection): + """Test upsert with unique index: second upsert updates rather than duplicates.""" + collection.create_index("key", unique=True) + execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"key": "abc"}, + "update": {"$set": {"val": 1}}, + "upsert": True, + }, + ) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"key": "abc"}, + "update": {"$set": {"val": 2}}, + "upsert": True, + "new": True, + }, + ) + assertSuccessPartial( + result, {"value": {"key": "abc", "val": 2}, "lastErrorObject": {"updatedExisting": True}} + ) + + +def test_findAndModify_upsert_array_filters_no_matching_array(collection): + """Test upsert + arrayFilters when inserted doc has no matching array elements.""" + collection.insert_one({"_id": 1, "grades": [50, 60, 70]}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$set": {"grades.$[elem]": 100}}, + "arrayFilters": [{"elem": {"$gte": 90}}], + "new": True, + }, + ) + assertSuccessPartial(result, {"value": {"_id": 1, "grades": [50, 60, 70]}})