diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/push/__init__.py b/documentdb_tests/compatibility/tests/core/operator/update/array/push/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_bson_type_validation.py b/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_bson_type_validation.py new file mode 100644 index 000000000..bb0b290c0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_bson_type_validation.py @@ -0,0 +1,95 @@ +"""Tests for $push BSON type validation. + +Verifies that $push rejects non-array target field types with error code 2, +and can push any BSON type as a value into an array. +""" + +import pytest +from bson import Binary + +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccess +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import BAD_VALUE_ERROR +from documentdb_tests.framework.executor import execute_command + +PUSH_PARAMS = [ + BsonTypeTestCase( + id="target_field", + msg="$push should reject non-array target field types", + valid_types=[BsonType.ARRAY], + default_error_code=BAD_VALUE_ERROR, + expected=[{"_id": 1, "arr": [1, 2, 99]}], + valid_inputs={BsonType.ARRAY: [1, 2]}, + ), + BsonTypeTestCase( + id="value_element", + msg="$push should accept any BSON type as value to push", + valid_types=list(BsonType), + default_error_code=BAD_VALUE_ERROR, + valid_inputs={BsonType.BIN_DATA: Binary(b"\x00\x01\x02", 128)}, + ), +] + + +def _setup_doc(spec, sample_value) -> dict: + """Build the setup document based on which aspect is being tested.""" + if spec.id == "target_field": + return {"_id": 1, "arr": sample_value} + return {"_id": 1, "arr": []} + + +def _build_update(spec, sample_value) -> dict: + """Build the update command based on which aspect is being tested.""" + if spec.id == "target_field": + return {"$push": {"arr": 99}} + return {"$push": {"arr": sample_value}} + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", generate_bson_rejection_test_cases(PUSH_PARAMS) +) +def test_push_bson_type_rejected(collection, bson_type, sample_value, spec): + """Test $push rejects non-array target field types with error.""" + setup_doc = _setup_doc(spec, sample_value) + update = _build_update(spec, sample_value) + collection.insert_one(setup_doc) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": update}], + }, + ) + assertFailureCode( + result, + spec.expected_code(bson_type), + msg=f"$push should reject {bson_type.value} for {spec.id}", + ) + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", generate_bson_acceptance_test_cases(PUSH_PARAMS) +) +def test_push_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test $push accepts valid BSON types for target field and value element.""" + setup_doc = _setup_doc(spec, sample_value) + update = _build_update(spec, sample_value) + collection.insert_one(setup_doc) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": update}], + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + if spec.id == "target_field": + expected = spec.expected + else: + expected = [{"_id": 1, "arr": [sample_value]}] + assertSuccess(result, expected, msg=f"$push should accept {bson_type.value} for {spec.id}") diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_commands.py b/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_commands.py new file mode 100644 index 000000000..32cb45466 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_commands.py @@ -0,0 +1,139 @@ +"""Tests for $push update command behavior. + +Covers: updateOne, updateMany, upsert, bulkWrite. +""" + +from documentdb_tests.framework.assertions import assertSuccess, assertSuccessPartial +from documentdb_tests.framework.executor import execute_command + + +def test_push_updateOne_response(collection): + """Test $push with updateOne reports correct response.""" + collection.insert_one({"_id": 1, "arr": [1]}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$push": {"arr": 2}}}], + }, + ) + assertSuccessPartial( + result, {"n": 1, "nModified": 1, "ok": 1.0}, msg="updateOne should report nModified=1" + ) + + +def test_push_updateOne_result(collection): + """Test $push with updateOne produces correct document.""" + collection.insert_one({"_id": 1, "arr": [1]}) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$push": {"arr": 2}}}], + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess(result, [{"_id": 1, "arr": [1, 2]}], msg="updateOne should append value") + + +def test_push_updateMany(collection): + """Test $push with updateMany updates all matched docs.""" + collection.insert_many( + [{"_id": 1, "arr": [1]}, {"_id": 2, "arr": [10]}, {"_id": 3, "arr": [100]}] + ) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {}, "u": {"$push": {"arr": 99}}, "multi": True}], + }, + ) + result = execute_command( + collection, {"find": collection.name, "filter": {}, "sort": {"_id": 1}} + ) + assertSuccess( + result, + [ + {"_id": 1, "arr": [1, 99]}, + {"_id": 2, "arr": [10, 99]}, + {"_id": 3, "arr": [100, 99]}, + ], + msg="updateMany should push to each matched document", + ) + + +def test_push_upsert_creates_doc(collection): + """Test $push with upsert:true creates document when not found.""" + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 99}, "u": {"$push": {"arr": 5}}, "upsert": True}], + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 99}}) + assertSuccess( + result, + [{"_id": 99, "arr": [5]}], + msg="Upsert should create doc with array containing the value", + ) + + +def test_push_upsert_with_each(collection): + """Test $push with $each and upsert:true creates document with all values.""" + execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 99}, "u": {"$push": {"arr": {"$each": [1, 2, 3]}}}, "upsert": True} + ], + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": {"_id": 99}}) + assertSuccess( + result, + [{"_id": 99, "arr": [1, 2, 3]}], + msg="Upsert with $each should create doc with all values", + ) + + +def test_push_bulk_write_response(collection): + """Test $push in bulkWrite reports correct response.""" + collection.insert_many([{"_id": 1, "arr": [1]}, {"_id": 2, "arr": [10]}]) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 1}, "u": {"$push": {"arr": 2}}}, + {"q": {"_id": 2}, "u": {"$push": {"arr": 20}}}, + ], + }, + ) + assertSuccessPartial( + result, {"n": 2, "nModified": 2, "ok": 1.0}, msg="Bulk update should report nModified=2" + ) + + +def test_push_bulk_write_result(collection): + """Test $push in bulkWrite produces correct documents.""" + collection.insert_many([{"_id": 1, "arr": [1]}, {"_id": 2, "arr": [10]}]) + execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 1}, "u": {"$push": {"arr": 2}}}, + {"q": {"_id": 2}, "u": {"$push": {"arr": 20}}}, + ], + }, + ) + result = execute_command( + collection, {"find": collection.name, "filter": {}, "sort": {"_id": 1}} + ) + assertSuccess( + result, + [{"_id": 1, "arr": [1, 2]}, {"_id": 2, "arr": [10, 20]}], + msg="Bulk update should push to each doc", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_core.py b/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_core.py new file mode 100644 index 000000000..667fd1a40 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_core.py @@ -0,0 +1,204 @@ +"""Tests for $push core behavior. + +Covers: basic append, missing field creation, array as value, empty operand, +dot notation/nested fields, multiple fields, large arrays, $sort without $each literal. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.update.utils import UpdateTestCase +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +CORE_BEHAVIOR_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "append_to_existing_array", + setup_docs=[{"_id": 1, "arr": [1, 2, 3]}], + query={"_id": 1}, + update={"$push": {"arr": 4}}, + expected={"_id": 1, "arr": [1, 2, 3, 4]}, + msg="$push should append value to end of existing array", + ), + UpdateTestCase( + "append_to_empty_array", + setup_docs=[{"_id": 1, "arr": []}], + query={"_id": 1}, + update={"$push": {"arr": "first"}}, + expected={"_id": 1, "arr": ["first"]}, + msg="$push on empty array should add as first element", + ), + UpdateTestCase( + "missing_field_creates_array", + setup_docs=[{"_id": 1, "x": 1}], + query={"_id": 1}, + update={"$push": {"arr": 42}}, + expected={"_id": 1, "x": 1, "arr": [42]}, + msg="$push on missing field should create array and preserve existing fields", + ), + UpdateTestCase( + "array_value_appended_as_single_element", + setup_docs=[{"_id": 1, "arr": [1, 2]}], + query={"_id": 1}, + update={"$push": {"arr": [3, 4]}}, + expected={"_id": 1, "arr": [1, 2, [3, 4]]}, + msg="$push with array value should append entire array as single element", + ), + UpdateTestCase( + "nested_array_value", + setup_docs=[{"_id": 1, "arr": []}], + query={"_id": 1}, + update={"$push": {"arr": [[1, 2], [3, 4]]}}, + expected={"_id": 1, "arr": [[[1, 2], [3, 4]]]}, + msg="$push with nested array appends the whole nested array as one element", + ), + UpdateTestCase( + "object_value", + setup_docs=[{"_id": 1, "arr": []}], + query={"_id": 1}, + update={"$push": {"arr": {"name": "test", "val": 99}}}, + expected={"_id": 1, "arr": [{"name": "test", "val": 99}]}, + msg="$push with object value should append object to array", + ), + UpdateTestCase( + "duplicate_value_allowed", + setup_docs=[{"_id": 1, "arr": [1, 2, 3]}], + query={"_id": 1}, + update={"$push": {"arr": 2}}, + expected={"_id": 1, "arr": [1, 2, 3, 2]}, + msg="$push should allow duplicate values", + ), + UpdateTestCase( + "empty_operand_noop", + setup_docs=[{"_id": 1, "arr": [1, 2, 3]}], + query={"_id": 1}, + update={"$push": {}}, + expected={"_id": 1, "arr": [1, 2, 3]}, + msg="$push with empty operand {} is a no-op", + ), +] + + +NESTED_FIELD_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "dot_notation_nested_array", + setup_docs=[{"_id": 1, "a": {"b": [1, 2]}}], + query={"_id": 1}, + update={"$push": {"a.b": 3}}, + expected={"_id": 1, "a": {"b": [1, 2, 3]}}, + msg="$push on nested array using dot notation", + ), + UpdateTestCase( + "dot_notation_deeply_nested", + setup_docs=[{"_id": 1, "a": {"b": {"c": {"d": [10]}}}}], + query={"_id": 1}, + update={"$push": {"a.b.c.d": 20}}, + expected={"_id": 1, "a": {"b": {"c": {"d": [10, 20]}}}}, + msg="$push on deeply nested array using dot notation", + ), + UpdateTestCase( + "dot_notation_creates_nested_path", + setup_docs=[{"_id": 1}], + query={"_id": 1}, + update={"$push": {"a.b": "val"}}, + expected={"_id": 1, "a": {"b": ["val"]}}, + msg="$push on missing nested field creates structure", + ), + UpdateTestCase( + "array_within_embedded_doc", + setup_docs=[{"_id": 1, "obj": {"items": ["x"]}}], + query={"_id": 1}, + update={"$push": {"obj.items": "y"}}, + expected={"_id": 1, "obj": {"items": ["x", "y"]}}, + msg="$push on array within embedded document", + ), + UpdateTestCase( + "numeric_index_path", + setup_docs=[{"_id": 1, "a": [{"b": [1, 2]}, {"b": [3]}]}], + query={"_id": 1}, + update={"$push": {"a.0.b": 99}}, + expected={"_id": 1, "a": [{"b": [1, 2, 99]}, {"b": [3]}]}, + msg="$push on a.0.b where a is an array uses numeric index", + ), +] + + +MULTIPLE_FIELD_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "push_multiple_fields", + setup_docs=[{"_id": 1, "a": [1], "b": [10]}], + query={"_id": 1}, + update={"$push": {"a": 2, "b": 20}}, + expected={"_id": 1, "a": [1, 2], "b": [10, 20]}, + msg="$push on multiple array fields in single operation", + ), + UpdateTestCase( + "push_multiple_fields_independent", + setup_docs=[{"_id": 1, "x": ["a"], "y": [1], "z": [True]}], + query={"_id": 1}, + update={"$push": {"x": "b", "y": 2, "z": False}}, + expected={"_id": 1, "x": ["a", "b"], "y": [1, 2], "z": [True, False]}, + msg="$push on multiple fields should process each independently", + ), + UpdateTestCase( + "push_multiple_fields_with_modifiers", + setup_docs=[{"_id": 1, "a": [3, 1, 2], "b": [10, 20, 30]}], + query={"_id": 1}, + update={ + "$push": { + "a": {"$each": [4], "$sort": 1}, + "b": {"$each": [0], "$position": 0}, + } + }, + expected={"_id": 1, "a": [1, 2, 3, 4], "b": [0, 10, 20, 30]}, + msg="$push multiple fields each with their own modifiers", + ), +] + + +LARGE_ARRAY_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "push_to_large_array", + setup_docs=[{"_id": 1, "arr": list(range(1000))}], + query={"_id": 1}, + update={"$push": {"arr": 1000}}, + expected={"_id": 1, "arr": list(range(1001))}, + msg="$push should append to large array correctly", + ), +] + + +MODIFIER_WITHOUT_EACH_TESTS: list[UpdateTestCase] = [ + UpdateTestCase( + "sort_without_each_pushes_literal", + setup_docs=[{"_id": 1, "arr": [3, 1, 2]}], + query={"_id": 1}, + update={"$push": {"arr": {"$sort": 1}}}, + expected={"_id": 1, "arr": [3, 1, 2, {"$sort": 1}]}, + msg="$sort without $each pushes the object as literal value", + ), +] + + +ALL_TESTS = ( + CORE_BEHAVIOR_TESTS + + NESTED_FIELD_TESTS + + MULTIPLE_FIELD_TESTS + + LARGE_ARRAY_TESTS + + MODIFIER_WITHOUT_EACH_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_push_core(collection, test: UpdateTestCase): + """Test $push core behavior produces expected document.""" + collection.insert_many(test.setup_docs) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": test.query, "u": test.update}], + }, + ) + result = execute_command(collection, {"find": collection.name, "filter": test.query}) + assertSuccess(result, [test.expected], msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_errors.py b/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_errors.py new file mode 100644 index 000000000..c7dc2667e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/push/test_push_errors.py @@ -0,0 +1,23 @@ +"""Tests for $push error handling. + +Covers: path traversal errors specific to $push. +""" + +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import PATH_NOT_VIABLE_ERROR +from documentdb_tests.framework.executor import execute_command + + +def test_push_scalar_intermediate_path_error(collection): + """Test $push through scalar intermediate field should error.""" + collection.insert_one({"_id": 1, "a": 5}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$push": {"a.b": 1}}}], + }, + ) + assertFailureCode( + result, PATH_NOT_VIABLE_ERROR, msg="$push through scalar intermediate field should error" + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/update/test_update_modifier_integration.py b/documentdb_tests/compatibility/tests/core/operator/update/test_update_modifier_integration.py index 86b6bb7a7..6385ca194 100644 --- a/documentdb_tests/compatibility/tests/core/operator/update/test_update_modifier_integration.py +++ b/documentdb_tests/compatibility/tests/core/operator/update/test_update_modifier_integration.py @@ -321,6 +321,14 @@ def test_position_update_modifier_integration(collection, test_case): expected=[{"_id": 1, "arr": {"$each": [1]}}], msg="$set should treat $each as a literal document value", ), + UpdateTestCase( + id="sort_slice_removes_new_elements", + setup_docs=[{"_id": 1, "arr": [1, 2, 3]}], + query={"_id": 1}, + update={"$push": {"arr": {"$each": [10, 20], "$sort": 1, "$slice": 3}}}, + expected=[{"_id": 1, "arr": [1, 2, 3]}], + msg="$sort + $slice where slice removes newly added elements", + ), ] diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index cd40af628..1b79a1a4a 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -13,6 +13,7 @@ ILLEGAL_OPERATION_ERROR = 20 NAMESPACE_NOT_FOUND_ERROR = 26 INDEX_NOT_FOUND_ERROR = 27 +PATH_NOT_VIABLE_ERROR = 28 CONFLICTING_UPDATE_OPERATORS_ERROR = 40 CURSOR_NOT_FOUND_ERROR = 43 NAMESPACE_EXISTS_ERROR = 48