From 75687d8034546d563452193bcf09eb9d82569889 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" Date: Mon, 25 May 2026 15:39:03 -0700 Subject: [PATCH 01/12] initial generated tests Signed-off-by: Alina (Xi) Li --- .../accumulators/mergeObjects/__init__.py | 0 .../mergeObjects/test_mergeObjects_core.py | 332 ++++++++++++++++++ .../test_mergeObjects_edge_cases.py | 188 ++++++++++ .../mergeObjects/test_mergeObjects_errors.py | 122 +++++++ .../test_mergeObjects_non_object_types.py | 225 ++++++++++++ .../test_mergeObjects_null_missing.py | 214 +++++++++++ 6 files changed, 1081 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_core.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_edge_cases.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_non_object_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_null_missing.py diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/__init__.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_core.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_core.py new file mode 100644 index 000000000..0155241ca --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_core.py @@ -0,0 +1,332 @@ +"""Tests for $mergeObjects accumulator: core merge behavior and BSON type preservation.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.accumulators.utils.accumulator_test_case import ( # noqa: E501 + AccumulatorTestCase, +) +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Disjoint Keys]: documents with non-overlapping keys produce a +# merged result containing all keys. +MERGE_OBJECTS_DISJOINT_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "disjoint_two_docs", + docs=[{"v": {"a": 1}}, {"v": {"b": 2}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": 1, "b": 2}}], + msg="$mergeObjects should merge two documents with disjoint keys", + ), + AccumulatorTestCase( + "disjoint_three_docs", + docs=[{"v": {"a": 1}}, {"v": {"b": 2}}, {"v": {"c": 3}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": 1, "b": 2, "c": 3}}], + msg="$mergeObjects should merge three documents with disjoint keys", + ), + AccumulatorTestCase( + "disjoint_multi_field_docs", + docs=[{"v": {"a": 1, "b": 2}}, {"v": {"c": 3, "d": 4}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": 1, "b": 2, "c": 3, "d": 4}}], + msg="$mergeObjects should merge multi-field documents with disjoint keys", + ), +] + +# Property [Overlapping Keys - Last Wins]: when documents share keys, the +# value from the last document in insertion order wins. +MERGE_OBJECTS_OVERLAP_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "overlap_simple", + docs=[{"v": {"a": 1}}, {"v": {"a": 2}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": 2}}], + msg="$mergeObjects should use last value when key overlaps", + ), + AccumulatorTestCase( + "overlap_triple", + docs=[{"v": {"a": 1}}, {"v": {"a": 2}}, {"v": {"a": 3}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": 3}}], + msg="$mergeObjects should use last value from three documents with same key", + ), + AccumulatorTestCase( + "overlap_partial", + docs=[{"v": {"a": 1, "b": 2}}, {"v": {"b": 3, "c": 4}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": 1, "b": 3, "c": 4}}], + msg="$mergeObjects should keep non-overlapping keys and overwrite overlapping ones", + ), + AccumulatorTestCase( + "overlap_type_change", + docs=[{"v": {"a": 1}}, {"v": {"a": "hello"}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": "hello"}}], + msg="$mergeObjects should allow type change on overwrite", + ), + AccumulatorTestCase( + "overlap_null_overwrites_value", + docs=[{"v": {"a": 1}}, {"v": {"a": None}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": None}}], + msg="$mergeObjects should allow null to overwrite an existing value", + ), + AccumulatorTestCase( + "overlap_value_overwrites_null", + docs=[{"v": {"a": None}}, {"v": {"a": 1}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": 1}}], + msg="$mergeObjects should allow a value to overwrite null", + ), +] + +# Property [Shallow Merge]: $mergeObjects performs a shallow merge; nested +# documents are replaced entirely, not recursively merged. +MERGE_OBJECTS_SHALLOW_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "shallow_nested_replaced", + docs=[{"v": {"a": {"x": 1, "y": 2}}}, {"v": {"a": {"y": 3, "z": 4}}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": {"y": 3, "z": 4}}}], + msg="$mergeObjects should replace nested document entirely, not deep merge", + ), + AccumulatorTestCase( + "shallow_array_replaced", + docs=[{"v": {"a": [1, 2, 3]}}, {"v": {"a": [4, 5]}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": [4, 5]}}], + msg="$mergeObjects should replace array entirely, not concatenate", + ), + AccumulatorTestCase( + "shallow_nested_to_scalar", + docs=[{"v": {"a": {"b": {"c": 1}}}}, {"v": {"a": 42}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": 42}}], + msg="$mergeObjects should replace deeply nested document with scalar", + ), + AccumulatorTestCase( + "shallow_scalar_to_nested", + docs=[{"v": {"a": 42}}, {"v": {"a": {"b": {"c": 1}}}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": {"b": {"c": 1}}}}], + msg="$mergeObjects should replace scalar with deeply nested document", + ), +] + +# Property [Empty Documents]: empty documents contribute no fields and do not +# affect the merged result. +MERGE_OBJECTS_EMPTY_DOC_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "empty_all", + docs=[{"v": {}}, {"v": {}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {}}], + msg="$mergeObjects should return empty document when all values are empty documents", + ), + AccumulatorTestCase( + "empty_with_nonempty", + docs=[{"v": {}}, {"v": {"a": 1}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": 1}}], + msg="$mergeObjects should ignore empty documents and merge non-empty ones", + ), + AccumulatorTestCase( + "empty_interspersed", + docs=[{"v": {"a": 1}}, {"v": {}}, {"v": {"b": 2}}, {"v": {}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": 1, "b": 2}}], + msg="$mergeObjects should ignore interspersed empty documents", + ), +] + +# Property [BSON Type Preservation]: $mergeObjects preserves the BSON type of +# field values from the merged documents. +MERGE_OBJECTS_BSON_TYPE_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "bson_int32_int64", + docs=[{"v": {"a": 1}}, {"v": {"b": Int64(2)}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": 1, "b": Int64(2)}}], + msg="$mergeObjects should preserve int32 and Int64 types", + ), + AccumulatorTestCase( + "bson_double_decimal128", + docs=[{"v": {"a": 3.14}}, {"v": {"b": Decimal128("2.718")}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": 3.14, "b": Decimal128("2.718")}}], + msg="$mergeObjects should preserve double and Decimal128 types", + ), + AccumulatorTestCase( + "bson_string_bool", + docs=[{"v": {"a": "hello"}}, {"v": {"b": True}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": "hello", "b": True}}], + msg="$mergeObjects should preserve string and bool types", + ), + AccumulatorTestCase( + "bson_date_objectid", + docs=[ + {"v": {"a": datetime(2024, 1, 1, tzinfo=timezone.utc)}}, + {"v": {"b": ObjectId("000000000000000000000000")}}, + ], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[ + { + "_id": None, + "result": { + "a": datetime(2024, 1, 1, tzinfo=timezone.utc), + "b": ObjectId("000000000000000000000000"), + }, + } + ], + msg="$mergeObjects should preserve datetime and ObjectId types", + ), + AccumulatorTestCase( + "bson_binary_regex", + docs=[{"v": {"a": Binary(b"\x01\x02")}}, {"v": {"b": Regex("abc", "i")}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": b"\x01\x02", "b": Regex("abc", 2)}}], + msg="$mergeObjects should preserve Binary and Regex types", + ), + AccumulatorTestCase( + "bson_timestamp_code", + docs=[{"v": {"a": Timestamp(1, 1)}}, {"v": {"b": Code("function(){}")}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": Timestamp(1, 1), "b": Code("function(){}")}}], + msg="$mergeObjects should preserve Timestamp and Code types", + ), + AccumulatorTestCase( + "bson_minkey_maxkey", + docs=[{"v": {"a": MinKey()}}, {"v": {"b": MaxKey()}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": MinKey(), "b": MaxKey()}}], + msg="$mergeObjects should preserve MinKey and MaxKey types", + ), + AccumulatorTestCase( + "bson_array_nested", + docs=[{"v": {"a": [1, 2, 3]}}, {"v": {"b": {"nested": {"deep": True}}}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": [1, 2, 3], "b": {"nested": {"deep": True}}}}], + msg="$mergeObjects should preserve array and nested document types", + ), +] + +# Property [Grouped Merge]: $mergeObjects correctly merges documents per group +# when grouping by a key. +MERGE_OBJECTS_GROUPED_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "grouped_by_category", + docs=[ + {"cat": "A", "v": {"x": 1}}, + {"cat": "A", "v": {"y": 2}}, + {"cat": "B", "v": {"x": 10}}, + {"cat": "B", "v": {"y": 20}}, + ], + pipeline=[ + {"$sort": {"cat": 1}}, + {"$group": {"_id": "$cat", "result": {"$mergeObjects": "$v"}}}, + {"$sort": {"_id": 1}}, + ], + expected=[ + {"_id": "A", "result": {"x": 1, "y": 2}}, + {"_id": "B", "result": {"x": 10, "y": 20}}, + ], + msg="$mergeObjects should merge documents independently per group", + ), + AccumulatorTestCase( + "grouped_with_overlap", + docs=[ + {"cat": "A", "v": {"x": 1}}, + {"cat": "A", "v": {"x": 2, "y": 3}}, + ], + pipeline=[ + {"$group": {"_id": "$cat", "result": {"$mergeObjects": "$v"}}}, + ], + expected=[{"_id": "A", "result": {"x": 2, "y": 3}}], + msg="$mergeObjects should apply last-wins within a group", + ), +] + +# Property [Expression Arguments]: $mergeObjects accepts expressions that +# resolve to documents. +MERGE_OBJECTS_EXPRESSION_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "expr_args_literal", + docs=[{"x": 1}, {"x": 2}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": {"$literal": {"a": 1}}}}}], + expected=[{"_id": None, "result": {"a": 1}}], + msg="$mergeObjects should accept $literal expression that resolves to a document", + ), + AccumulatorTestCase( + "expr_args_cond", + docs=[{"v": {"a": 1}, "flag": True}, {"v": {"b": 2}, "flag": True}], + pipeline=[ + { + "$group": { + "_id": None, + "result": {"$mergeObjects": {"$cond": ["$flag", "$v", {"z": 0}]}}, + } + } + ], + expected=[{"_id": None, "result": {"a": 1, "b": 2}}], + msg="$mergeObjects should accept $cond expression that resolves to a document", + ), + AccumulatorTestCase( + "expr_args_nested_field_path", + docs=[{"data": {"inner": {"a": 1}}}, {"data": {"inner": {"b": 2}}}], + pipeline=[ + {"$group": {"_id": None, "result": {"$mergeObjects": "$data.inner"}}}, + ], + expected=[{"_id": None, "result": {"a": 1, "b": 2}}], + msg="$mergeObjects should accept nested field path expression", + ), +] + +# Property [Constant Object Expression]: a constant object expression applies +# the same document to every group member, with last document winning. +MERGE_OBJECTS_CONSTANT_OBJECT_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "constant_object", + docs=[{"x": 1}, {"x": 2}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": {"a": 1, "b": 2}}}}], + expected=[{"_id": None, "result": {"a": 1, "b": 2}}], + msg="$mergeObjects should accept constant object and return it", + ), + AccumulatorTestCase( + "constant_empty_object", + docs=[{"x": 1}, {"x": 2}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": {}}}}], + expected=[{"_id": None, "result": {}}], + msg="$mergeObjects should accept constant empty object and return empty document", + ), +] + +MERGE_OBJECTS_CORE_TESTS = ( + MERGE_OBJECTS_DISJOINT_TESTS + + MERGE_OBJECTS_OVERLAP_TESTS + + MERGE_OBJECTS_SHALLOW_TESTS + + MERGE_OBJECTS_EMPTY_DOC_TESTS + + MERGE_OBJECTS_BSON_TYPE_TESTS + + MERGE_OBJECTS_GROUPED_TESTS + + MERGE_OBJECTS_EXPRESSION_TESTS + + MERGE_OBJECTS_CONSTANT_OBJECT_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(MERGE_OBJECTS_CORE_TESTS)) +def test_mergeObjects_core(collection, test_case: AccumulatorTestCase): + """Test $mergeObjects core merge behavior.""" + if test_case.docs: + collection.insert_many(test_case.docs) + result = execute_command( + collection, + {"aggregate": collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertSuccess(result, test_case.expected, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_edge_cases.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_edge_cases.py new file mode 100644 index 000000000..5812fa674 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_edge_cases.py @@ -0,0 +1,188 @@ +"""Tests for $mergeObjects accumulator: edge cases and special field names.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.accumulators.utils.accumulator_test_case import ( # noqa: E501 + AccumulatorTestCase, +) +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Single Document]: a single-document group returns the document +# as-is without modification. +MERGE_OBJECTS_SINGLE_DOC_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "single_doc_passthrough", + docs=[{"v": {"a": 1, "b": 2}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": 1, "b": 2}}], + msg="$mergeObjects should return the document unchanged for a single-document group", + ), + AccumulatorTestCase( + "single_doc_empty", + docs=[{"v": {}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {}}], + msg="$mergeObjects should return empty document for a single empty document", + ), + AccumulatorTestCase( + "single_doc_nested", + docs=[{"v": {"a": {"b": {"c": 1}}}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": {"b": {"c": 1}}}}], + msg="$mergeObjects should preserve nested structure in single-document group", + ), +] + +# Property [Many Documents]: $mergeObjects correctly merges many documents in +# a group. +MERGE_OBJECTS_MANY_DOCS_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "many_docs_disjoint", + docs=[{"v": {f"k{i}": i}} for i in range(20)], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {f"k{i}": i for i in range(20)}}], + msg="$mergeObjects should correctly merge 20 documents with disjoint keys", + ), + AccumulatorTestCase( + "many_docs_same_key", + docs=[{"v": {"a": i}} for i in range(10)], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": 9}}], + msg="$mergeObjects should use last value when many documents share the same key", + ), +] + +# Property [Large Documents]: $mergeObjects handles documents with many fields. +MERGE_OBJECTS_LARGE_DOC_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "large_doc_many_fields", + docs=[{"v": {f"field_{i}": i for i in range(50)}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {f"field_{i}": i for i in range(50)}}], + msg="$mergeObjects should handle documents with 50 fields", + ), +] + +# Property [Special Field Names]: $mergeObjects correctly handles special +# field names including unicode, dollar-prefixed, dotted, empty string, and +# numeric string keys. +MERGE_OBJECTS_SPECIAL_FIELD_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "special_unicode_keys", + docs=[{"v": {"\u65e5\u672c\u8a9e": 1}}, {"v": {"\u4e2d\u6587": 2}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"\u65e5\u672c\u8a9e": 1, "\u4e2d\u6587": 2}}], + msg="$mergeObjects should preserve Unicode field names", + ), + AccumulatorTestCase( + "special_dollar_prefix", + docs=[{"v": {"$a": 1}}, {"v": {"$b": 2}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"$a": 1, "$b": 2}}], + msg="$mergeObjects should preserve dollar-prefixed field names", + ), + AccumulatorTestCase( + "special_dotted_keys", + docs=[{"v": {"a.b": 1}}, {"v": {"c.d": 2}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a.b": 1, "c.d": 2}}], + msg="$mergeObjects should preserve dotted field names as literal keys", + ), + AccumulatorTestCase( + "special_empty_string_key", + docs=[{"v": {"": 1}}, {"v": {"a": 2}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"": 1, "a": 2}}], + msg="$mergeObjects should preserve empty string field names", + ), + AccumulatorTestCase( + "special_numeric_string_keys", + docs=[{"v": {"0": "zero"}}, {"v": {"1": "one"}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"0": "zero", "1": "one"}}], + msg="$mergeObjects should preserve numeric string field names", + ), + AccumulatorTestCase( + "special_long_field_name", + docs=[{"v": {"a" * 200: 1}}, {"v": {"b": 2}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a" * 200: 1, "b": 2}}], + msg="$mergeObjects should preserve very long field names", + ), + AccumulatorTestCase( + "special_dollar_overlap", + docs=[{"v": {"$a": 1}}, {"v": {"$a": 99}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"$a": 99}}], + msg="$mergeObjects should apply last-wins to dollar-prefixed overlapping keys", + ), +] + +# Property [_id Field Handling]: $mergeObjects treats _id as a normal field +# in the merged result. +MERGE_OBJECTS_ID_FIELD_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "id_field_merged", + docs=[{"v": {"_id": 100, "a": 1}}, {"v": {"b": 2}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"_id": 100, "a": 1, "b": 2}}], + msg="$mergeObjects should include _id from merged documents as a normal field", + ), + AccumulatorTestCase( + "id_field_overwritten", + docs=[{"v": {"_id": 1, "a": 1}}, {"v": {"_id": 2, "b": 2}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"_id": 2, "a": 1, "b": 2}}], + msg="$mergeObjects should overwrite _id with last value, like any other field", + ), +] + +# Property [Deeply Nested Structure]: $mergeObjects preserves deeply nested +# structures in the merged result. +MERGE_OBJECTS_DEEP_NESTING_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "deep_nesting_preserved", + docs=[ + {"v": {"a": {"b": {"c": {"d": {"e": 1}}}}}}, + {"v": {"f": 2}}, + ], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": {"b": {"c": {"d": {"e": 1}}}}, "f": 2}}], + msg="$mergeObjects should preserve deeply nested document structure", + ), + AccumulatorTestCase( + "deep_nesting_overwrite", + docs=[ + {"v": {"a": {"b": {"c": 1}}}}, + {"v": {"a": {"x": 2}}}, + ], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": {"x": 2}}}], + msg="$mergeObjects should replace entire nested structure on overwrite", + ), +] + +MERGE_OBJECTS_EDGE_TESTS = ( + MERGE_OBJECTS_SINGLE_DOC_TESTS + + MERGE_OBJECTS_MANY_DOCS_TESTS + + MERGE_OBJECTS_LARGE_DOC_TESTS + + MERGE_OBJECTS_SPECIAL_FIELD_TESTS + + MERGE_OBJECTS_ID_FIELD_TESTS + + MERGE_OBJECTS_DEEP_NESTING_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(MERGE_OBJECTS_EDGE_TESTS)) +def test_mergeObjects_edge_cases(collection, test_case: AccumulatorTestCase): + """Test $mergeObjects edge cases.""" + if test_case.docs: + collection.insert_many(test_case.docs) + result = execute_command( + collection, + {"aggregate": collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertSuccess(result, test_case.expected, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_errors.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_errors.py new file mode 100644 index 000000000..b7130607b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_errors.py @@ -0,0 +1,122 @@ +"""Tests for $mergeObjects accumulator: arity errors, syntax validation, and error propagation.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.accumulators.utils.accumulator_test_case import ( # noqa: E501 + AccumulatorTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + CONVERSION_FAILURE_ERROR, + EXPRESSION_OBJECT_MULTIPLE_FIELDS_ERROR, + GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, + INVALID_DOLLAR_FIELD_PATH, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Syntax Validation]: "$" by itself is not a valid FieldPath and +# produces an error. +MERGE_OBJECTS_SYNTAX_VALIDATION_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "syntax_bare_dollar", + docs=[{"v": {"a": 1}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$"}}}], + error_code=INVALID_DOLLAR_FIELD_PATH, + msg="$mergeObjects should reject '$' as an invalid FieldPath", + ), +] + +# Property [Arity Rejection]: $mergeObjects in accumulator context is a unary +# operator and must reject array syntax. +MERGE_OBJECTS_ARITY_ERROR_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "arity_empty_array", + docs=[{"v": {"a": 1}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": []}}}], + error_code=GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, + msg="$mergeObjects should reject empty array in accumulator context", + ), + AccumulatorTestCase( + "arity_single_element_array", + docs=[{"v": {"a": 1}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": [{"a": 1}]}}}], + error_code=GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, + msg="$mergeObjects should reject single-element array in accumulator context", + ), + AccumulatorTestCase( + "arity_single_field_ref_array", + docs=[{"v": {"a": 1}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": ["$v"]}}}], + error_code=GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, + msg="$mergeObjects should reject single field ref in array in accumulator context", + ), + AccumulatorTestCase( + "arity_multi_element_array", + docs=[{"v": {"a": 1}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": [{"a": 1}, {"b": 2}]}}}], + error_code=GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, + msg="$mergeObjects should reject multi-element array in accumulator context", + ), + AccumulatorTestCase( + "arity_multi_key_expression_object", + docs=[{"v": {"a": 1}}], + pipeline=[ + { + "$group": { + "_id": None, + "result": {"$mergeObjects": {"$literal": {"a": 1}, "$toUpper": "hello"}}, + } + } + ], + error_code=EXPRESSION_OBJECT_MULTIPLE_FIELDS_ERROR, + msg="$mergeObjects should reject multi-key expression object", + ), +] + +# Property [Expression Error Propagation]: when the accumulator expression +# errors for any document in the group, the error propagates to the caller. +MERGE_OBJECTS_EXPRESSION_ERROR_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "expr_error_to_object_id_invalid", + docs=[{"v": "not_valid_oid"}], + pipeline=[ + { + "$group": { + "_id": None, + "result": { + "$mergeObjects": { + "$cond": [ + True, + {"converted": {"$toObjectId": "$v"}}, + {"fallback": 1}, + ] + } + }, + } + } + ], + error_code=CONVERSION_FAILURE_ERROR, + msg="$mergeObjects should propagate $toObjectId conversion error from expression", + ), +] + +MERGE_OBJECTS_ERROR_TESTS = ( + MERGE_OBJECTS_SYNTAX_VALIDATION_TESTS + + MERGE_OBJECTS_ARITY_ERROR_TESTS + + MERGE_OBJECTS_EXPRESSION_ERROR_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(MERGE_OBJECTS_ERROR_TESTS)) +def test_mergeObjects_errors(collection, test_case: AccumulatorTestCase): + """Test $mergeObjects error cases.""" + if test_case.docs: + collection.insert_many(test_case.docs) + result = execute_command( + collection, + {"aggregate": collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertFailureCode(result, test_case.error_code, msg=test_case.msg) # type: ignore[arg-type] diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_non_object_types.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_non_object_types.py new file mode 100644 index 000000000..6a3659b5c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_non_object_types.py @@ -0,0 +1,225 @@ +"""Tests for $mergeObjects accumulator: non-object type error behavior.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.accumulators.utils.accumulator_test_case import ( # noqa: E501 + AccumulatorTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import MERGE_OBJECTS_NON_OBJECT_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Non-Object Type Rejection]: $mergeObjects rejects non-object, +# non-null BSON types with an error, unlike numeric accumulators which ignore +# non-numeric types silently. +MERGE_OBJECTS_NON_OBJECT_TYPE_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "non_object_string", + docs=[{"v": "hello"}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + error_code=MERGE_OBJECTS_NON_OBJECT_ERROR, + msg="$mergeObjects should reject string values", + ), + AccumulatorTestCase( + "non_object_int32", + docs=[{"v": 42}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + error_code=MERGE_OBJECTS_NON_OBJECT_ERROR, + msg="$mergeObjects should reject int32 values", + ), + AccumulatorTestCase( + "non_object_int64", + docs=[{"v": Int64(42)}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + error_code=MERGE_OBJECTS_NON_OBJECT_ERROR, + msg="$mergeObjects should reject Int64 values", + ), + AccumulatorTestCase( + "non_object_double", + docs=[{"v": 3.14}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + error_code=MERGE_OBJECTS_NON_OBJECT_ERROR, + msg="$mergeObjects should reject double values", + ), + AccumulatorTestCase( + "non_object_decimal128", + docs=[{"v": Decimal128("1.5")}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + error_code=MERGE_OBJECTS_NON_OBJECT_ERROR, + msg="$mergeObjects should reject Decimal128 values", + ), + AccumulatorTestCase( + "non_object_bool_true", + docs=[{"v": True}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + error_code=MERGE_OBJECTS_NON_OBJECT_ERROR, + msg="$mergeObjects should reject boolean True", + ), + AccumulatorTestCase( + "non_object_bool_false", + docs=[{"v": False}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + error_code=MERGE_OBJECTS_NON_OBJECT_ERROR, + msg="$mergeObjects should reject boolean False", + ), + AccumulatorTestCase( + "non_object_datetime", + docs=[{"v": datetime(2024, 1, 1, tzinfo=timezone.utc)}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + error_code=MERGE_OBJECTS_NON_OBJECT_ERROR, + msg="$mergeObjects should reject datetime values", + ), + AccumulatorTestCase( + "non_object_objectid", + docs=[{"v": ObjectId("000000000000000000000000")}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + error_code=MERGE_OBJECTS_NON_OBJECT_ERROR, + msg="$mergeObjects should reject ObjectId values", + ), + AccumulatorTestCase( + "non_object_binary", + docs=[{"v": Binary(b"\x01\x02")}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + error_code=MERGE_OBJECTS_NON_OBJECT_ERROR, + msg="$mergeObjects should reject Binary values", + ), + AccumulatorTestCase( + "non_object_regex", + docs=[{"v": Regex("abc", "i")}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + error_code=MERGE_OBJECTS_NON_OBJECT_ERROR, + msg="$mergeObjects should reject Regex values", + ), + AccumulatorTestCase( + "non_object_code", + docs=[{"v": Code("function(){}")}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + error_code=MERGE_OBJECTS_NON_OBJECT_ERROR, + msg="$mergeObjects should reject Code values", + ), + AccumulatorTestCase( + "non_object_timestamp", + docs=[{"v": Timestamp(1, 1)}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + error_code=MERGE_OBJECTS_NON_OBJECT_ERROR, + msg="$mergeObjects should reject Timestamp values", + ), + AccumulatorTestCase( + "non_object_minkey", + docs=[{"v": MinKey()}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + error_code=MERGE_OBJECTS_NON_OBJECT_ERROR, + msg="$mergeObjects should reject MinKey values", + ), + AccumulatorTestCase( + "non_object_maxkey", + docs=[{"v": MaxKey()}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + error_code=MERGE_OBJECTS_NON_OBJECT_ERROR, + msg="$mergeObjects should reject MaxKey values", + ), + AccumulatorTestCase( + "non_object_array", + docs=[{"v": [1, 2, 3]}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + error_code=MERGE_OBJECTS_NON_OBJECT_ERROR, + msg="$mergeObjects should reject array values", + ), + AccumulatorTestCase( + "non_object_empty_array", + docs=[{"v": []}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + error_code=MERGE_OBJECTS_NON_OBJECT_ERROR, + msg="$mergeObjects should reject empty array values", + ), + AccumulatorTestCase( + "non_object_numeric_string", + docs=[{"v": "123"}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + error_code=MERGE_OBJECTS_NON_OBJECT_ERROR, + msg="$mergeObjects should reject numeric strings without coercion", + ), +] + +# Property [Non-Object After Valid Objects]: $mergeObjects errors when a +# non-object value appears after valid objects in the group. +MERGE_OBJECTS_NON_OBJECT_AFTER_VALID_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "non_object_after_valid_string", + docs=[{"v": {"a": 1}}, {"v": "hello"}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + error_code=MERGE_OBJECTS_NON_OBJECT_ERROR, + msg="$mergeObjects should error when string appears after valid object", + ), + AccumulatorTestCase( + "non_object_after_valid_int", + docs=[{"v": {"a": 1}}, {"v": 42}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + error_code=MERGE_OBJECTS_NON_OBJECT_ERROR, + msg="$mergeObjects should error when int appears after valid object", + ), + AccumulatorTestCase( + "non_object_after_valid_array", + docs=[{"v": {"a": 1}}, {"v": [1, 2]}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + error_code=MERGE_OBJECTS_NON_OBJECT_ERROR, + msg="$mergeObjects should error when array appears after valid object", + ), + AccumulatorTestCase( + "non_object_after_valid_bool", + docs=[{"v": {"a": 1}}, {"v": True}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + error_code=MERGE_OBJECTS_NON_OBJECT_ERROR, + msg="$mergeObjects should error when bool appears after valid object", + ), +] + +# Property [Non-Object Constant Expression]: $mergeObjects errors when a +# constant expression resolves to a non-object, non-null type. +MERGE_OBJECTS_NON_OBJECT_CONSTANT_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "constant_string", + docs=[{"x": 1}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "hello"}}}], + error_code=MERGE_OBJECTS_NON_OBJECT_ERROR, + msg="$mergeObjects should reject constant string expression", + ), + AccumulatorTestCase( + "constant_int", + docs=[{"x": 1}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": 42}}}], + error_code=MERGE_OBJECTS_NON_OBJECT_ERROR, + msg="$mergeObjects should reject constant int expression", + ), + AccumulatorTestCase( + "constant_bool", + docs=[{"x": 1}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": True}}}], + error_code=MERGE_OBJECTS_NON_OBJECT_ERROR, + msg="$mergeObjects should reject constant bool expression", + ), +] + +MERGE_OBJECTS_NON_OBJECT_TESTS = ( + MERGE_OBJECTS_NON_OBJECT_TYPE_TESTS + + MERGE_OBJECTS_NON_OBJECT_AFTER_VALID_TESTS + + MERGE_OBJECTS_NON_OBJECT_CONSTANT_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(MERGE_OBJECTS_NON_OBJECT_TESTS)) +def test_mergeObjects_non_object_types(collection, test_case: AccumulatorTestCase): + """Test $mergeObjects non-object type error behavior.""" + if test_case.docs: + collection.insert_many(test_case.docs) + result = execute_command( + collection, + {"aggregate": collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertFailureCode(result, test_case.error_code, msg=test_case.msg) # type: ignore[arg-type] diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_null_missing.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_null_missing.py new file mode 100644 index 000000000..63492c3b0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_null_missing.py @@ -0,0 +1,214 @@ +"""Tests for $mergeObjects accumulator: null/missing handling.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.accumulators.utils.accumulator_test_case import ( # noqa: E501 + AccumulatorTestCase, +) +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Null Ignored]: null values are silently ignored by $mergeObjects, +# contributing nothing to the merged result. +MERGE_OBJECTS_NULL_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "null_all", + docs=[{"v": None}, {"v": None}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {}}], + msg="$mergeObjects should return empty document when all values are null", + ), + AccumulatorTestCase( + "null_single", + docs=[{"v": None}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {}}], + msg="$mergeObjects should return empty document for a single null value", + ), + AccumulatorTestCase( + "null_with_object", + docs=[{"v": None}, {"v": {"a": 1}}, {"v": {"b": 2}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": 1, "b": 2}}], + msg="$mergeObjects should ignore null and merge remaining objects", + ), + AccumulatorTestCase( + "null_first", + docs=[{"v": None}, {"v": {"a": 1}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": 1}}], + msg="$mergeObjects should ignore null in first position", + ), + AccumulatorTestCase( + "null_last", + docs=[{"v": {"a": 1}}, {"v": None}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": 1}}], + msg="$mergeObjects should ignore null in last position", + ), + AccumulatorTestCase( + "null_middle", + docs=[{"v": {"a": 1}}, {"v": None}, {"v": {"b": 2}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": 1, "b": 2}}], + msg="$mergeObjects should ignore null in middle position", + ), + AccumulatorTestCase( + "null_multiple_interspersed", + docs=[{"v": None}, {"v": {"a": 1}}, {"v": None}, {"v": {"b": 2}}, {"v": None}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": 1, "b": 2}}], + msg="$mergeObjects should ignore multiple interspersed nulls", + ), +] + +# Property [Missing Ignored]: missing fields are silently ignored by +# $mergeObjects, contributing nothing to the merged result. +MERGE_OBJECTS_MISSING_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "missing_all", + docs=[{"x": 1}, {"x": 2}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {}}], + msg="$mergeObjects should return empty document when all documents have missing field", + ), + AccumulatorTestCase( + "missing_single", + docs=[{"x": 1}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {}}], + msg="$mergeObjects should return empty document for a single missing field", + ), + AccumulatorTestCase( + "missing_with_object", + docs=[{"x": 1}, {"v": {"a": 1}}, {"v": {"b": 2}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": 1, "b": 2}}], + msg="$mergeObjects should ignore missing and merge remaining objects", + ), + AccumulatorTestCase( + "missing_first", + docs=[{"x": 1}, {"v": {"a": 1}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": 1}}], + msg="$mergeObjects should ignore missing field in first document", + ), + AccumulatorTestCase( + "missing_last", + docs=[{"v": {"a": 1}}, {"x": 1}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": 1}}], + msg="$mergeObjects should ignore missing field in last document", + ), +] + +# Property [Null and Missing Mixed]: null and missing values are both ignored +# when mixed together, with objects being merged normally. +MERGE_OBJECTS_NULL_MISSING_MIX_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "null_and_missing_mix", + docs=[{"v": None}, {"x": 1}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {}}], + msg="$mergeObjects should return empty document when group has only null and missing", + ), + AccumulatorTestCase( + "null_and_missing_with_object", + docs=[{"v": None}, {"x": 1}, {"v": {"a": 1}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": 1}}], + msg="$mergeObjects should ignore both null and missing, merging only objects", + ), +] + +# Property [$$REMOVE Handling]: $$REMOVE is treated as missing and silently +# ignored by $mergeObjects. +MERGE_OBJECTS_REMOVE_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "remove_only", + docs=[{"v": {"a": 1}}], + pipeline=[ + { + "$group": { + "_id": None, + "result": {"$mergeObjects": {"$cond": [False, "$v", "$$REMOVE"]}}, + } + } + ], + expected=[{"_id": None, "result": {}}], + msg="$mergeObjects should treat $$REMOVE as missing and return empty document", + ), + AccumulatorTestCase( + "remove_with_object", + docs=[{"v": {"a": 1}, "flag": True}, {"v": {"b": 2}, "flag": False}], + pipeline=[ + { + "$group": { + "_id": None, + "result": {"$mergeObjects": {"$cond": ["$flag", "$v", "$$REMOVE"]}}, + } + } + ], + expected=[{"_id": None, "result": {"a": 1}}], + msg="$mergeObjects should ignore $$REMOVE and merge only non-removed objects", + ), +] + +# Property [Constant Null]: a constant null expression produces an empty +# document result. +MERGE_OBJECTS_CONSTANT_NULL_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "constant_null", + docs=[{"x": 1}, {"x": 2}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": None}}}], + expected=[{"_id": None, "result": {}}], + msg="$mergeObjects should return empty document for a constant null expression", + ), + AccumulatorTestCase( + "literal_null_expr", + docs=[{"x": 1}, {"x": 2}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": {"$literal": None}}}}], + expected=[{"_id": None, "result": {}}], + msg="$mergeObjects should return empty document when expression evaluates to null", + ), +] + +MERGE_OBJECTS_NULL_MISSING_TESTS = ( + MERGE_OBJECTS_NULL_TESTS + + MERGE_OBJECTS_MISSING_TESTS + + MERGE_OBJECTS_NULL_MISSING_MIX_TESTS + + MERGE_OBJECTS_REMOVE_TESTS + + MERGE_OBJECTS_CONSTANT_NULL_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(MERGE_OBJECTS_NULL_MISSING_TESTS)) +def test_mergeObjects_null_missing(collection, test_case: AccumulatorTestCase): + """Test $mergeObjects null/missing handling.""" + if test_case.docs: + collection.insert_many(test_case.docs) + result = execute_command( + collection, + {"aggregate": collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertSuccess(result, test_case.expected, msg=test_case.msg) + + +# Property [Empty Collection]: empty collection produces no group output +# (empty result set). +def test_mergeObjects_empty_collection(collection): + """Test $mergeObjects on empty collection returns empty result set.""" + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + "cursor": {}, + }, + ) + assertSuccess( + result, [], msg="$mergeObjects on empty collection should return empty result set" + ) From 3e83a8475d4423c6566636dbd8cbba67a0ce3a58 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" Date: Mon, 25 May 2026 15:43:37 -0700 Subject: [PATCH 02/12] apply style guide changes Signed-off-by: Alina (Xi) Li --- ...eObjects_core.py => test_accumulator_mergeObjects_core.py} | 2 +- ...e_cases.py => test_accumulator_mergeObjects_edge_cases.py} | 2 +- ...ects_errors.py => test_accumulator_mergeObjects_errors.py} | 2 +- ...s.py => test_accumulator_mergeObjects_non_object_types.py} | 2 +- ...ssing.py => test_accumulator_mergeObjects_null_missing.py} | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) rename documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/{test_mergeObjects_core.py => test_accumulator_mergeObjects_core.py} (99%) rename documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/{test_mergeObjects_edge_cases.py => test_accumulator_mergeObjects_edge_cases.py} (98%) rename documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/{test_mergeObjects_errors.py => test_accumulator_mergeObjects_errors.py} (98%) rename documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/{test_mergeObjects_non_object_types.py => test_accumulator_mergeObjects_non_object_types.py} (98%) rename documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/{test_mergeObjects_null_missing.py => test_accumulator_mergeObjects_null_missing.py} (98%) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_core.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_core.py similarity index 99% rename from documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_core.py rename to documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_core.py index 0155241ca..8227ee574 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_core.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_core.py @@ -321,7 +321,7 @@ @pytest.mark.parametrize("test_case", pytest_params(MERGE_OBJECTS_CORE_TESTS)) -def test_mergeObjects_core(collection, test_case: AccumulatorTestCase): +def test_accumulator_mergeObjects_core(collection, test_case: AccumulatorTestCase): """Test $mergeObjects core merge behavior.""" if test_case.docs: collection.insert_many(test_case.docs) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_edge_cases.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_edge_cases.py similarity index 98% rename from documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_edge_cases.py rename to documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_edge_cases.py index 5812fa674..f0da28873 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_edge_cases.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_edge_cases.py @@ -177,7 +177,7 @@ @pytest.mark.parametrize("test_case", pytest_params(MERGE_OBJECTS_EDGE_TESTS)) -def test_mergeObjects_edge_cases(collection, test_case: AccumulatorTestCase): +def test_accumulator_mergeObjects_edge_cases(collection, test_case: AccumulatorTestCase): """Test $mergeObjects edge cases.""" if test_case.docs: collection.insert_many(test_case.docs) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_errors.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_errors.py similarity index 98% rename from documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_errors.py rename to documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_errors.py index b7130607b..6bf1bfcdf 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_errors.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_errors.py @@ -111,7 +111,7 @@ @pytest.mark.parametrize("test_case", pytest_params(MERGE_OBJECTS_ERROR_TESTS)) -def test_mergeObjects_errors(collection, test_case: AccumulatorTestCase): +def test_accumulator_mergeObjects_errors(collection, test_case: AccumulatorTestCase): """Test $mergeObjects error cases.""" if test_case.docs: collection.insert_many(test_case.docs) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_non_object_types.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_non_object_types.py similarity index 98% rename from documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_non_object_types.py rename to documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_non_object_types.py index 6a3659b5c..75a569e7f 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_non_object_types.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_non_object_types.py @@ -214,7 +214,7 @@ @pytest.mark.parametrize("test_case", pytest_params(MERGE_OBJECTS_NON_OBJECT_TESTS)) -def test_mergeObjects_non_object_types(collection, test_case: AccumulatorTestCase): +def test_accumulator_mergeObjects_non_object_types(collection, test_case: AccumulatorTestCase): """Test $mergeObjects non-object type error behavior.""" if test_case.docs: collection.insert_many(test_case.docs) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_null_missing.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_null_missing.py similarity index 98% rename from documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_null_missing.py rename to documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_null_missing.py index 63492c3b0..9be072725 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_mergeObjects_null_missing.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_null_missing.py @@ -186,7 +186,7 @@ @pytest.mark.parametrize("test_case", pytest_params(MERGE_OBJECTS_NULL_MISSING_TESTS)) -def test_mergeObjects_null_missing(collection, test_case: AccumulatorTestCase): +def test_accumulator_mergeObjects_null_missing(collection, test_case: AccumulatorTestCase): """Test $mergeObjects null/missing handling.""" if test_case.docs: collection.insert_many(test_case.docs) @@ -199,7 +199,7 @@ def test_mergeObjects_null_missing(collection, test_case: AccumulatorTestCase): # Property [Empty Collection]: empty collection produces no group output # (empty result set). -def test_mergeObjects_empty_collection(collection): +def test_accumulator_mergeObjects_empty_collection(collection): """Test $mergeObjects on empty collection returns empty result set.""" result = execute_command( collection, From 90c5e5cd1b959195887ebce306ec03c0dc6d1818 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" Date: Thu, 28 May 2026 16:30:21 -0700 Subject: [PATCH 03/12] add divide tests Signed-off-by: Alina (Xi) Li --- .../test_accumulator_mergeObjects_errors.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_errors.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_errors.py index 6bf1bfcdf..8cb60c73e 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_errors.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_errors.py @@ -10,6 +10,7 @@ from documentdb_tests.framework.assertions import assertFailureCode from documentdb_tests.framework.error_codes import ( CONVERSION_FAILURE_ERROR, + DIVIDE_BY_ZERO_V2_ERROR, EXPRESSION_OBJECT_MULTIPLE_FIELDS_ERROR, GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, INVALID_DOLLAR_FIELD_PATH, @@ -101,6 +102,38 @@ error_code=CONVERSION_FAILURE_ERROR, msg="$mergeObjects should propagate $toObjectId conversion error from expression", ), + AccumulatorTestCase( + "expr_error_divide_by_zero_field_path", + docs=[{"_id": 0, "v": 0}], + pipeline=[ + {"$sort": {"_id": 1}}, + { + "$group": { + "_id": None, + "result": {"$mergeObjects": {"$divide": [1, "$v"]}}, + } + }, + ], + error_code=DIVIDE_BY_ZERO_V2_ERROR, + msg="$mergeObjects should propagate $divide by zero when divisor comes from field path", + ), + AccumulatorTestCase( + "expr_error_divide_by_zero_later_doc", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 0}], + pipeline=[ + {"$sort": {"_id": 1}}, + { + "$group": { + "_id": None, + "result": { + "$mergeObjects": {"$let": {"vars": {}, "in": {"x": {"$divide": [1, "$v"]}}}} + }, + } + }, + ], + error_code=DIVIDE_BY_ZERO_V2_ERROR, + msg="$mergeObjects should propagate error even when failing doc is not the first", + ), ] MERGE_OBJECTS_ERROR_TESTS = ( From 249568aca3beaa67494504391c670dd662ec9bb0 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" Date: Mon, 1 Jun 2026 12:30:08 -0700 Subject: [PATCH 04/12] Add expression tests and order dependence tests Signed-off-by: Alina (Xi) Li --- .../test_accumulator_mergeObjects_core.py | 91 ++++++++++++++++--- ...est_accumulator_mergeObjects_edge_cases.py | 27 ++++++ 2 files changed, 106 insertions(+), 12 deletions(-) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_core.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_core.py index 8227ee574..b42daa167 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_core.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_core.py @@ -254,18 +254,57 @@ ), ] -# Property [Expression Arguments]: $mergeObjects accepts expressions that -# resolve to documents. -MERGE_OBJECTS_EXPRESSION_TESTS: list[AccumulatorTestCase] = [ +# Property [Expression Types]: $mergeObjects accepts various expression types +# as its operand and evaluates them per document. +MERGE_OBJECTS_EXPRESSION_TYPE_TESTS: list[AccumulatorTestCase] = [ AccumulatorTestCase( - "expr_args_literal", + "expr_type_field_path", + docs=[{"v": {"a": 1}}, {"v": {"b": 2}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": 1, "b": 2}}], + msg="$mergeObjects should accept a simple field path", + ), + AccumulatorTestCase( + "expr_type_nested_field_path", + docs=[{"data": {"inner": {"a": 1}}}, {"data": {"inner": {"b": 2}}}], + pipeline=[ + {"$group": {"_id": None, "result": {"$mergeObjects": "$data.inner"}}}, + ], + expected=[{"_id": None, "result": {"a": 1, "b": 2}}], + msg="$mergeObjects should accept a nested field path", + ), + AccumulatorTestCase( + "expr_type_literal", docs=[{"x": 1}, {"x": 2}], pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": {"$literal": {"a": 1}}}}}], expected=[{"_id": None, "result": {"a": 1}}], - msg="$mergeObjects should accept $literal expression that resolves to a document", + msg="$mergeObjects should accept a $literal expression", ), AccumulatorTestCase( - "expr_args_cond", + "expr_type_sysvar_remove", + docs=[{"v": {"a": 1}}, {"v": {"b": 2}}], + pipeline=[ + {"$group": {"_id": None, "result": {"$mergeObjects": "$$REMOVE"}}}, + ], + expected=[{"_id": None, "result": {}}], + msg="$mergeObjects should accept $$REMOVE system variable", + ), + AccumulatorTestCase( + "expr_type_operator_single", + docs=[{"v": {"a": 1}}, {"v": {"b": 2}}], + pipeline=[ + { + "$group": { + "_id": None, + "result": {"$mergeObjects": {"$ifNull": ["$v", {}]}}, + } + } + ], + expected=[{"_id": None, "result": {"a": 1, "b": 2}}], + msg="$mergeObjects should accept a single-input expression operator", + ), + AccumulatorTestCase( + "expr_type_cond", docs=[{"v": {"a": 1}, "flag": True}, {"v": {"b": 2}, "flag": True}], pipeline=[ { @@ -276,16 +315,44 @@ } ], expected=[{"_id": None, "result": {"a": 1, "b": 2}}], - msg="$mergeObjects should accept $cond expression that resolves to a document", + msg="$mergeObjects should accept a $cond expression", ), AccumulatorTestCase( - "expr_args_nested_field_path", - docs=[{"data": {"inner": {"a": 1}}}, {"data": {"inner": {"b": 2}}}], + "expr_type_object_expression", + docs=[{"v": 1}, {"v": 2}], pipeline=[ - {"$group": {"_id": None, "result": {"$mergeObjects": "$data.inner"}}}, + {"$group": {"_id": None, "result": {"$mergeObjects": {"a": "$v"}}}}, + ], + expected=[{"_id": None, "result": {"a": 2}}], + msg="$mergeObjects should accept an object expression with field references", + ), + AccumulatorTestCase( + "expr_type_object_with_operator", + docs=[{"v": -5}, {"v": -10}], + pipeline=[ + { + "$group": { + "_id": None, + "result": {"$mergeObjects": {"a": {"$abs": "$v"}}}, + } + }, + ], + expected=[{"_id": None, "result": {"a": 10}}], + msg="$mergeObjects should accept an object expression containing an operator", + ), + AccumulatorTestCase( + "expr_type_let", + docs=[{"v": {"a": 1}}, {"v": {"b": 2}}], + pipeline=[ + { + "$group": { + "_id": None, + "result": {"$mergeObjects": {"$let": {"vars": {"x": "$v"}, "in": "$$x"}}}, + } + }, ], expected=[{"_id": None, "result": {"a": 1, "b": 2}}], - msg="$mergeObjects should accept nested field path expression", + msg="$mergeObjects should accept a $let expression as its operand", ), ] @@ -315,7 +382,7 @@ + MERGE_OBJECTS_EMPTY_DOC_TESTS + MERGE_OBJECTS_BSON_TYPE_TESTS + MERGE_OBJECTS_GROUPED_TESTS - + MERGE_OBJECTS_EXPRESSION_TESTS + + MERGE_OBJECTS_EXPRESSION_TYPE_TESTS + MERGE_OBJECTS_CONSTANT_OBJECT_TESTS ) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_edge_cases.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_edge_cases.py index f0da28873..a89046163 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_edge_cases.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_edge_cases.py @@ -166,6 +166,32 @@ ), ] +# Property [Order Dependence]: $mergeObjects is order-dependent; the last +# document's value wins for overlapping keys. Different sort orders produce +# different results. +MERGE_OBJECTS_ORDER_DEPENDENCE_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "order_dependent_asc", + docs=[{"_id": 1, "v": {"a": 1}}, {"_id": 2, "v": {"a": 2}}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}, + ], + expected=[{"_id": None, "result": {"a": 2}}], + msg="$mergeObjects with ascending sort should use value from last document", + ), + AccumulatorTestCase( + "order_dependent_desc", + docs=[{"_id": 1, "v": {"a": 1}}, {"_id": 2, "v": {"a": 2}}], + pipeline=[ + {"$sort": {"_id": -1}}, + {"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}, + ], + expected=[{"_id": None, "result": {"a": 1}}], + msg="$mergeObjects with descending sort should use value from last document", + ), +] + MERGE_OBJECTS_EDGE_TESTS = ( MERGE_OBJECTS_SINGLE_DOC_TESTS + MERGE_OBJECTS_MANY_DOCS_TESTS @@ -173,6 +199,7 @@ + MERGE_OBJECTS_SPECIAL_FIELD_TESTS + MERGE_OBJECTS_ID_FIELD_TESTS + MERGE_OBJECTS_DEEP_NESTING_TESTS + + MERGE_OBJECTS_ORDER_DEPENDENCE_TESTS ) From fbfb2cbf9618565c5cafd865a7532c7249372aee Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" Date: Mon, 1 Jun 2026 14:08:45 -0700 Subject: [PATCH 05/12] add missing tests Signed-off-by: Alina (Xi) Li --- .../test_accumulator_mergeObjects_core.py | 27 +++++++++++++++++++ .../test_accumulator_mergeObjects_errors.py | 14 ++++++++++ 2 files changed, 41 insertions(+) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_core.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_core.py index b42daa167..dd36f2bf5 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_core.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_core.py @@ -356,6 +356,32 @@ ), ] +# Property [Field Lookup]: $mergeObjects resolves field paths including nested +# object paths and array index paths. +MERGE_OBJECTS_FIELD_LOOKUP_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "field_lookup_array_index_path", + docs=[ + {"v": {"0": {"b": {"x": 1}}}}, + {"v": {"0": {"b": {"y": 2}}}}, + ], + pipeline=[ + {"$group": {"_id": None, "result": {"$mergeObjects": "$v.0.b"}}}, + ], + expected=[{"_id": None, "result": {"x": 1, "y": 2}}], + msg="$mergeObjects should resolve array index path on nested object", + ), + AccumulatorTestCase( + "field_lookup_nonexistent", + docs=[{"v": {"a": 1}}, {"v": {"b": 2}}], + pipeline=[ + {"$group": {"_id": None, "result": {"$mergeObjects": "$nonexistent"}}}, + ], + expected=[{"_id": None, "result": {}}], + msg="$mergeObjects should treat nonexistent field as missing", + ), +] + # Property [Constant Object Expression]: a constant object expression applies # the same document to every group member, with last document winning. MERGE_OBJECTS_CONSTANT_OBJECT_TESTS: list[AccumulatorTestCase] = [ @@ -383,6 +409,7 @@ + MERGE_OBJECTS_BSON_TYPE_TESTS + MERGE_OBJECTS_GROUPED_TESTS + MERGE_OBJECTS_EXPRESSION_TYPE_TESTS + + MERGE_OBJECTS_FIELD_LOOKUP_TESTS + MERGE_OBJECTS_CONSTANT_OBJECT_TESTS ) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_errors.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_errors.py index 8cb60c73e..832559dd7 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_errors.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_errors.py @@ -102,6 +102,20 @@ error_code=CONVERSION_FAILURE_ERROR, msg="$mergeObjects should propagate $toObjectId conversion error from expression", ), + AccumulatorTestCase( + "expr_error_divide_by_zero_literal", + docs=[{"v": 10}], + pipeline=[ + { + "$group": { + "_id": None, + "result": {"$mergeObjects": {"$divide": ["$v", 0]}}, + } + }, + ], + error_code=DIVIDE_BY_ZERO_V2_ERROR, + msg="$mergeObjects should propagate $divide by zero with literal zero divisor", + ), AccumulatorTestCase( "expr_error_divide_by_zero_field_path", docs=[{"_id": 0, "v": 0}], From cb21016578b931323ab7ad70bcfb59e5ffdd8645 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" Date: Mon, 1 Jun 2026 14:17:57 -0700 Subject: [PATCH 06/12] convert to AccumulatorTestCase Signed-off-by: Alina (Xi) Li --- ...t_accumulator_mergeObjects_null_missing.py | 29 ++++++++----------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_null_missing.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_null_missing.py index 9be072725..e40e65eeb 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_null_missing.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_null_missing.py @@ -176,12 +176,24 @@ ), ] +# Property [Empty Collection]: empty collection produces no group output +# (empty result set). +MERGE_OBJECTS_EMPTY_COLLECTION_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "empty_collection", + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[], + msg="$mergeObjects on empty collection should return empty result set", + ), +] + MERGE_OBJECTS_NULL_MISSING_TESTS = ( MERGE_OBJECTS_NULL_TESTS + MERGE_OBJECTS_MISSING_TESTS + MERGE_OBJECTS_NULL_MISSING_MIX_TESTS + MERGE_OBJECTS_REMOVE_TESTS + MERGE_OBJECTS_CONSTANT_NULL_TESTS + + MERGE_OBJECTS_EMPTY_COLLECTION_TESTS ) @@ -195,20 +207,3 @@ def test_accumulator_mergeObjects_null_missing(collection, test_case: Accumulato {"aggregate": collection.name, "pipeline": test_case.pipeline, "cursor": {}}, ) assertSuccess(result, test_case.expected, msg=test_case.msg) - - -# Property [Empty Collection]: empty collection produces no group output -# (empty result set). -def test_accumulator_mergeObjects_empty_collection(collection): - """Test $mergeObjects on empty collection returns empty result set.""" - result = execute_command( - collection, - { - "aggregate": collection.name, - "pipeline": [{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], - "cursor": {}, - }, - ) - assertSuccess( - result, [], msg="$mergeObjects on empty collection should return empty result set" - ) From 66026c02fa0eba8747a90a5a68cd5779b30e09b8 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" Date: Mon, 1 Jun 2026 14:24:21 -0700 Subject: [PATCH 07/12] test splitting Signed-off-by: Alina (Xi) Li --- .../test_accumulator_mergeObjects_core.py | 162 ++-------------- ...st_accumulator_mergeObjects_input_forms.py | 177 ++++++++++++++++++ 2 files changed, 188 insertions(+), 151 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_input_forms.py diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_core.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_core.py index dd36f2bf5..fdbd16fd5 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_core.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_core.py @@ -5,7 +5,17 @@ from datetime import datetime, timezone import pytest -from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) from documentdb_tests.compatibility.tests.core.operator.accumulators.utils.accumulator_test_case import ( # noqa: E501 AccumulatorTestCase, @@ -254,153 +264,6 @@ ), ] -# Property [Expression Types]: $mergeObjects accepts various expression types -# as its operand and evaluates them per document. -MERGE_OBJECTS_EXPRESSION_TYPE_TESTS: list[AccumulatorTestCase] = [ - AccumulatorTestCase( - "expr_type_field_path", - docs=[{"v": {"a": 1}}, {"v": {"b": 2}}], - pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], - expected=[{"_id": None, "result": {"a": 1, "b": 2}}], - msg="$mergeObjects should accept a simple field path", - ), - AccumulatorTestCase( - "expr_type_nested_field_path", - docs=[{"data": {"inner": {"a": 1}}}, {"data": {"inner": {"b": 2}}}], - pipeline=[ - {"$group": {"_id": None, "result": {"$mergeObjects": "$data.inner"}}}, - ], - expected=[{"_id": None, "result": {"a": 1, "b": 2}}], - msg="$mergeObjects should accept a nested field path", - ), - AccumulatorTestCase( - "expr_type_literal", - docs=[{"x": 1}, {"x": 2}], - pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": {"$literal": {"a": 1}}}}}], - expected=[{"_id": None, "result": {"a": 1}}], - msg="$mergeObjects should accept a $literal expression", - ), - AccumulatorTestCase( - "expr_type_sysvar_remove", - docs=[{"v": {"a": 1}}, {"v": {"b": 2}}], - pipeline=[ - {"$group": {"_id": None, "result": {"$mergeObjects": "$$REMOVE"}}}, - ], - expected=[{"_id": None, "result": {}}], - msg="$mergeObjects should accept $$REMOVE system variable", - ), - AccumulatorTestCase( - "expr_type_operator_single", - docs=[{"v": {"a": 1}}, {"v": {"b": 2}}], - pipeline=[ - { - "$group": { - "_id": None, - "result": {"$mergeObjects": {"$ifNull": ["$v", {}]}}, - } - } - ], - expected=[{"_id": None, "result": {"a": 1, "b": 2}}], - msg="$mergeObjects should accept a single-input expression operator", - ), - AccumulatorTestCase( - "expr_type_cond", - docs=[{"v": {"a": 1}, "flag": True}, {"v": {"b": 2}, "flag": True}], - pipeline=[ - { - "$group": { - "_id": None, - "result": {"$mergeObjects": {"$cond": ["$flag", "$v", {"z": 0}]}}, - } - } - ], - expected=[{"_id": None, "result": {"a": 1, "b": 2}}], - msg="$mergeObjects should accept a $cond expression", - ), - AccumulatorTestCase( - "expr_type_object_expression", - docs=[{"v": 1}, {"v": 2}], - pipeline=[ - {"$group": {"_id": None, "result": {"$mergeObjects": {"a": "$v"}}}}, - ], - expected=[{"_id": None, "result": {"a": 2}}], - msg="$mergeObjects should accept an object expression with field references", - ), - AccumulatorTestCase( - "expr_type_object_with_operator", - docs=[{"v": -5}, {"v": -10}], - pipeline=[ - { - "$group": { - "_id": None, - "result": {"$mergeObjects": {"a": {"$abs": "$v"}}}, - } - }, - ], - expected=[{"_id": None, "result": {"a": 10}}], - msg="$mergeObjects should accept an object expression containing an operator", - ), - AccumulatorTestCase( - "expr_type_let", - docs=[{"v": {"a": 1}}, {"v": {"b": 2}}], - pipeline=[ - { - "$group": { - "_id": None, - "result": {"$mergeObjects": {"$let": {"vars": {"x": "$v"}, "in": "$$x"}}}, - } - }, - ], - expected=[{"_id": None, "result": {"a": 1, "b": 2}}], - msg="$mergeObjects should accept a $let expression as its operand", - ), -] - -# Property [Field Lookup]: $mergeObjects resolves field paths including nested -# object paths and array index paths. -MERGE_OBJECTS_FIELD_LOOKUP_TESTS: list[AccumulatorTestCase] = [ - AccumulatorTestCase( - "field_lookup_array_index_path", - docs=[ - {"v": {"0": {"b": {"x": 1}}}}, - {"v": {"0": {"b": {"y": 2}}}}, - ], - pipeline=[ - {"$group": {"_id": None, "result": {"$mergeObjects": "$v.0.b"}}}, - ], - expected=[{"_id": None, "result": {"x": 1, "y": 2}}], - msg="$mergeObjects should resolve array index path on nested object", - ), - AccumulatorTestCase( - "field_lookup_nonexistent", - docs=[{"v": {"a": 1}}, {"v": {"b": 2}}], - pipeline=[ - {"$group": {"_id": None, "result": {"$mergeObjects": "$nonexistent"}}}, - ], - expected=[{"_id": None, "result": {}}], - msg="$mergeObjects should treat nonexistent field as missing", - ), -] - -# Property [Constant Object Expression]: a constant object expression applies -# the same document to every group member, with last document winning. -MERGE_OBJECTS_CONSTANT_OBJECT_TESTS: list[AccumulatorTestCase] = [ - AccumulatorTestCase( - "constant_object", - docs=[{"x": 1}, {"x": 2}], - pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": {"a": 1, "b": 2}}}}], - expected=[{"_id": None, "result": {"a": 1, "b": 2}}], - msg="$mergeObjects should accept constant object and return it", - ), - AccumulatorTestCase( - "constant_empty_object", - docs=[{"x": 1}, {"x": 2}], - pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": {}}}}], - expected=[{"_id": None, "result": {}}], - msg="$mergeObjects should accept constant empty object and return empty document", - ), -] - MERGE_OBJECTS_CORE_TESTS = ( MERGE_OBJECTS_DISJOINT_TESTS + MERGE_OBJECTS_OVERLAP_TESTS @@ -408,9 +271,6 @@ + MERGE_OBJECTS_EMPTY_DOC_TESTS + MERGE_OBJECTS_BSON_TYPE_TESTS + MERGE_OBJECTS_GROUPED_TESTS - + MERGE_OBJECTS_EXPRESSION_TYPE_TESTS - + MERGE_OBJECTS_FIELD_LOOKUP_TESTS - + MERGE_OBJECTS_CONSTANT_OBJECT_TESTS ) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_input_forms.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_input_forms.py new file mode 100644 index 000000000..3d41c6795 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_input_forms.py @@ -0,0 +1,177 @@ +"""Tests for $mergeObjects accumulator: expression types, field lookup, and constant objects.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.accumulators.utils.accumulator_test_case import ( # noqa: E501 + AccumulatorTestCase, +) +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Expression Types]: $mergeObjects accepts various expression types +# as its operand and evaluates them per document. +MERGE_OBJECTS_EXPRESSION_TYPE_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "expr_type_field_path", + docs=[{"v": {"a": 1}}, {"v": {"b": 2}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": 1, "b": 2}}], + msg="$mergeObjects should accept a simple field path", + ), + AccumulatorTestCase( + "expr_type_nested_field_path", + docs=[{"data": {"inner": {"a": 1}}}, {"data": {"inner": {"b": 2}}}], + pipeline=[ + {"$group": {"_id": None, "result": {"$mergeObjects": "$data.inner"}}}, + ], + expected=[{"_id": None, "result": {"a": 1, "b": 2}}], + msg="$mergeObjects should accept a nested field path", + ), + AccumulatorTestCase( + "expr_type_literal", + docs=[{"x": 1}, {"x": 2}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": {"$literal": {"a": 1}}}}}], + expected=[{"_id": None, "result": {"a": 1}}], + msg="$mergeObjects should accept a $literal expression", + ), + AccumulatorTestCase( + "expr_type_sysvar_remove", + docs=[{"v": {"a": 1}}, {"v": {"b": 2}}], + pipeline=[ + {"$group": {"_id": None, "result": {"$mergeObjects": "$$REMOVE"}}}, + ], + expected=[{"_id": None, "result": {}}], + msg="$mergeObjects should accept $$REMOVE system variable", + ), + AccumulatorTestCase( + "expr_type_operator_single", + docs=[{"v": {"a": 1}}, {"v": {"b": 2}}], + pipeline=[ + { + "$group": { + "_id": None, + "result": {"$mergeObjects": {"$ifNull": ["$v", {}]}}, + } + } + ], + expected=[{"_id": None, "result": {"a": 1, "b": 2}}], + msg="$mergeObjects should accept a single-input expression operator", + ), + AccumulatorTestCase( + "expr_type_cond", + docs=[{"v": {"a": 1}, "flag": True}, {"v": {"b": 2}, "flag": True}], + pipeline=[ + { + "$group": { + "_id": None, + "result": {"$mergeObjects": {"$cond": ["$flag", "$v", {"z": 0}]}}, + } + } + ], + expected=[{"_id": None, "result": {"a": 1, "b": 2}}], + msg="$mergeObjects should accept a $cond expression", + ), + AccumulatorTestCase( + "expr_type_object_expression", + docs=[{"v": 1}, {"v": 2}], + pipeline=[ + {"$group": {"_id": None, "result": {"$mergeObjects": {"a": "$v"}}}}, + ], + expected=[{"_id": None, "result": {"a": 2}}], + msg="$mergeObjects should accept an object expression with field references", + ), + AccumulatorTestCase( + "expr_type_object_with_operator", + docs=[{"v": -5}, {"v": -10}], + pipeline=[ + { + "$group": { + "_id": None, + "result": {"$mergeObjects": {"a": {"$abs": "$v"}}}, + } + }, + ], + expected=[{"_id": None, "result": {"a": 10}}], + msg="$mergeObjects should accept an object expression containing an operator", + ), + AccumulatorTestCase( + "expr_type_let", + docs=[{"v": {"a": 1}}, {"v": {"b": 2}}], + pipeline=[ + { + "$group": { + "_id": None, + "result": {"$mergeObjects": {"$let": {"vars": {"x": "$v"}, "in": "$$x"}}}, + } + }, + ], + expected=[{"_id": None, "result": {"a": 1, "b": 2}}], + msg="$mergeObjects should accept a $let expression as its operand", + ), +] + +# Property [Field Lookup]: $mergeObjects resolves field paths including nested +# object paths and array index paths. +MERGE_OBJECTS_FIELD_LOOKUP_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "field_lookup_array_index_path", + docs=[ + {"v": {"0": {"b": {"x": 1}}}}, + {"v": {"0": {"b": {"y": 2}}}}, + ], + pipeline=[ + {"$group": {"_id": None, "result": {"$mergeObjects": "$v.0.b"}}}, + ], + expected=[{"_id": None, "result": {"x": 1, "y": 2}}], + msg="$mergeObjects should resolve array index path on nested object", + ), + AccumulatorTestCase( + "field_lookup_nonexistent", + docs=[{"v": {"a": 1}}, {"v": {"b": 2}}], + pipeline=[ + {"$group": {"_id": None, "result": {"$mergeObjects": "$nonexistent"}}}, + ], + expected=[{"_id": None, "result": {}}], + msg="$mergeObjects should treat nonexistent field as missing", + ), +] + +# Property [Constant Object Expression]: a constant object expression applies +# the same document to every group member, with last document winning. +MERGE_OBJECTS_CONSTANT_OBJECT_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "constant_object", + docs=[{"x": 1}, {"x": 2}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": {"a": 1, "b": 2}}}}], + expected=[{"_id": None, "result": {"a": 1, "b": 2}}], + msg="$mergeObjects should accept constant object and return it", + ), + AccumulatorTestCase( + "constant_empty_object", + docs=[{"x": 1}, {"x": 2}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": {}}}}], + expected=[{"_id": None, "result": {}}], + msg="$mergeObjects should accept constant empty object and return empty document", + ), +] + +MERGE_OBJECTS_INPUT_FORM_TESTS = ( + MERGE_OBJECTS_EXPRESSION_TYPE_TESTS + + MERGE_OBJECTS_FIELD_LOOKUP_TESTS + + MERGE_OBJECTS_CONSTANT_OBJECT_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(MERGE_OBJECTS_INPUT_FORM_TESTS)) +def test_accumulator_mergeObjects_input_forms(collection, test_case: AccumulatorTestCase): + """Test $mergeObjects expression types, field lookup, and constant objects.""" + if test_case.docs: + collection.insert_many(test_case.docs) + result = execute_command( + collection, + {"aggregate": collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + assertSuccess(result, test_case.expected, msg=test_case.msg) From 9e596ba95c30ebdc4a7c8dfbcf5276587c8b72ce Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" Date: Mon, 1 Jun 2026 14:32:59 -0700 Subject: [PATCH 08/12] add root tests and large object tests Signed-off-by: Alina (Xi) Li --- ...est_accumulator_mergeObjects_edge_cases.py | 37 +++++++++++++++++++ ...st_accumulator_mergeObjects_input_forms.py | 20 ++++++++++ 2 files changed, 57 insertions(+) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_edge_cases.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_edge_cases.py index a89046163..c3377d905 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_edge_cases.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_edge_cases.py @@ -67,6 +67,42 @@ ), ] +# Property [Large Values]: $mergeObjects preserves large field values. +MERGE_OBJECTS_LARGE_VALUE_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "large_value_long_string", + docs=[{"v": {"a": "x" * 10000}}, {"v": {"b": 1}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": "x" * 10000, "b": 1}}], + msg="$mergeObjects should preserve very long string values", + ), + AccumulatorTestCase( + "large_value_deep_nesting", + docs=[ + {"v": {"a": {"b": {"c": {"d": {"e": {"f": {"g": {"h": {"i": {"j": 1}}}}}}}}}}}, + {"v": {"x": 2}}, + ], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[ + { + "_id": None, + "result": { + "a": {"b": {"c": {"d": {"e": {"f": {"g": {"h": {"i": {"j": 1}}}}}}}}}, + "x": 2, + }, + } + ], + msg="$mergeObjects should preserve deeply nested objects (10 levels)", + ), + AccumulatorTestCase( + "large_value_large_array", + docs=[{"v": {"a": list(range(1000))}}, {"v": {"b": 1}}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": list(range(1000)), "b": 1}}], + msg="$mergeObjects should preserve large array fields (1000 elements)", + ), +] + # Property [Special Field Names]: $mergeObjects correctly handles special # field names including unicode, dollar-prefixed, dotted, empty string, and # numeric string keys. @@ -196,6 +232,7 @@ MERGE_OBJECTS_SINGLE_DOC_TESTS + MERGE_OBJECTS_MANY_DOCS_TESTS + MERGE_OBJECTS_LARGE_DOC_TESTS + + MERGE_OBJECTS_LARGE_VALUE_TESTS + MERGE_OBJECTS_SPECIAL_FIELD_TESTS + MERGE_OBJECTS_ID_FIELD_TESTS + MERGE_OBJECTS_DEEP_NESTING_TESTS diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_input_forms.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_input_forms.py index 3d41c6795..814c90f21 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_input_forms.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_input_forms.py @@ -46,6 +46,26 @@ expected=[{"_id": None, "result": {}}], msg="$mergeObjects should accept $$REMOVE system variable", ), + AccumulatorTestCase( + "expr_type_sysvar_root", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$mergeObjects": "$$ROOT"}}}, + ], + expected=[{"_id": None, "result": {"_id": 2, "x": 2}}], + msg="$mergeObjects should accept $$ROOT system variable", + ), + AccumulatorTestCase( + "expr_type_sysvar_current", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$mergeObjects": "$$CURRENT"}}}, + ], + expected=[{"_id": None, "result": {"_id": 2, "x": 2}}], + msg="$mergeObjects should accept $$CURRENT system variable", + ), AccumulatorTestCase( "expr_type_operator_single", docs=[{"v": {"a": 1}}, {"v": {"b": 2}}], From ee7d6914c9663006eacd975a80b3b7ab1023568f Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" Date: Mon, 1 Jun 2026 14:40:46 -0700 Subject: [PATCH 09/12] rename tests and remove dups Signed-off-by: Alina (Xi) Li --- .../test_accumulator_mergeObjects_core.py | 2 +- ...st_accumulator_mergeObjects_input_forms.py | 37 ++++++++----------- 2 files changed, 16 insertions(+), 23 deletions(-) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_core.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_core.py index fdbd16fd5..eceb72dca 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_core.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_core.py @@ -202,7 +202,7 @@ "bson_binary_regex", docs=[{"v": {"a": Binary(b"\x01\x02")}}, {"v": {"b": Regex("abc", "i")}}], pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], - expected=[{"_id": None, "result": {"a": b"\x01\x02", "b": Regex("abc", 2)}}], + expected=[{"_id": None, "result": {"a": b"\x01\x02", "b": Regex("abc", "i")}}], msg="$mergeObjects should preserve Binary and Regex types", ), AccumulatorTestCase( diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_input_forms.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_input_forms.py index 814c90f21..fb8eec15e 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_input_forms.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_input_forms.py @@ -14,13 +14,6 @@ # Property [Expression Types]: $mergeObjects accepts various expression types # as its operand and evaluates them per document. MERGE_OBJECTS_EXPRESSION_TYPE_TESTS: list[AccumulatorTestCase] = [ - AccumulatorTestCase( - "expr_type_field_path", - docs=[{"v": {"a": 1}}, {"v": {"b": 2}}], - pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], - expected=[{"_id": None, "result": {"a": 1, "b": 2}}], - msg="$mergeObjects should accept a simple field path", - ), AccumulatorTestCase( "expr_type_nested_field_path", docs=[{"data": {"inner": {"a": 1}}}, {"data": {"inner": {"b": 2}}}], @@ -39,12 +32,12 @@ ), AccumulatorTestCase( "expr_type_sysvar_remove", - docs=[{"v": {"a": 1}}, {"v": {"b": 2}}], + docs=[{"x": 1}, {"x": 2}], pipeline=[ {"$group": {"_id": None, "result": {"$mergeObjects": "$$REMOVE"}}}, ], expected=[{"_id": None, "result": {}}], - msg="$mergeObjects should accept $$REMOVE system variable", + msg="$mergeObjects should accept $$REMOVE and return empty document", ), AccumulatorTestCase( "expr_type_sysvar_root", @@ -67,7 +60,7 @@ msg="$mergeObjects should accept $$CURRENT system variable", ), AccumulatorTestCase( - "expr_type_operator_single", + "expr_type_ifnull", docs=[{"v": {"a": 1}}, {"v": {"b": 2}}], pipeline=[ { @@ -78,7 +71,7 @@ } ], expected=[{"_id": None, "result": {"a": 1, "b": 2}}], - msg="$mergeObjects should accept a single-input expression operator", + msg="$mergeObjects should accept $ifNull as its operand expression", ), AccumulatorTestCase( "expr_type_cond", @@ -95,13 +88,13 @@ msg="$mergeObjects should accept a $cond expression", ), AccumulatorTestCase( - "expr_type_object_expression", + "expr_type_object_with_field_ref", docs=[{"v": 1}, {"v": 2}], pipeline=[ {"$group": {"_id": None, "result": {"$mergeObjects": {"a": "$v"}}}}, ], expected=[{"_id": None, "result": {"a": 2}}], - msg="$mergeObjects should accept an object expression with field references", + msg="$mergeObjects should accept object expression with field reference, last wins", ), AccumulatorTestCase( "expr_type_object_with_operator", @@ -115,7 +108,7 @@ }, ], expected=[{"_id": None, "result": {"a": 10}}], - msg="$mergeObjects should accept an object expression containing an operator", + msg="$mergeObjects should accept object expression with operator, last wins", ), AccumulatorTestCase( "expr_type_let", @@ -137,7 +130,7 @@ # object paths and array index paths. MERGE_OBJECTS_FIELD_LOOKUP_TESTS: list[AccumulatorTestCase] = [ AccumulatorTestCase( - "field_lookup_array_index_path", + "field_lookup_numeric_key_path", docs=[ {"v": {"0": {"b": {"x": 1}}}}, {"v": {"0": {"b": {"y": 2}}}}, @@ -146,31 +139,31 @@ {"$group": {"_id": None, "result": {"$mergeObjects": "$v.0.b"}}}, ], expected=[{"_id": None, "result": {"x": 1, "y": 2}}], - msg="$mergeObjects should resolve array index path on nested object", + msg="$mergeObjects should traverse numeric string key as object field name", ), AccumulatorTestCase( - "field_lookup_nonexistent", + "field_lookup_nonexistent_returns_empty", docs=[{"v": {"a": 1}}, {"v": {"b": 2}}], pipeline=[ {"$group": {"_id": None, "result": {"$mergeObjects": "$nonexistent"}}}, ], expected=[{"_id": None, "result": {}}], - msg="$mergeObjects should treat nonexistent field as missing", + msg="$mergeObjects should treat nonexistent field path as missing", ), ] -# Property [Constant Object Expression]: a constant object expression applies -# the same document to every group member, with last document winning. +# Property [Constant Object Expression]: a constant object expression (no +# field references or operators) is accepted and returned unchanged. MERGE_OBJECTS_CONSTANT_OBJECT_TESTS: list[AccumulatorTestCase] = [ AccumulatorTestCase( - "constant_object", + "constant_object_returned", docs=[{"x": 1}, {"x": 2}], pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": {"a": 1, "b": 2}}}}], expected=[{"_id": None, "result": {"a": 1, "b": 2}}], msg="$mergeObjects should accept constant object and return it", ), AccumulatorTestCase( - "constant_empty_object", + "constant_empty_object_returned", docs=[{"x": 1}, {"x": 2}], pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": {}}}}], expected=[{"_id": None, "result": {}}], From 0b0015e3142cf9fc7877b3a036559e10d1f642b8 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" Date: Mon, 1 Jun 2026 14:45:59 -0700 Subject: [PATCH 10/12] inital integration tests Signed-off-by: Alina (Xi) Li --- ...t_accumulators_mergeObjects_integration.py | 311 ++++++++++++++++++ 1 file changed, 311 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/accumulators/test_accumulators_mergeObjects_integration.py diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/test_accumulators_mergeObjects_integration.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/test_accumulators_mergeObjects_integration.py new file mode 100644 index 000000000..d2c1246d5 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/test_accumulators_mergeObjects_integration.py @@ -0,0 +1,311 @@ +"""Tests for $mergeObjects accumulator composed with sibling accumulators in the same $group.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.accumulators.utils.accumulator_test_case import ( # noqa: E501 + AccumulatorTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [MergeObjects vs Push]: $mergeObjects shallow-merges per-document +# objects into one result while $push collects them as separate array elements. +MERGEOBJECTS_WITH_PUSH_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "mergeObjects_vs_push_disjoint", + docs=[ + {"_id": 1, "cat": "a", "meta": {"src": "x"}}, + {"_id": 2, "cat": "a", "meta": {"quality": "high"}}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + { + "$group": { + "_id": "$cat", + "merged": {"$mergeObjects": "$meta"}, + "pushed": {"$push": "$meta"}, + } + }, + ], + expected=[ + { + "_id": "a", + "merged": {"src": "x", "quality": "high"}, + "pushed": [{"src": "x"}, {"quality": "high"}], + } + ], + msg="$mergeObjects should merge into one object while $push collects as array", + ), + AccumulatorTestCase( + "mergeObjects_vs_push_overlap", + docs=[ + {"_id": 1, "cat": "a", "meta": {"k": 1}}, + {"_id": 2, "cat": "a", "meta": {"k": 2}}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + { + "$group": { + "_id": "$cat", + "merged": {"$mergeObjects": "$meta"}, + "pushed": {"$push": "$meta"}, + } + }, + ], + expected=[ + { + "_id": "a", + "merged": {"k": 2}, + "pushed": [{"k": 1}, {"k": 2}], + } + ], + msg="$mergeObjects last-wins on overlap while $push preserves both objects", + ), +] + +# Property [MergeObjects vs First/Last]: $mergeObjects combines all documents +# while $first/$last pick one positional value from the sorted group. +MERGEOBJECTS_WITH_FIRST_LAST_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "mergeObjects_with_first_last", + docs=[ + {"_id": 1, "cat": "a", "meta": {"x": 1}, "v": 10}, + {"_id": 2, "cat": "a", "meta": {"y": 2}, "v": 20}, + {"_id": 3, "cat": "a", "meta": {"z": 3}, "v": 30}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + { + "$group": { + "_id": "$cat", + "merged": {"$mergeObjects": "$meta"}, + "first_v": {"$first": "$v"}, + "last_v": {"$last": "$v"}, + } + }, + ], + expected=[ + { + "_id": "a", + "merged": {"x": 1, "y": 2, "z": 3}, + "first_v": 10, + "last_v": 30, + } + ], + msg="$mergeObjects should merge all objects while $first/$last pick endpoints", + ), +] + +# Property [MergeObjects vs Sum/Count]: $mergeObjects combines object fields +# while $sum aggregates numeric values and $sum(1) counts documents. +MERGEOBJECTS_WITH_SUM_COUNT_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "mergeObjects_with_sum_count", + docs=[ + {"_id": 1, "cat": "a", "meta": {"region": "us"}, "score": 10}, + {"_id": 2, "cat": "a", "meta": {"tier": "gold"}, "score": 20}, + {"_id": 3, "cat": "b", "meta": {"region": "eu"}, "score": 5}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + { + "$group": { + "_id": "$cat", + "merged": {"$mergeObjects": "$meta"}, + "total": {"$sum": "$score"}, + "count": {"$sum": 1}, + } + }, + ], + expected=[ + { + "_id": "a", + "merged": {"region": "us", "tier": "gold"}, + "total": 30, + "count": 2, + }, + {"_id": "b", "merged": {"region": "eu"}, "total": 5, "count": 1}, + ], + msg="$mergeObjects should merge objects while $sum/$count aggregate numbers", + ), +] + +# Property [MergeObjects vs AddToSet]: $mergeObjects merges all objects +# (last-wins on overlap) while $addToSet collects unique scalar values. +MERGEOBJECTS_WITH_ADDTOSET_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "mergeObjects_vs_addToSet", + docs=[ + {"_id": 1, "cat": "a", "meta": {"src": "x"}, "tag": "a"}, + {"_id": 2, "cat": "a", "meta": {"src": "y"}, "tag": "b"}, + {"_id": 3, "cat": "a", "meta": {"quality": "high"}, "tag": "a"}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + { + "$group": { + "_id": "$cat", + "merged": {"$mergeObjects": "$meta"}, + "tags": {"$addToSet": "$tag"}, + } + }, + ], + expected=[ + { + "_id": "a", + "merged": {"src": "y", "quality": "high"}, + "tags": ["a", "b"], + } + ], + msg="$mergeObjects should last-win on 'src' while $addToSet deduplicates tags", + ), +] + +# Property [MergeObjects Null Divergence]: $mergeObjects ignores null while +# sibling accumulators handle null differently -- $push includes it, $sum +# treats it as 0, $first returns it. +MERGEOBJECTS_NULL_DIVERGENCE_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "mergeObjects_null_vs_push_sum", + docs=[ + {"_id": 1, "cat": "a", "meta": None, "v": None}, + {"_id": 2, "cat": "a", "meta": {"b": 2}, "v": 10}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + { + "$group": { + "_id": "$cat", + "merged": {"$mergeObjects": "$meta"}, + "pushed": {"$push": "$meta"}, + "total": {"$sum": "$v"}, + } + }, + ], + expected=[ + { + "_id": "a", + "merged": {"b": 2}, + "pushed": [None, {"b": 2}], + "total": 10, + } + ], + msg="$mergeObjects ignores null; $push includes null; $sum treats null as 0", + ), + AccumulatorTestCase( + "mergeObjects_null_vs_first", + docs=[ + {"_id": 1, "cat": "a", "meta": None, "v": None}, + {"_id": 2, "cat": "a", "meta": {"b": 2}, "v": 10}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + { + "$group": { + "_id": "$cat", + "merged": {"$mergeObjects": "$meta"}, + "first_v": {"$first": "$v"}, + } + }, + ], + expected=[ + {"_id": "a", "merged": {"b": 2}, "first_v": None}, + ], + msg="$mergeObjects ignores null meta; $first returns null as the first value", + ), +] + +# Property [MergeObjects Missing Divergence]: $mergeObjects ignores missing +# fields while $sum ignores them (returns 0) and $push omits them. +MERGEOBJECTS_MISSING_DIVERGENCE_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "mergeObjects_missing_vs_sum_push", + docs=[ + {"_id": 1, "cat": "a", "meta": {"x": 1}, "v": 10}, + {"_id": 2, "cat": "a", "v": 20}, + {"_id": 3, "cat": "a", "meta": {"z": 3}}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + { + "$group": { + "_id": "$cat", + "merged": {"$mergeObjects": "$meta"}, + "total": {"$sum": "$v"}, + "pushed": {"$push": "$meta"}, + } + }, + ], + expected=[ + { + "_id": "a", + "merged": {"x": 1, "z": 3}, + "total": 30, + "pushed": [{"x": 1}, {"z": 3}], + } + ], + msg="$mergeObjects skips missing meta; $sum skips missing v; $push omits missing", + ), +] + +# Property [Multiple MergeObjects]: multiple $mergeObjects accumulators in the +# same $group independently merge different fields. +MULTIPLE_MERGEOBJECTS_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "multiple_mergeObjects_different_fields", + docs=[ + {"_id": 1, "cat": "a", "meta": {"x": 1}, "cfg": {"debug": True}}, + {"_id": 2, "cat": "a", "meta": {"y": 2}, "cfg": {"verbose": False}}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + { + "$group": { + "_id": "$cat", + "merged_meta": {"$mergeObjects": "$meta"}, + "merged_cfg": {"$mergeObjects": "$cfg"}, + } + }, + ], + expected=[ + { + "_id": "a", + "merged_meta": {"x": 1, "y": 2}, + "merged_cfg": {"debug": True, "verbose": False}, + } + ], + msg="Multiple $mergeObjects accumulators should merge their fields independently", + ), +] + +MERGEOBJECTS_INTEGRATION_TESTS = ( + MERGEOBJECTS_WITH_PUSH_TESTS + + MERGEOBJECTS_WITH_FIRST_LAST_TESTS + + MERGEOBJECTS_WITH_SUM_COUNT_TESTS + + MERGEOBJECTS_WITH_ADDTOSET_TESTS + + MERGEOBJECTS_NULL_DIVERGENCE_TESTS + + MERGEOBJECTS_MISSING_DIVERGENCE_TESTS + + MULTIPLE_MERGEOBJECTS_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(MERGEOBJECTS_INTEGRATION_TESTS)) +def test_accumulators_mergeObjects_integration(collection, test_case: AccumulatorTestCase): + """Test $mergeObjects accumulator composed with sibling accumulators in the same $group.""" + if test_case.docs: + collection.insert_many(test_case.docs) + result = execute_command( + collection, + {"aggregate": collection.name, "pipeline": test_case.pipeline or [], "cursor": {}}, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ignore_doc_order=True, + ignore_order_in=["tags"], + ) From 6e71b935394b7804104d93ed724a55ca50b4b321 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" Date: Tue, 2 Jun 2026 11:40:04 -0700 Subject: [PATCH 11/12] add expr_error_toint_non_convertible and expr_error_mod_by_zero Signed-off-by: Alina (Xi) Li --- .../test_accumulator_mergeObjects_errors.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_errors.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_errors.py index 832559dd7..ff81bb655 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_errors.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_errors.py @@ -14,6 +14,7 @@ EXPRESSION_OBJECT_MULTIPLE_FIELDS_ERROR, GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, INVALID_DOLLAR_FIELD_PATH, + MODULO_BY_ZERO_V2_ERROR, ) from documentdb_tests.framework.executor import execute_command from documentdb_tests.framework.parametrize import pytest_params @@ -80,6 +81,22 @@ # Property [Expression Error Propagation]: when the accumulator expression # errors for any document in the group, the error propagates to the caller. MERGE_OBJECTS_EXPRESSION_ERROR_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "expr_error_toint_non_convertible", + docs=[{"v": "hello"}], + pipeline=[ + { + "$group": { + "_id": None, + "result": { + "$mergeObjects": {"$let": {"vars": {}, "in": {"x": {"$toInt": "$v"}}}} + }, + } + } + ], + error_code=CONVERSION_FAILURE_ERROR, + msg="$mergeObjects should propagate $toInt conversion error for non-convertible value", + ), AccumulatorTestCase( "expr_error_to_object_id_invalid", docs=[{"v": "not_valid_oid"}], @@ -148,6 +165,22 @@ error_code=DIVIDE_BY_ZERO_V2_ERROR, msg="$mergeObjects should propagate error even when failing doc is not the first", ), + AccumulatorTestCase( + "expr_error_mod_by_zero", + docs=[{"v": 10}], + pipeline=[ + { + "$group": { + "_id": None, + "result": { + "$mergeObjects": {"$let": {"vars": {}, "in": {"x": {"$mod": ["$v", 0]}}}} + }, + } + }, + ], + error_code=MODULO_BY_ZERO_V2_ERROR, + msg="$mergeObjects should propagate $mod by zero error", + ), ] MERGE_OBJECTS_ERROR_TESTS = ( From eefa53c79ab6b67bf5164bb6422cc9e6eb4bef62 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" Date: Tue, 2 Jun 2026 11:59:52 -0700 Subject: [PATCH 12/12] add missing tests from updated test_coverage from Signed-off-by: Alina (Xi) Li --- .../test_accumulator_mergeObjects_core.py | 57 +++++++++++++++++++ ...est_accumulator_mergeObjects_edge_cases.py | 42 ++++++++++++++ ...st_accumulator_mergeObjects_input_forms.py | 29 ++++++++++ ...cumulator_mergeObjects_non_object_types.py | 7 +++ ...t_accumulator_mergeObjects_null_missing.py | 18 ++++++ ...t_accumulators_mergeObjects_integration.py | 33 +++++++++++ 6 files changed, 186 insertions(+) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_core.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_core.py index eceb72dca..da9d04a44 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_core.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_core.py @@ -228,6 +228,62 @@ ), ] +# Property [Nested Structure Preservation]: $mergeObjects preserves deeply +# nested arrays-of-objects with embedded arrays without flattening or +# truncation, and correctly resolves array traversal via field paths. +MERGE_OBJECTS_NESTED_STRUCTURE_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "nested_arrays_of_objects_with_embedded_arrays", + docs=[ + { + "v": { + "data": { + "users": [ + {"profile": {"name": "Alice", "scores": [85, 90]}}, + {"profile": {"name": "Bob", "scores": [70, 80]}}, + ] + } + } + }, + {"v": {"metadata": {"tags": [["a", "b"], ["c"]]}}}, + ], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[ + { + "_id": None, + "result": { + "data": { + "users": [ + {"profile": {"name": "Alice", "scores": [85, 90]}}, + {"profile": {"name": "Bob", "scores": [70, 80]}}, + ] + }, + "metadata": {"tags": [["a", "b"], ["c"]]}, + }, + } + ], + msg="$mergeObjects should preserve deeply nested arrays-of-objects with embedded arrays", + ), + AccumulatorTestCase( + "nested_mixed_depth_structures", + docs=[ + {"v": {"a": {"arr": [{"nested": [1, 2]}, {"nested": [3]}]}}}, + {"v": {"b": [{"obj": {"key": "val"}}, [1, 2, 3]]}}, + ], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[ + { + "_id": None, + "result": { + "a": {"arr": [{"nested": [1, 2]}, {"nested": [3]}]}, + "b": [{"obj": {"key": "val"}}, [1, 2, 3]], + }, + } + ], + msg="$mergeObjects should preserve mixed-depth nested structures with arrays and objects", + ), +] + # Property [Grouped Merge]: $mergeObjects correctly merges documents per group # when grouping by a key. MERGE_OBJECTS_GROUPED_TESTS: list[AccumulatorTestCase] = [ @@ -270,6 +326,7 @@ + MERGE_OBJECTS_SHALLOW_TESTS + MERGE_OBJECTS_EMPTY_DOC_TESTS + MERGE_OBJECTS_BSON_TYPE_TESTS + + MERGE_OBJECTS_NESTED_STRUCTURE_TESTS + MERGE_OBJECTS_GROUPED_TESTS ) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_edge_cases.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_edge_cases.py index c3377d905..379ef25e4 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_edge_cases.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_edge_cases.py @@ -54,6 +54,20 @@ expected=[{"_id": None, "result": {"a": 9}}], msg="$mergeObjects should use last value when many documents share the same key", ), + AccumulatorTestCase( + "large_scale_disjoint_keys", + docs=[{"v": {f"k{i}": i}} for i in range(1000)], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {f"k{i}": i for i in range(1000)}}], + msg="$mergeObjects should correctly merge 1000 documents with disjoint keys", + ), + AccumulatorTestCase( + "large_scale_same_key", + docs=[{"v": {"a": i}} for i in range(1000)], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}], + expected=[{"_id": None, "result": {"a": 999}}], + msg="$mergeObjects should use last value from 1000 documents sharing the same key", + ), ] # Property [Large Documents]: $mergeObjects handles documents with many fields. @@ -226,6 +240,34 @@ expected=[{"_id": None, "result": {"a": 1}}], msg="$mergeObjects with descending sort should use value from last document", ), + AccumulatorTestCase( + "order_dependent_compound_sort", + docs=[ + {"_id": 1, "priority": 1, "status": "active", "v": {"a": "first"}}, + {"_id": 2, "priority": 1, "status": "active", "v": {"a": "second"}}, + {"_id": 3, "priority": 2, "status": "inactive", "v": {"a": "third"}}, + ], + pipeline=[ + {"$sort": {"priority": 1, "status": -1, "_id": 1}}, + {"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}, + ], + expected=[{"_id": None, "result": {"a": "third"}}], + msg="$mergeObjects with compound mixed-direction sort should use last value", + ), + AccumulatorTestCase( + "order_dependent_nested_field_sort", + docs=[ + {"_id": 1, "user": {"dept": "eng"}, "v": {"a": 1}}, + {"_id": 2, "user": {"dept": "sales"}, "v": {"a": 2}}, + {"_id": 3, "user": {"dept": "eng"}, "v": {"a": 3}}, + ], + pipeline=[ + {"$sort": {"user.dept": 1, "_id": 1}}, + {"$group": {"_id": None, "result": {"$mergeObjects": "$v"}}}, + ], + expected=[{"_id": None, "result": {"a": 2}}], + msg="$mergeObjects with nested field path sort should use last document's value", + ), ] MERGE_OBJECTS_EDGE_TESTS = ( diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_input_forms.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_input_forms.py index fb8eec15e..2365a9f0f 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_input_forms.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_input_forms.py @@ -141,6 +141,18 @@ expected=[{"_id": None, "result": {"x": 1, "y": 2}}], msg="$mergeObjects should traverse numeric string key as object field name", ), + AccumulatorTestCase( + "field_lookup_nested_object_path", + docs=[ + {"data": {"inner": {"obj": {"x": 1}}}}, + {"data": {"inner": {"obj": {"y": 2}}}}, + ], + pipeline=[ + {"$group": {"_id": None, "result": {"$mergeObjects": "$data.inner.obj"}}}, + ], + expected=[{"_id": None, "result": {"x": 1, "y": 2}}], + msg="$mergeObjects should resolve deeply nested object field path", + ), AccumulatorTestCase( "field_lookup_nonexistent_returns_empty", docs=[{"v": {"a": 1}}, {"v": {"b": 2}}], @@ -169,6 +181,23 @@ expected=[{"_id": None, "result": {}}], msg="$mergeObjects should accept constant empty object and return empty document", ), + AccumulatorTestCase( + "constant_object_multi_group", + docs=[ + {"cat": "A", "x": 1}, + {"cat": "A", "x": 2}, + {"cat": "B", "x": 3}, + ], + pipeline=[ + {"$group": {"_id": "$cat", "result": {"$mergeObjects": {"val": "$x"}}}}, + {"$sort": {"_id": 1}}, + ], + expected=[ + {"_id": "A", "result": {"val": 2}}, + {"_id": "B", "result": {"val": 3}}, + ], + msg="$mergeObjects with object expression should merge independently per group", + ), ] MERGE_OBJECTS_INPUT_FORM_TESTS = ( diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_non_object_types.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_non_object_types.py index 75a569e7f..c48428bda 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_non_object_types.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_non_object_types.py @@ -145,6 +145,13 @@ error_code=MERGE_OBJECTS_NON_OBJECT_ERROR, msg="$mergeObjects should reject numeric strings without coercion", ), + AccumulatorTestCase( + "non_object_array_from_field_path_traversal", + docs=[{"a": [{"b": {"x": 1}}, {"b": {"y": 2}}]}], + pipeline=[{"$group": {"_id": None, "result": {"$mergeObjects": "$a.b"}}}], + error_code=MERGE_OBJECTS_NON_OBJECT_ERROR, + msg="$mergeObjects should reject array from field path traversal on array-of-objects", + ), ] # Property [Non-Object After Valid Objects]: $mergeObjects errors when a diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_null_missing.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_null_missing.py index e40e65eeb..54aa7b545 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_null_missing.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/mergeObjects/test_accumulator_mergeObjects_null_missing.py @@ -122,6 +122,24 @@ expected=[{"_id": None, "result": {"a": 1}}], msg="$mergeObjects should ignore both null and missing, merging only objects", ), + AccumulatorTestCase( + "null_and_missing_multi_group", + docs=[ + {"cat": "A", "v": None}, + {"cat": "A", "v": {"a": 1}}, + {"cat": "B"}, + {"cat": "B", "v": {"b": 2}}, + ], + pipeline=[ + {"$group": {"_id": "$cat", "result": {"$mergeObjects": "$v"}}}, + {"$sort": {"_id": 1}}, + ], + expected=[ + {"_id": "A", "result": {"a": 1}}, + {"_id": "B", "result": {"b": 2}}, + ], + msg="$mergeObjects should handle null and missing independently per group boundary", + ), ] # Property [$$REMOVE Handling]: $$REMOVE is treated as missing and silently diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/test_accumulators_mergeObjects_integration.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/test_accumulators_mergeObjects_integration.py index d2c1246d5..7b7a8f8b8 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/test_accumulators_mergeObjects_integration.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/test_accumulators_mergeObjects_integration.py @@ -279,6 +279,39 @@ ], msg="Multiple $mergeObjects accumulators should merge their fields independently", ), + AccumulatorTestCase( + "multiple_mergeObjects_multi_group_independent", + docs=[ + {"_id": 1, "cat": "a", "meta": {"x": 1}, "cfg": {"debug": True}}, + {"_id": 2, "cat": "a", "meta": {"y": 2}, "cfg": {"verbose": False}}, + {"_id": 3, "cat": "b", "meta": {"z": 3}, "cfg": {"level": "info"}}, + {"_id": 4, "cat": "b", "meta": None, "cfg": {"level": "warn"}}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + { + "$group": { + "_id": "$cat", + "merged_meta": {"$mergeObjects": "$meta"}, + "merged_cfg": {"$mergeObjects": "$cfg"}, + } + }, + {"$sort": {"_id": 1}}, + ], + expected=[ + { + "_id": "a", + "merged_meta": {"x": 1, "y": 2}, + "merged_cfg": {"debug": True, "verbose": False}, + }, + { + "_id": "b", + "merged_meta": {"z": 3}, + "merged_cfg": {"level": "warn"}, + }, + ], + msg="Multiple $mergeObjects should reset independently across group boundaries", + ), ] MERGEOBJECTS_INTEGRATION_TESTS = (