diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/first/__init__.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/first/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/first/test_accumulator_first_errors.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/first/test_accumulator_first_errors.py new file mode 100644 index 00000000..c102ad9b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/first/test_accumulator_first_errors.py @@ -0,0 +1,117 @@ +"""Tests for $first accumulator error cases: arity rejection and expression error propagation.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.accumulators.utils import ( + AccumulatorTestCase, +) +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, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Arity]: $first in accumulator context is a unary operator and +# rejects array syntax. +FIRST_ARITY_ERROR_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "arity_empty_array", + docs=[{"v": 1}], + pipeline=[ + {"$group": {"_id": None, "result": {"$first": []}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + error_code=GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, + msg="$first should reject empty array in accumulator context", + ), + AccumulatorTestCase( + "arity_single_element_array", + docs=[{"v": 1}], + pipeline=[ + {"$group": {"_id": None, "result": {"$first": [1]}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + error_code=GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, + msg="$first should reject single-element literal array in accumulator context", + ), + AccumulatorTestCase( + "arity_single_field_ref_array", + docs=[{"v": 1}], + pipeline=[ + {"$group": {"_id": None, "result": {"$first": ["$v"]}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + error_code=GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, + msg="$first should reject single field ref in array in accumulator context", + ), + AccumulatorTestCase( + "arity_multi_element_array", + docs=[{"v": 1}], + pipeline=[ + {"$group": {"_id": None, "result": {"$first": [1, 2, 3]}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + error_code=GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, + msg="$first should reject multi-element array in accumulator context", + ), + AccumulatorTestCase( + "arity_multi_key_expression_object", + docs=[{"v": 1}], + pipeline=[ + { + "$group": { + "_id": None, + "result": {"$first": {"$add": [1, 2], "$multiply": [3, 4]}}, + } + }, + {"$project": {"_id": 0, "result": 1}}, + ], + error_code=EXPRESSION_OBJECT_MULTIPLE_FIELDS_ERROR, + msg="$first should reject multi-key expression object", + ), +] + +# Property [Expression Error Propagation]: errors raised during sub-expression +# evaluation propagate through the accumulator without being caught. +FIRST_EXPRESSION_ERROR_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "expr_error_divide_by_zero", + docs=[{"v": 1}], + pipeline=[ + {"$group": {"_id": None, "result": {"$first": {"$divide": ["$v", 0]}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + error_code=DIVIDE_BY_ZERO_V2_ERROR, + msg="$first should propagate $divide by zero error", + ), + AccumulatorTestCase( + "expr_error_to_int_invalid_string", + docs=[{"v": "abc"}], + pipeline=[ + {"$group": {"_id": None, "result": {"$first": {"$toInt": "$v"}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + error_code=CONVERSION_FAILURE_ERROR, + msg="$first should propagate $toInt conversion error from expression", + ), +] + +FIRST_ERROR_TESTS = FIRST_ARITY_ERROR_TESTS + FIRST_EXPRESSION_ERROR_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(FIRST_ERROR_TESTS)) +def test_accumulator_first_errors(collection, test_case): + """Test $first accumulator 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) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/first/test_accumulator_first_null_missing.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/first/test_accumulator_first_null_missing.py new file mode 100644 index 00000000..d2e2b9e8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/first/test_accumulator_first_null_missing.py @@ -0,0 +1,139 @@ +"""Tests for $first accumulator null, missing, and edge case behavior.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.accumulators.utils import ( + 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 and Missing NOT Excluded]: $first returns whatever the +# first document has, including null and missing values. +FIRST_NULL_MISSING_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "null_first_then_value", + docs=[{"v": None}, {"v": 5}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": None}], + msg="$first should return null when first doc has null (first wins)", + ), + AccumulatorTestCase( + "null_missing_first_then_value", + docs=[{"x": 1}, {"v": 5}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": None}], + msg="$first should return null when first doc has missing field", + ), + AccumulatorTestCase( + "null_value_first_then_null", + docs=[{"v": 5}, {"v": None}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": 5}], + msg="$first should return 5 when first doc has value, second is null", + ), + AccumulatorTestCase( + "null_value_first_then_missing", + docs=[{"v": 5}, {"x": 1}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": 5}], + msg="$first should return 5 when first doc has value, second is missing", + ), + AccumulatorTestCase( + "null_all", + docs=[{"v": None}, {"v": None}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": None}], + msg="$first should return null when all docs have null", + ), + AccumulatorTestCase( + "null_missing_all", + docs=[{"x": 1}, {"x": 2}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": None}], + msg="$first should return null when all docs have missing field", + ), + AccumulatorTestCase( + "null_and_missing_mixed", + docs=[{"v": None}, {"x": 1}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": None}], + msg="$first should return null when first is null and second is missing", + ), +] + +# Property [Edge Cases]: edge cases unique to the accumulator context. +FIRST_EDGE_CASE_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "edge_single_doc", + docs=[{"v": 42}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": 42}], + msg="$first of a single document should return that document's value", + ), + AccumulatorTestCase( + "edge_single_null_doc", + docs=[{"v": None}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": None}], + msg="$first of a single null document should return null", + ), + AccumulatorTestCase( + "edge_single_missing_doc", + docs=[{"x": 1}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": None}], + msg="$first of a single document with missing field should return null", + ), + AccumulatorTestCase( + "edge_array_not_traversed", + docs=[{"v": [5, 1, 8]}, {"v": [3, 9, 2]}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": [5, 1, 8]}], + msg="$first should return array as whole value, not traverse it", + ), + AccumulatorTestCase( + "edge_empty_collection", + docs=[], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[], + msg="$first on empty collection should produce no groups (empty result)", + ), + AccumulatorTestCase( + "edge_order_dependent_asc", + docs=[{"v": 3}, {"v": 1}, {"v": 5}, {"v": 2}, {"v": 4}], + pipeline=[ + {"$sort": {"v": 1}}, + {"$group": {"_id": None, "result": {"$first": "$v"}}}, + ], + expected=[{"_id": None, "result": 1}], + msg="$first with ascending sort should return smallest value", + ), + AccumulatorTestCase( + "edge_order_dependent_desc", + docs=[{"v": 3}, {"v": 1}, {"v": 5}, {"v": 2}, {"v": 4}], + pipeline=[ + {"$sort": {"v": -1}}, + {"$group": {"_id": None, "result": {"$first": "$v"}}}, + ], + expected=[{"_id": None, "result": 5}], + msg="$first with descending sort should return largest value", + ), +] + +FIRST_SUCCESS_TESTS = FIRST_NULL_MISSING_TESTS + FIRST_EDGE_CASE_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(FIRST_SUCCESS_TESTS)) +def test_accumulator_first_null_missing(collection, test_case: AccumulatorTestCase): + """Test $first accumulator null, missing, and edge case 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/first/test_accumulator_first_types.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/first/test_accumulator_first_types.py new file mode 100644 index 00000000..df4fa9f9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/first/test_accumulator_first_types.py @@ -0,0 +1,608 @@ +"""Tests for $first accumulator BSON type preservation and type fidelity.""" + +from __future__ import annotations + +import math +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.operator.accumulators.utils import ( + AccumulatorTestCase, +) +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_LARGE_EXPONENT, + DECIMAL128_MIN_POSITIVE, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Property [BSON Type Preservation]: $first returns the first document's +# value with its BSON type preserved exactly. +FIRST_BSON_TYPE_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "type_int32", + docs=[{"v": 42}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": 42}], + msg="$first should preserve int32 type", + ), + AccumulatorTestCase( + "type_int64", + docs=[{"v": Int64(42)}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": Int64(42)}], + msg="$first should preserve Int64 type", + ), + AccumulatorTestCase( + "type_double", + docs=[{"v": 3.14}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": 3.14}], + msg="$first should preserve double type", + ), + AccumulatorTestCase( + "type_decimal128", + docs=[{"v": Decimal128("3.14")}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": Decimal128("3.14")}], + msg="$first should preserve Decimal128 type", + ), + AccumulatorTestCase( + "type_string", + docs=[{"v": "hello"}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": "hello"}], + msg="$first should preserve string type", + ), + AccumulatorTestCase( + "type_bool_true", + docs=[{"v": True}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": True}], + msg="$first should preserve boolean True", + ), + AccumulatorTestCase( + "type_bool_false", + docs=[{"v": False}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": False}], + msg="$first should preserve boolean False", + ), + AccumulatorTestCase( + "type_null", + docs=[{"v": None}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": None}], + msg="$first should preserve null value", + ), + AccumulatorTestCase( + "type_embedded_doc", + docs=[{"v": {"a": 1, "b": 2}}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": {"a": 1, "b": 2}}], + msg="$first should preserve embedded document", + ), + AccumulatorTestCase( + "type_empty_doc", + docs=[{"v": {}}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": {}}], + msg="$first should preserve empty document", + ), + AccumulatorTestCase( + "type_array", + docs=[{"v": [1, 2, 3]}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": [1, 2, 3]}], + msg="$first should preserve array value", + ), + AccumulatorTestCase( + "type_empty_array", + docs=[{"v": []}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": []}], + msg="$first should preserve empty array", + ), + AccumulatorTestCase( + "type_binary", + docs=[{"v": Binary(b"\x01\x02")}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": b"\x01\x02"}], + msg="$first should preserve Binary value", + ), + AccumulatorTestCase( + "type_binary_custom_subtype", + docs=[{"v": Binary(b"\x01", 5)}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": Binary(b"\x01", 5)}], + msg="$first should preserve Binary with custom subtype", + ), + AccumulatorTestCase( + "type_objectid", + docs=[{"v": ObjectId("000000000000000000000001")}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": ObjectId("000000000000000000000001")}], + msg="$first should preserve ObjectId value", + ), + AccumulatorTestCase( + "type_datetime", + docs=[{"v": datetime(2023, 6, 15, tzinfo=timezone.utc)}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": datetime(2023, 6, 15, tzinfo=timezone.utc)}], + msg="$first should preserve datetime value", + ), + AccumulatorTestCase( + "type_timestamp", + docs=[{"v": Timestamp(100, 1)}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": Timestamp(100, 1)}], + msg="$first should preserve Timestamp value", + ), + AccumulatorTestCase( + "type_regex", + docs=[{"v": Regex("abc", "i")}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": Regex("abc", "i")}], + msg="$first should preserve Regex value", + ), +] + +# Property [Special Numeric Preservation]: $first passes through special +# numeric values exactly as stored in the first document. +FIRST_SPECIAL_NUMERIC_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "special_float_nan", + docs=[{"v": FLOAT_NAN}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": pytest.approx(math.nan, nan_ok=True)}], + msg="$first should preserve float NaN", + ), + AccumulatorTestCase( + "special_float_neg_zero", + docs=[{"v": DOUBLE_NEGATIVE_ZERO}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": DOUBLE_NEGATIVE_ZERO}], + msg="$first should preserve double -0.0", + ), + AccumulatorTestCase( + "special_float_inf", + docs=[{"v": FLOAT_INFINITY}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": FLOAT_INFINITY}], + msg="$first should preserve float Infinity", + ), + AccumulatorTestCase( + "special_float_neg_inf", + docs=[{"v": FLOAT_NEGATIVE_INFINITY}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": FLOAT_NEGATIVE_INFINITY}], + msg="$first should preserve float -Infinity", + ), + AccumulatorTestCase( + "special_decimal_nan", + docs=[{"v": DECIMAL128_NAN}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": DECIMAL128_NAN}], + msg="$first should preserve Decimal128 NaN", + ), + AccumulatorTestCase( + "special_decimal_neg_nan", + docs=[{"v": DECIMAL128_NEGATIVE_NAN}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": DECIMAL128_NEGATIVE_NAN}], + msg="$first should preserve Decimal128 -NaN", + ), + AccumulatorTestCase( + "special_decimal_neg_zero", + docs=[{"v": DECIMAL128_NEGATIVE_ZERO}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": DECIMAL128_NEGATIVE_ZERO}], + msg="$first should preserve Decimal128 -0", + ), + AccumulatorTestCase( + "special_decimal_inf", + docs=[{"v": DECIMAL128_INFINITY}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": DECIMAL128_INFINITY}], + msg="$first should preserve Decimal128 Infinity", + ), + AccumulatorTestCase( + "special_decimal_neg_inf", + docs=[{"v": DECIMAL128_NEGATIVE_INFINITY}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": DECIMAL128_NEGATIVE_INFINITY}], + msg="$first should preserve Decimal128 -Infinity", + ), +] + +# Property [Decimal128 Precision]: $first passes through Decimal128 values +# without modifying precision, trailing zeros, or exponent. +FIRST_DECIMAL_PRECISION_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "decimal_high_precision", + docs=[{"v": Decimal128("1.234567890123456789012345678901234")}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": Decimal128("1.234567890123456789012345678901234")}], + msg="$first should preserve 34-digit Decimal128 precision", + ), + AccumulatorTestCase( + "decimal_trailing_zeros", + docs=[{"v": Decimal128("1.00")}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": Decimal128("1.00")}], + msg="$first should preserve trailing zeros in Decimal128", + ), + AccumulatorTestCase( + "decimal_large_exponent", + docs=[{"v": DECIMAL128_LARGE_EXPONENT}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": DECIMAL128_LARGE_EXPONENT}], + msg="$first should preserve Decimal128 with large exponent", + ), + AccumulatorTestCase( + "decimal_small_positive", + docs=[{"v": DECIMAL128_MIN_POSITIVE}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": DECIMAL128_MIN_POSITIVE}], + msg="$first should preserve smallest positive Decimal128", + ), + AccumulatorTestCase( + "decimal_zero", + docs=[{"v": DECIMAL128_ZERO}, {"v": 999}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": DECIMAL128_ZERO}], + msg="$first should preserve Decimal128 zero", + ), +] + +# Property [Position-Based]: $first picks the first document's value +# regardless of what other documents contain. +FIRST_MIXED_TYPE_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "mixed_int_then_string", + docs=[{"v": 42}, {"v": "hello"}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": 42}], + msg="$first should return int when first doc is int, second is string", + ), + AccumulatorTestCase( + "mixed_string_then_int", + docs=[{"v": "hello"}, {"v": 42}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": "hello"}], + msg="$first should return string when first doc is string, second is int", + ), + AccumulatorTestCase( + "mixed_bool_then_number", + docs=[{"v": True}, {"v": 42}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": True}], + msg="$first should return True when first doc is bool, second is int", + ), + AccumulatorTestCase( + "mixed_array_then_scalar", + docs=[{"v": [1, 2, 3]}, {"v": 42}], + pipeline=[{"$group": {"_id": None, "result": {"$first": "$v"}}}], + expected=[{"_id": None, "result": [1, 2, 3]}], + msg="$first should return array when first doc is array, second is scalar", + ), +] + +# --------------------------------------------------------------------------- +# Property [BSON Constant Arguments]: $first accepts BSON constants as the +# accumulator argument (not field references). The constant is returned for +# every document, so the "first" value is that constant. +# --------------------------------------------------------------------------- +FIRST_BSON_CONSTANT_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "const_true", + docs=[{"v": 1}, {"v": 2}], + pipeline=[ + {"$sort": {"v": 1}}, + {"$group": {"_id": None, "result": {"$first": True}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": True}], + msg="$first with boolean True constant should return True", + ), + AccumulatorTestCase( + "const_false", + docs=[{"v": 1}, {"v": 2}], + pipeline=[ + {"$sort": {"v": 1}}, + {"$group": {"_id": None, "result": {"$first": False}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": False}], + msg="$first with boolean False constant should return False", + ), + AccumulatorTestCase( + "const_int64", + docs=[{"v": 1}, {"v": 2}], + pipeline=[ + {"$sort": {"v": 1}}, + {"$group": {"_id": None, "result": {"$first": Int64(42)}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": Int64(42)}], + msg="$first with Int64 constant should return that Int64 value", + ), + AccumulatorTestCase( + "const_double", + docs=[{"v": 1}, {"v": 2}], + pipeline=[ + {"$sort": {"v": 1}}, + {"$group": {"_id": None, "result": {"$first": 3.14}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": 3.14}], + msg="$first with double constant should return that double value", + ), + AccumulatorTestCase( + "const_decimal128", + docs=[{"v": 1}, {"v": 2}], + pipeline=[ + {"$sort": {"v": 1}}, + {"$group": {"_id": None, "result": {"$first": Decimal128("3.14")}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": Decimal128("3.14")}], + msg="$first with Decimal128 constant should return that Decimal128 value", + ), + AccumulatorTestCase( + "const_string", + docs=[{"v": 1}, {"v": 2}], + pipeline=[ + {"$sort": {"v": 1}}, + {"$group": {"_id": None, "result": {"$first": "hello"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": "hello"}], + msg="$first with string constant (no $) should return that string", + ), + AccumulatorTestCase( + "const_binary", + docs=[{"v": 1}, {"v": 2}], + pipeline=[ + {"$sort": {"v": 1}}, + {"$group": {"_id": None, "result": {"$first": Binary(b"\x01\x02")}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": b"\x01\x02"}], + msg="$first with Binary constant should return that Binary value", + ), + AccumulatorTestCase( + "const_objectid", + docs=[{"v": 1}, {"v": 2}], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": None, + "result": {"$first": ObjectId("000000000000000000000000")}, + } + }, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": ObjectId("000000000000000000000000")}], + msg="$first with ObjectId constant should return that ObjectId", + ), + AccumulatorTestCase( + "const_datetime", + docs=[{"v": 1}, {"v": 2}], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": None, + "result": {"$first": datetime(2020, 1, 1, tzinfo=timezone.utc)}, + } + }, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": datetime(2020, 1, 1, tzinfo=timezone.utc)}], + msg="$first with datetime constant should return that datetime", + ), + AccumulatorTestCase( + "const_timestamp", + docs=[{"v": 1}, {"v": 2}], + pipeline=[ + {"$sort": {"v": 1}}, + {"$group": {"_id": None, "result": {"$first": Timestamp(1, 1)}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": Timestamp(1, 1)}], + msg="$first with Timestamp constant should return that Timestamp", + ), + AccumulatorTestCase( + "const_regex", + docs=[{"v": 1}, {"v": 2}], + pipeline=[ + {"$sort": {"v": 1}}, + {"$group": {"_id": None, "result": {"$first": Regex("abc", "i")}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": Regex("abc", "i")}], + msg="$first with Regex constant should return that Regex", + ), + AccumulatorTestCase( + "const_null", + docs=[{"v": 1}, {"v": 2}], + pipeline=[ + {"$sort": {"v": 1}}, + {"$group": {"_id": None, "result": {"$first": None}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": None}], + msg="$first with null constant should return null", + ), + AccumulatorTestCase( + "const_minkey", + docs=[{"v": 1}, {"v": 2}], + pipeline=[ + {"$sort": {"v": 1}}, + {"$group": {"_id": None, "result": {"$first": MinKey()}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": {"": MinKey()}}], + msg="$first with MinKey constant should return MinKey wrapped in document", + ), + AccumulatorTestCase( + "const_maxkey", + docs=[{"v": 1}, {"v": 2}], + pipeline=[ + {"$sort": {"v": 1}}, + {"$group": {"_id": None, "result": {"$first": MaxKey()}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": {"": MaxKey()}}], + msg="$first with MaxKey constant should return MaxKey wrapped in document", + ), +] + +# --------------------------------------------------------------------------- +# Property [Expression Types]: $first accepts various expression types as +# its operand and evaluates them per document before picking the first. +# --------------------------------------------------------------------------- +FIRST_EXPRESSION_TYPE_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "expr_operator_single", + docs=[{"v": -10}, {"v": 20}, {"v": -5}], + pipeline=[ + {"$sort": {"v": 1}}, + {"$group": {"_id": None, "result": {"$first": {"$abs": "$v"}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": 10}], + msg="$first should accept single-input expression operator", + ), + AccumulatorTestCase( + "expr_operator_multi_arg", + docs=[{"v": -10, "w": 3}, {"v": 20, "w": 7}, {"v": -5, "w": 1}], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": None, + "result": {"$first": {"$add": ["$v", "$w"]}}, + } + }, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": -7}], + msg="$first should accept a multi-arg expression operator", + ), + AccumulatorTestCase( + "expr_nested", + docs=[{"v": -10}, {"v": 20}, {"v": -5}], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": None, + "result": {"$first": {"$add": [1, {"$abs": "$v"}]}}, + } + }, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": 11}], + msg="$first should accept nested expression operators", + ), + AccumulatorTestCase( + "expr_sysvar_remove", + docs=[{"v": 1}, {"v": 2}], + pipeline=[ + {"$sort": {"v": 1}}, + {"$group": {"_id": None, "result": {"$first": "$$REMOVE"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": None}], + msg="$first with $$REMOVE should treat value as missing and return null", + ), + AccumulatorTestCase( + "expr_object_expression", + docs=[{"v": 10}, {"v": 20}, {"v": 5}], + pipeline=[ + {"$sort": {"v": 1}}, + {"$group": {"_id": None, "result": {"$first": {"a": "$v"}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": {"a": 5}}], + msg="$first should accept an object expression", + ), + AccumulatorTestCase( + "expr_object_with_operator", + docs=[{"v": -10}, {"v": 20}, {"v": -5}], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": None, + "result": {"$first": {"a": {"$abs": "$v"}}}, + } + }, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": {"a": 10}}], + msg="$first should accept an object expression containing an operator", + ), + AccumulatorTestCase( + "expr_let", + docs=[{"v": 10}, {"v": 20}, {"v": 5}], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": None, + "result": {"$first": {"$let": {"vars": {"x": "$v"}, "in": "$$x"}}}, + } + }, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": 5}], + msg="$first should accept a $let expression as its operand", + ), +] + +FIRST_TYPE_SUCCESS_TESTS = ( + FIRST_BSON_TYPE_TESTS + + FIRST_SPECIAL_NUMERIC_TESTS + + FIRST_DECIMAL_PRECISION_TESTS + + FIRST_MIXED_TYPE_TESTS + + FIRST_BSON_CONSTANT_TESTS + + FIRST_EXPRESSION_TYPE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(FIRST_TYPE_SUCCESS_TESTS)) +def test_accumulator_first_types(collection, test_case: AccumulatorTestCase): + """Test $first accumulator BSON type preservation and type fidelity.""" + 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/test_accumulators_first_integration.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/test_accumulators_first_integration.py new file mode 100644 index 00000000..3ee006e5 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/test_accumulators_first_integration.py @@ -0,0 +1,483 @@ +"""Tests for $first accumulator composed with sibling accumulators in the same $group.""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +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 [First with Last]: $first and $last coexist in the same $group, +# picking the first and last values respectively. A preceding $sort +# establishes deterministic order. +FIRST_WITH_LAST_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "first_last_sorted_asc", + docs=[ + {"cat": "a", "v": 30}, + {"cat": "a", "v": 10}, + {"cat": "a", "v": 20}, + ], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": "$cat", + "first_v": {"$first": "$v"}, + "last_v": {"$last": "$v"}, + } + }, + ], + expected=[{"_id": "a", "first_v": 10, "last_v": 30}], + msg="$first should pick smallest and $last should pick largest after ascending sort", + ), + AccumulatorTestCase( + "first_last_sorted_desc", + docs=[ + {"cat": "a", "v": 30}, + {"cat": "a", "v": 10}, + {"cat": "a", "v": 20}, + ], + pipeline=[ + {"$sort": {"v": -1}}, + { + "$group": { + "_id": "$cat", + "first_v": {"$first": "$v"}, + "last_v": {"$last": "$v"}, + } + }, + ], + expected=[{"_id": "a", "first_v": 30, "last_v": 10}], + msg="$first should pick largest and $last should pick smallest after descending sort", + ), + AccumulatorTestCase( + "first_last_multiple_groups", + docs=[ + {"cat": "a", "v": 5}, + {"cat": "a", "v": 15}, + {"cat": "b", "v": 100}, + {"cat": "b", "v": 200}, + {"cat": "b", "v": 300}, + ], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": "$cat", + "first_v": {"$first": "$v"}, + "last_v": {"$last": "$v"}, + } + }, + ], + expected=[ + {"_id": "a", "first_v": 5, "last_v": 15}, + {"_id": "b", "first_v": 100, "last_v": 300}, + ], + msg="$first and $last should work independently across multiple groups", + ), + AccumulatorTestCase( + "first_last_null_first_doc", + docs=[ + {"cat": "a", "v": None}, + {"cat": "a", "v": 10}, + {"cat": "a", "v": 20}, + ], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": "$cat", + "first_v": {"$first": "$v"}, + "last_v": {"$last": "$v"}, + } + }, + ], + expected=[{"_id": "a", "first_v": None, "last_v": 20}], + msg="$first should return null (null sorts first) while $last returns 20", + ), +] + +# Property [First with Min/Max]: $first is position-based while $min/$max +# are value-based. The same data can produce different $first results +# depending on sort order, but $min/$max are always the same. +FIRST_WITH_MIN_MAX_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "first_min_max_sorted_asc", + docs=[ + {"cat": "a", "v": 30}, + {"cat": "a", "v": 10}, + {"cat": "a", "v": 20}, + ], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": "$cat", + "first_v": {"$first": "$v"}, + "lo": {"$min": "$v"}, + "hi": {"$max": "$v"}, + } + }, + ], + expected=[{"_id": "a", "first_v": 10, "lo": 10, "hi": 30}], + msg="$first equals $min after ascending sort; $max is independent", + ), + AccumulatorTestCase( + "first_min_max_sorted_desc", + docs=[ + {"cat": "a", "v": 30}, + {"cat": "a", "v": 10}, + {"cat": "a", "v": 20}, + ], + pipeline=[ + {"$sort": {"v": -1}}, + { + "$group": { + "_id": "$cat", + "first_v": {"$first": "$v"}, + "lo": {"$min": "$v"}, + "hi": {"$max": "$v"}, + } + }, + ], + expected=[{"_id": "a", "first_v": 30, "lo": 10, "hi": 30}], + msg="$first equals $max after descending sort; $min/$max unchanged", + ), + AccumulatorTestCase( + "first_min_max_null_divergence", + docs=[ + {"cat": "a", "v": None}, + {"cat": "a", "v": 10}, + {"cat": "a", "v": 5}, + ], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": "$cat", + "first_v": {"$first": "$v"}, + "lo": {"$min": "$v"}, + "hi": {"$max": "$v"}, + } + }, + ], + expected=[{"_id": "a", "first_v": None, "lo": 5, "hi": 10}], + msg="$first returns null (includes it) while $min/$max ignore null", + ), +] + +# Property [First with Sum/Avg]: $first picks one value, $sum/$avg +# aggregate all. Null divergence: $first returns null when it's in the +# first position; $sum treats null as 0; $avg excludes null from count. +FIRST_WITH_SUM_AVG_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "first_sum_avg_basic", + docs=[ + {"cat": "a", "v": 10}, + {"cat": "a", "v": 20}, + {"cat": "a", "v": 30}, + ], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": "$cat", + "first_v": {"$first": "$v"}, + "total": {"$sum": "$v"}, + "mean": {"$avg": "$v"}, + } + }, + ], + expected=[{"_id": "a", "first_v": 10, "total": 60, "mean": 20.0}], + msg="$first picks 10 while $sum and $avg compute over all values", + ), + AccumulatorTestCase( + "first_sum_avg_null_first_doc", + docs=[ + {"cat": "a", "v": None}, + {"cat": "a", "v": 10}, + {"cat": "a", "v": 20}, + ], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": "$cat", + "first_v": {"$first": "$v"}, + "total": {"$sum": "$v"}, + "mean": {"$avg": "$v"}, + } + }, + ], + expected=[{"_id": "a", "first_v": None, "total": 30, "mean": 15.0}], + msg="$first returns null; $sum ignores null (30); $avg ignores null (15.0)", + ), + AccumulatorTestCase( + "first_sum_avg_all_null", + docs=[ + {"cat": "a", "v": None}, + {"cat": "a", "v": None}, + ], + pipeline=[ + { + "$group": { + "_id": "$cat", + "first_v": {"$first": "$v"}, + "total": {"$sum": "$v"}, + "mean": {"$avg": "$v"}, + } + } + ], + expected=[{"_id": "a", "first_v": None, "total": 0, "mean": None}], + msg="$first returns null; $sum returns 0; $avg returns null when all null", + ), +] + +# Property [First with Count]: $first picks one value while $count counts +# all documents in the group. +FIRST_WITH_COUNT_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "first_count_basic", + docs=[ + {"cat": "a", "v": 10}, + {"cat": "a", "v": 20}, + {"cat": "b", "v": 5}, + ], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": "$cat", + "first_v": {"$first": "$v"}, + "n": {"$sum": 1}, + } + }, + ], + expected=[ + {"_id": "a", "first_v": 10, "n": 2}, + {"_id": "b", "first_v": 5, "n": 1}, + ], + msg="$first picks one value while $sum(1) counts all docs per group", + ), + AccumulatorTestCase( + "first_count_null_counted", + docs=[ + {"cat": "a", "v": None}, + {"cat": "a", "v": 10}, + ], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": "$cat", + "first_v": {"$first": "$v"}, + "n": {"$sum": 1}, + } + }, + ], + expected=[{"_id": "a", "first_v": None, "n": 2}], + msg="$first returns null; $sum(1) still counts the null doc", + ), +] + +# Property [First with Push/AddToSet]: $first picks one value while $push +# collects all values and $addToSet collects unique values. +FIRST_WITH_PUSH_ADDTOSET_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "first_push_addtoset", + docs=[ + {"cat": "a", "v": 10}, + {"cat": "a", "v": 20}, + {"cat": "a", "v": 10}, + ], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": "$cat", + "first_v": {"$first": "$v"}, + "all_vals": {"$push": "$v"}, + "unique_vals": {"$addToSet": "$v"}, + } + }, + ], + expected=[ + {"_id": "a", "first_v": 10, "all_vals": [10, 10, 20], "unique_vals": [10, 20]}, + ], + msg="$first picks 10 while $push collects all and $addToSet collects unique", + ), + AccumulatorTestCase( + "first_push_null_handling", + docs=[ + {"cat": "a", "v": None}, + {"cat": "a", "v": 10}, + ], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": "$cat", + "first_v": {"$first": "$v"}, + "all_vals": {"$push": "$v"}, + } + }, + ], + expected=[ + {"_id": "a", "first_v": None, "all_vals": [None, 10]}, + ], + msg="$first returns null; $push includes null in the collected array", + ), +] + +# Property [First with MergeObjects]: $first picks one scalar value while +# $mergeObjects combines per-document subdocuments into one merged object. +FIRST_WITH_MERGE_OBJECTS_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "first_merge_objects", + docs=[ + {"cat": "a", "v": 10, "meta": {"src": "x"}}, + {"cat": "a", "v": 20, "meta": {"quality": "high"}}, + ], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": "$cat", + "first_v": {"$first": "$v"}, + "merged": {"$mergeObjects": "$meta"}, + } + }, + ], + expected=[ + {"_id": "a", "first_v": 10, "merged": {"src": "x", "quality": "high"}}, + ], + msg="$first picks 10 while $mergeObjects combines all metadata objects", + ), +] + +# Property [Multiple First]: multiple $first accumulators in the same $group +# independently pick the first value from different fields. +MULTIPLE_FIRST_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "multiple_first_different_fields", + docs=[ + {"cat": "a", "name": "alice", "score": 85}, + {"cat": "a", "name": "bob", "score": 92}, + {"cat": "b", "name": "carol", "score": 78}, + ], + pipeline=[ + {"$sort": {"score": 1}}, + { + "$group": { + "_id": "$cat", + "first_name": {"$first": "$name"}, + "first_score": {"$first": "$score"}, + } + }, + ], + expected=[ + {"_id": "a", "first_name": "alice", "first_score": 85}, + {"_id": "b", "first_name": "carol", "first_score": 78}, + ], + msg="Multiple $first accumulators should independently pick first from each field", + ), + AccumulatorTestCase( + "multiple_first_one_missing", + docs=[ + {"cat": "a", "score": 85}, + {"cat": "a", "name": "bob", "score": 92}, + ], + pipeline=[ + {"$sort": {"score": 1}}, + { + "$group": { + "_id": "$cat", + "first_name": {"$first": "$name"}, + "first_score": {"$first": "$score"}, + } + }, + ], + expected=[{"_id": "a", "first_name": None, "first_score": 85}], + msg="$first returns null for missing field while sibling $first returns value", + ), +] + +# Property [First Type Preservation with Sibling]: $first preserves the BSON +# type of the first document's value, even when sibling accumulators promote +# types (e.g. $sum promoting int32+Decimal128 to Decimal128). +FIRST_TYPE_PRESERVATION_WITH_SIBLING_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "first_int32_with_sum_decimal128", + docs=[ + {"cat": "a", "v": 10}, + {"cat": "a", "v": Decimal128("20.5")}, + ], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": "$cat", + "first_v": {"$first": "$v"}, + "total": {"$sum": "$v"}, + } + }, + ], + expected=[{"_id": "a", "first_v": 10, "total": Decimal128("30.5")}], + msg="$first preserves int32 while $sum promotes to Decimal128", + ), + AccumulatorTestCase( + "first_int64_with_sum_double", + docs=[ + {"cat": "a", "v": Int64(100)}, + {"cat": "a", "v": 2.5}, + ], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": "$cat", + "first_v": {"$first": "$v"}, + "total": {"$sum": "$v"}, + } + }, + ], + expected=[{"_id": "a", "first_v": 2.5, "total": 102.5}], + msg="$first preserves double (2.5 sorts first) while $sum promotes to double", + ), +] + +FIRST_INTEGRATION_TESTS = ( + FIRST_WITH_LAST_TESTS + + FIRST_WITH_MIN_MAX_TESTS + + FIRST_WITH_SUM_AVG_TESTS + + FIRST_WITH_COUNT_TESTS + + FIRST_WITH_PUSH_ADDTOSET_TESTS + + FIRST_WITH_MERGE_OBJECTS_TESTS + + MULTIPLE_FIRST_TESTS + + FIRST_TYPE_PRESERVATION_WITH_SIBLING_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(FIRST_INTEGRATION_TESTS)) +def test_accumulators_first_integration(collection, test_case: AccumulatorTestCase): + """Test $first 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=["unique_vals"], + )