From f19faddc76de5a07f3af325932bc8d6e30534648 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" Date: Tue, 19 May 2026 12:58:06 -0700 Subject: [PATCH 01/14] Initial generated tests Signed-off-by: Alina (Xi) Li --- .../last/test_accumulator_last.py | 667 ++++++++++++++++++ .../accumulators/last/test_last_errors.py | 174 +++++ .../last/test_last_group_context.py | 406 +++++++++++ .../last/test_last_group_types.py | 175 +++++ 4 files changed, 1422 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_group_context.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_group_types.py diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last.py new file mode 100644 index 00000000..ec712cd0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last.py @@ -0,0 +1,667 @@ +"""Tests for $last accumulator: BSON type passthrough, null/missing, sort order, expressions.""" + +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, assertSuccessNaN +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_MAX, + DECIMAL128_MIN, + DECIMAL128_NEGATIVE_ZERO, + DOUBLE_MAX, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT64_MAX, + INT64_MIN, +) + +# --------------------------------------------------------------------------- +# Pipeline helpers +# --------------------------------------------------------------------------- + + +def _group_last(acc): + """Build a $sort + $group pipeline for $last.""" + return [ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": acc}}}, + {"$project": {"_id": 0, "result": 1}}, + ] + + +# --------------------------------------------------------------------------- +# Property lists +# --------------------------------------------------------------------------- + +# Property [BSON Type Passthrough]: $last returns the last value in a group +# unchanged, preserving its exact BSON type without coercion. +LAST_BSON_TYPE_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "bson_double", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 3.14}], + pipeline=_group_last("$v"), + expected=[{"result": 3.14}], + msg="$last should return double value unchanged", + ), + AccumulatorTestCase( + "bson_int32", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 42}], + pipeline=_group_last("$v"), + expected=[{"result": 42}], + msg="$last should return int32 value unchanged", + ), + AccumulatorTestCase( + "bson_int64", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Int64(9223372036854775807)}], + pipeline=_group_last("$v"), + expected=[{"result": Int64(9223372036854775807)}], + msg="$last should return int64 value unchanged", + ), + AccumulatorTestCase( + "bson_decimal128", + docs=[ + {"_id": 0, "v": 1}, + {"_id": 1, "v": Decimal128("123.456")}, + ], + pipeline=_group_last("$v"), + expected=[{"result": Decimal128("123.456")}], + msg="$last should return Decimal128 value unchanged", + ), + AccumulatorTestCase( + "bson_string", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": "hello"}], + pipeline=_group_last("$v"), + expected=[{"result": "hello"}], + msg="$last should return string value unchanged", + ), + AccumulatorTestCase( + "bson_bool_true", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": True}], + pipeline=_group_last("$v"), + expected=[{"result": True}], + msg="$last should return boolean true unchanged", + ), + AccumulatorTestCase( + "bson_bool_false", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": False}], + pipeline=_group_last("$v"), + expected=[{"result": False}], + msg="$last should return boolean false unchanged", + ), + AccumulatorTestCase( + "bson_date", + docs=[ + {"_id": 0, "v": 1}, + {"_id": 1, "v": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + ], + pipeline=_group_last("$v"), + expected=[{"result": datetime(2024, 1, 1, tzinfo=timezone.utc)}], + msg="$last should return datetime value unchanged", + ), + AccumulatorTestCase( + "bson_null", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": None}], + pipeline=_group_last("$v"), + expected=[{"result": None}], + msg="$last should return null value unchanged", + ), + AccumulatorTestCase( + "bson_object", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": {"nested": "doc"}}], + pipeline=_group_last("$v"), + expected=[{"result": {"nested": "doc"}}], + msg="$last should return embedded document unchanged", + ), + AccumulatorTestCase( + "bson_array", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": [1, 2, 3]}], + pipeline=_group_last("$v"), + expected=[{"result": [1, 2, 3]}], + msg="$last should return entire array unchanged without traversal", + ), + AccumulatorTestCase( + "bson_binary", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Binary(b"\x00\x01\x02")}], + pipeline=_group_last("$v"), + expected=[{"result": b"\x00\x01\x02"}], + msg="$last should return Binary value unchanged", + ), + AccumulatorTestCase( + "bson_objectid", + docs=[ + {"_id": 0, "v": 1}, + {"_id": 1, "v": ObjectId("507f1f77bcf86cd799439011")}, + ], + pipeline=_group_last("$v"), + expected=[{"result": ObjectId("507f1f77bcf86cd799439011")}], + msg="$last should return ObjectId unchanged", + ), + AccumulatorTestCase( + "bson_regex", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Regex("^abc", "i")}], + pipeline=_group_last("$v"), + expected=[{"result": Regex("^abc", "i")}], + msg="$last should return Regex unchanged", + ), + AccumulatorTestCase( + "bson_code", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Code("function(){}")}], + pipeline=_group_last("$v"), + expected=[{"result": "function(){}"}], + msg="$last should return Code as string via runCommand", + ), + AccumulatorTestCase( + "bson_timestamp", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Timestamp(1, 1)}], + pipeline=_group_last("$v"), + expected=[{"result": Timestamp(1, 1)}], + msg="$last should return Timestamp unchanged", + ), + AccumulatorTestCase( + "bson_minkey", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": MinKey()}], + pipeline=_group_last("$v"), + expected=[{"result": {"": MinKey()}}], + msg="$last should return MinKey wrapped as object via runCommand", + ), + AccumulatorTestCase( + "bson_maxkey", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": MaxKey()}], + pipeline=_group_last("$v"), + expected=[{"result": {"": MaxKey()}}], + msg="$last should return MaxKey wrapped as object via runCommand", + ), +] + +# Property [Null and Missing Handling]: $last returns whatever value the last +# document has. If the field is missing, $last returns null. Unlike numeric +# accumulators, $last does NOT ignore nulls. +LAST_NULL_MISSING_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "null_last_doc", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": None}], + pipeline=_group_last("$v"), + expected=[{"result": None}], + msg="$last should return null when last document has null value", + ), + AccumulatorTestCase( + "missing_last_doc", + docs=[{"_id": 0, "v": 1}, {"_id": 1}], + pipeline=_group_last("$v"), + expected=[{"result": None}], + msg="$last should return null when last document has missing field", + ), + AccumulatorTestCase( + "null_all", + docs=[{"_id": 0, "v": None}, {"_id": 1, "v": None}], + pipeline=_group_last("$v"), + expected=[{"result": None}], + msg="$last should return null when all values are null", + ), + AccumulatorTestCase( + "missing_all", + docs=[{"_id": 0}, {"_id": 1}], + pipeline=_group_last("$v"), + expected=[{"result": None}], + msg="$last should return null when all documents have missing field", + ), + AccumulatorTestCase( + "null_not_last", + docs=[{"_id": 0, "v": None}, {"_id": 1, "v": 10}], + pipeline=_group_last("$v"), + expected=[{"result": 10}], + msg="$last should return last value even when earlier values are null", + ), + AccumulatorTestCase( + "missing_not_last", + docs=[{"_id": 0}, {"_id": 1, "v": 10}], + pipeline=_group_last("$v"), + expected=[{"result": 10}], + msg="$last should return last value even when earlier fields are missing", + ), + AccumulatorTestCase( + "null_among_values", + docs=[{"_id": 0, "v": 10}, {"_id": 1, "v": None}, {"_id": 2, "v": 20}], + pipeline=_group_last("$v"), + expected=[{"result": 20}], + msg="$last should return value from last document regardless of intermediate nulls", + ), + AccumulatorTestCase( + "missing_among_values", + docs=[{"_id": 0, "v": 10}, {"_id": 1}, {"_id": 2, "v": 20}], + pipeline=_group_last("$v"), + expected=[{"result": 20}], + msg="$last should return value from last doc regardless of intermediate missing fields", + ), +] + +# Property [Sort Order Dependency]: $last returns the value from the last +# document as determined by the preceding $sort stage. +LAST_SORT_ORDER_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "sort_ascending", + docs=[ + {"_id": 0, "v": 10}, + {"_id": 1, "v": 20}, + {"_id": 2, "v": 30}, + ], + pipeline=[ + {"$sort": {"v": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": 30}], + msg="$last should return highest value when sorted ascending", + ), + AccumulatorTestCase( + "sort_descending", + docs=[ + {"_id": 0, "v": 10}, + {"_id": 1, "v": 20}, + {"_id": 2, "v": 30}, + ], + pipeline=[ + {"$sort": {"v": -1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": 10}], + msg="$last should return lowest value when sorted descending", + ), + AccumulatorTestCase( + "sort_by_secondary_field", + docs=[ + {"_id": 0, "s": 1, "v": "a"}, + {"_id": 1, "s": 3, "v": "c"}, + {"_id": 2, "s": 2, "v": "b"}, + ], + pipeline=[ + {"$sort": {"s": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": "c"}], + msg="$last should return value from document with highest sort key", + ), + AccumulatorTestCase( + "sort_by_id", + docs=[ + {"_id": 3, "v": "third"}, + {"_id": 1, "v": "first"}, + {"_id": 2, "v": "second"}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": "third"}], + msg="$last should return value from document with highest _id when sorted by _id", + ), + AccumulatorTestCase( + "compound_sort", + docs=[ + {"_id": 0, "cat": "A", "val": 1, "v": "a1"}, + {"_id": 1, "cat": "A", "val": 2, "v": "a2"}, + {"_id": 2, "cat": "B", "val": 1, "v": "b1"}, + ], + pipeline=[ + {"$sort": {"cat": 1, "val": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": "b1"}], + msg="$last should return value from last document by compound sort order", + ), +] + +# Property [Special Numeric Passthrough]: $last passes through special numeric +# values (NaN, Infinity, negative zero) without transformation. +# NaN tests are separated because they require assertSuccessNaN for comparison. +LAST_NAN_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "nan_double", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": FLOAT_NAN}], + pipeline=_group_last("$v"), + expected=[{"result": FLOAT_NAN}], + msg="$last should return double NaN unchanged", + ), + AccumulatorTestCase( + "nan_decimal128", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Decimal128("NaN")}], + pipeline=_group_last("$v"), + expected=[{"result": Decimal128("NaN")}], + msg="$last should return Decimal128 NaN unchanged", + ), +] + +LAST_SPECIAL_NUMERIC_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "inf_double", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": FLOAT_INFINITY}], + pipeline=_group_last("$v"), + expected=[{"result": FLOAT_INFINITY}], + msg="$last should return double Infinity unchanged", + ), + AccumulatorTestCase( + "neg_inf_double", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": FLOAT_NEGATIVE_INFINITY}], + pipeline=_group_last("$v"), + expected=[{"result": FLOAT_NEGATIVE_INFINITY}], + msg="$last should return double -Infinity unchanged", + ), + AccumulatorTestCase( + "inf_decimal128", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Decimal128("Infinity")}], + pipeline=_group_last("$v"), + expected=[{"result": Decimal128("Infinity")}], + msg="$last should return Decimal128 Infinity unchanged", + ), + AccumulatorTestCase( + "neg_inf_decimal128", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Decimal128("-Infinity")}], + pipeline=_group_last("$v"), + expected=[{"result": Decimal128("-Infinity")}], + msg="$last should return Decimal128 -Infinity unchanged", + ), + AccumulatorTestCase( + "neg_zero_double", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": DOUBLE_NEGATIVE_ZERO}], + pipeline=_group_last("$v"), + expected=[{"result": DOUBLE_NEGATIVE_ZERO}], + msg="$last should preserve double -0.0 unchanged", + ), + AccumulatorTestCase( + "neg_zero_decimal128", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": DECIMAL128_NEGATIVE_ZERO}], + pipeline=_group_last("$v"), + expected=[{"result": DECIMAL128_NEGATIVE_ZERO}], + msg="$last should preserve Decimal128 -0 unchanged", + ), +] + +# Property [Numeric Boundary Passthrough]: $last passes through numeric +# boundary values without corruption. +LAST_BOUNDARY_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "boundary_int32_max", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": INT32_MAX}], + pipeline=_group_last("$v"), + expected=[{"result": INT32_MAX}], + msg="$last should return INT32_MAX unchanged", + ), + AccumulatorTestCase( + "boundary_int32_min", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": INT32_MIN}], + pipeline=_group_last("$v"), + expected=[{"result": INT32_MIN}], + msg="$last should return INT32_MIN unchanged", + ), + AccumulatorTestCase( + "boundary_int64_max", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": INT64_MAX}], + pipeline=_group_last("$v"), + expected=[{"result": INT64_MAX}], + msg="$last should return INT64_MAX unchanged", + ), + AccumulatorTestCase( + "boundary_int64_min", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": INT64_MIN}], + pipeline=_group_last("$v"), + expected=[{"result": INT64_MIN}], + msg="$last should return INT64_MIN unchanged", + ), + AccumulatorTestCase( + "boundary_double_max", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": DOUBLE_MAX}], + pipeline=_group_last("$v"), + expected=[{"result": DOUBLE_MAX}], + msg="$last should return DOUBLE_MAX unchanged", + ), + AccumulatorTestCase( + "boundary_double_min_subnormal", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": DOUBLE_MIN_SUBNORMAL}], + pipeline=_group_last("$v"), + expected=[{"result": DOUBLE_MIN_SUBNORMAL}], + msg="$last should return double min subnormal unchanged", + ), + AccumulatorTestCase( + "boundary_decimal128_max", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": DECIMAL128_MAX}], + pipeline=_group_last("$v"), + expected=[{"result": DECIMAL128_MAX}], + msg="$last should return DECIMAL128_MAX unchanged", + ), + AccumulatorTestCase( + "boundary_decimal128_min", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": DECIMAL128_MIN}], + pipeline=_group_last("$v"), + expected=[{"result": DECIMAL128_MIN}], + msg="$last should return DECIMAL128_MIN unchanged", + ), +] + +# Property [Array Passthrough]: in accumulator context, $last returns the +# entire array from the last document without traversal. +LAST_ARRAY_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "array_whole", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": [1, 2, 3]}], + pipeline=_group_last("$v"), + expected=[{"result": [1, 2, 3]}], + msg="$last should return entire array without traversal", + ), + AccumulatorTestCase( + "array_nested", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": [[1, 2], [3, 4]]}], + pipeline=_group_last("$v"), + expected=[{"result": [[1, 2], [3, 4]]}], + msg="$last should return nested array unchanged", + ), + AccumulatorTestCase( + "array_empty", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": []}], + pipeline=_group_last("$v"), + expected=[{"result": []}], + msg="$last should return empty array unchanged", + ), + AccumulatorTestCase( + "array_of_objects", + docs=[ + {"_id": 0, "v": 1}, + {"_id": 1, "v": [{"a": 1}, {"a": 2}]}, + ], + pipeline=_group_last("$v"), + expected=[{"result": [{"a": 1}, {"a": 2}]}], + msg="$last should return array of objects unchanged", + ), + AccumulatorTestCase( + "array_single_element", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": [42]}], + pipeline=_group_last("$v"), + expected=[{"result": [42]}], + msg="$last should return single-element array as array, not scalar", + ), + AccumulatorTestCase( + "array_mixed_types", + docs=[ + {"_id": 0, "v": 1}, + {"_id": 1, "v": [1, "two", None, True]}, + ], + pipeline=_group_last("$v"), + expected=[{"result": [1, "two", None, True]}], + msg="$last should return mixed-type array unchanged", + ), +] + +# Property [Expression Arguments]: $last accepts various expression types +# beyond simple field paths. +LAST_EXPRESSION_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "expr_field_path", + docs=[{"_id": 0, "v": 10}, {"_id": 1, "v": 20}], + pipeline=_group_last("$v"), + expected=[{"result": 20}], + msg="$last should accept field path expression", + ), + AccumulatorTestCase( + "expr_nested_field", + docs=[ + {"_id": 0, "a": {"b": 10}}, + {"_id": 1, "a": {"b": 20}}, + ], + pipeline=_group_last("$a.b"), + expected=[{"result": 20}], + msg="$last should accept nested field path", + ), + AccumulatorTestCase( + "expr_deep_nested", + docs=[ + {"_id": 0, "a": {"b": {"c": 10}}}, + {"_id": 1, "a": {"b": {"c": 20}}}, + ], + pipeline=_group_last("$a.b.c"), + expected=[{"result": 20}], + msg="$last should accept deeply nested field path", + ), + AccumulatorTestCase( + "expr_literal", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 2}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": {"$literal": 99}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": 99}], + msg="$last should accept literal expression", + ), + AccumulatorTestCase( + "expr_computed", + docs=[ + {"_id": 0, "a": 2, "b": 3}, + {"_id": 1, "a": 4, "b": 5}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": {"$multiply": ["$a", "$b"]}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": 20}], + msg="$last should accept computed sub-expression", + ), + AccumulatorTestCase( + "expr_conditional", + docs=[ + {"_id": 0, "v": -5}, + {"_id": 1, "v": 10}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + { + "$group": { + "_id": None, + "result": {"$last": {"$cond": [{"$gte": ["$v", 0]}, "$v", 0]}}, + } + }, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": 10}], + msg="$last should accept conditional expression", + ), + AccumulatorTestCase( + "expr_constant_value", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 2}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": 42}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": 42}], + msg="$last should accept constant literal value", + ), + AccumulatorTestCase( + "expr_missing_nested", + docs=[ + {"_id": 0, "a": {"b": 10}}, + {"_id": 1, "a": {}}, + ], + pipeline=_group_last("$a.b"), + expected=[{"result": None}], + msg="$last should return null when nested field is missing in last document", + ), +] + +# Property [Mixed BSON Types in Group]: $last does not perform any type +# checking and returns whatever type the last document has. +LAST_MIXED_TYPE_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "mixed_types_last_wins", + docs=[ + {"_id": 0, "v": 1}, + {"_id": 1, "v": "hello"}, + {"_id": 2, "v": True}, + ], + pipeline=_group_last("$v"), + expected=[{"result": True}], + msg="$last should return last value regardless of mixed types in group", + ), +] + +LAST_SUCCESS_TESTS = ( + LAST_BSON_TYPE_TESTS + + LAST_NULL_MISSING_TESTS + + LAST_SORT_ORDER_TESTS + + LAST_SPECIAL_NUMERIC_TESTS + + LAST_BOUNDARY_TESTS + + LAST_ARRAY_TESTS + + LAST_EXPRESSION_TESTS + + LAST_MIXED_TYPE_TESTS +) + + +def _run(collection, test_case: AccumulatorTestCase): + """Insert docs and execute the pipeline.""" + if test_case.docs: + collection.insert_many(test_case.docs) + return execute_command( + collection, + {"aggregate": collection.name, "pipeline": test_case.pipeline, "cursor": {}}, + ) + + +@pytest.mark.parametrize("test_case", pytest_params(LAST_SUCCESS_TESTS)) +def test_accumulator_last(collection, test_case: AccumulatorTestCase): + """Test $last accumulator success cases.""" + result = _run(collection, test_case) + assertSuccess(result, test_case.expected, msg=test_case.msg) + + +@pytest.mark.parametrize("test_case", pytest_params(LAST_NAN_TESTS)) +def test_accumulator_last_nan(collection, test_case: AccumulatorTestCase): + """Test $last accumulator NaN passthrough (requires NaN-aware comparison).""" + result = _run(collection, test_case) + assertSuccessNaN(result, test_case.expected, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_errors.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_errors.py new file mode 100644 index 00000000..116fdac0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_errors.py @@ -0,0 +1,174 @@ +"""Tests for $last accumulator: arity rejection and expression 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, + DIVIDE_BY_ZERO_V2_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. +LAST_SYNTAX_VALIDATION_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "syntax_bare_dollar", + docs=[{"v": 1}], + pipeline=[{"$group": {"_id": None, "result": {"$last": "$"}}}], + error_code=INVALID_DOLLAR_FIELD_PATH, + msg="$last should reject '$' as an invalid FieldPath", + ), +] + +# Property [Arity Rejection]: $last rejects array syntax in accumulator +# context ($group, $bucket, $bucketAuto), and multi-key expression objects +# produce an expression parsing error. +LAST_ARITY_ERROR_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "arity_empty_array_group", + docs=[{"v": 1}], + pipeline=[{"$group": {"_id": None, "result": {"$last": []}}}], + error_code=GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, + msg="$last should reject empty array in $group", + ), + AccumulatorTestCase( + "arity_single_element_array", + docs=[{"v": 1}], + pipeline=[{"$group": {"_id": None, "result": {"$last": [1]}}}], + error_code=GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, + msg="$last should reject single-element array in accumulator context", + ), + AccumulatorTestCase( + "arity_single_field_ref_array", + docs=[{"v": 1}], + pipeline=[{"$group": {"_id": None, "result": {"$last": ["$v"]}}}], + error_code=GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, + msg="$last should reject single field ref in array in accumulator context", + ), + AccumulatorTestCase( + "arity_multi_element_array_group", + docs=[{"v": 1}], + pipeline=[{"$group": {"_id": None, "result": {"$last": [1, 2, 3]}}}], + error_code=GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, + msg="$last should reject multi-element array in $group", + ), + AccumulatorTestCase( + "arity_multi_key_expression_object", + docs=[{"v": 1}], + pipeline=[ + { + "$group": { + "_id": None, + "result": {"$last": {"$add": [1, 2], "$multiply": [3, 4]}}, + } + } + ], + error_code=EXPRESSION_OBJECT_MULTIPLE_FIELDS_ERROR, + msg="$last should reject multi-key expression object", + ), + AccumulatorTestCase( + "arity_empty_array_bucket", + docs=[{"v": 1}], + pipeline=[ + { + "$bucket": { + "groupBy": "$v", + "boundaries": [0, 10], + "output": {"result": {"$last": []}}, + } + } + ], + error_code=GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, + msg="$last should reject empty array in $bucket", + ), + AccumulatorTestCase( + "arity_multi_element_array_bucket", + docs=[{"v": 1}], + pipeline=[ + { + "$bucket": { + "groupBy": "$v", + "boundaries": [0, 10], + "output": {"result": {"$last": [1, 2, 3]}}, + } + } + ], + error_code=GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, + msg="$last should reject multi-element array in $bucket", + ), + AccumulatorTestCase( + "arity_empty_array_bucket_auto", + docs=[{"v": 1}], + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$v", + "buckets": 1, + "output": {"result": {"$last": []}}, + } + } + ], + error_code=GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, + msg="$last should reject empty array in $bucketAuto", + ), + AccumulatorTestCase( + "arity_multi_element_array_bucket_auto", + docs=[{"v": 1}], + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$v", + "buckets": 1, + "output": {"result": {"$last": [1, 2, 3]}}, + } + } + ], + error_code=GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, + msg="$last should reject multi-element array in $bucketAuto", + ), +] + +# Property [Expression Error Propagation]: when the accumulator expression +# errors for any document in the group, the error propagates to the caller. +LAST_EXPRESSION_ERROR_PROPAGATION_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "expr_error_to_int_invalid_string", + docs=[{"v": "abc"}], + pipeline=[{"$group": {"_id": None, "result": {"$last": {"$toInt": "$v"}}}}], + error_code=CONVERSION_FAILURE_ERROR, + msg="$last should propagate $toInt conversion error from expression", + ), + AccumulatorTestCase( + "expr_error_divide_by_zero", + docs=[{"v": 1}], + pipeline=[{"$group": {"_id": None, "result": {"$last": {"$divide": ["$v", 0]}}}}], + error_code=DIVIDE_BY_ZERO_V2_ERROR, + msg="$last should propagate $divide by zero error", + ), +] + +LAST_ERROR_TESTS = ( + LAST_SYNTAX_VALIDATION_TESTS + LAST_ARITY_ERROR_TESTS + LAST_EXPRESSION_ERROR_PROPAGATION_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(LAST_ERROR_TESTS)) +def test_last_errors(collection, test_case: AccumulatorTestCase): + """Test $last error cases.""" + if test_case.docs: + collection.insert_many(test_case.docs) + result = execute_command( + collection, + {"aggregate": collection.name, "pipeline": test_case.pipeline or [], "cursor": {}}, + ) + assertFailureCode(result, test_case.error_code, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_group_context.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_group_context.py new file mode 100644 index 00000000..3f4058c6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_group_context.py @@ -0,0 +1,406 @@ +"""Tests for $last accumulator in $group context. + +Covers empty collection, single document, multiple groups, large groups, +pipeline interactions, compound _id, multiple accumulators, and stage contexts +($bucket, $bucketAuto). +""" + +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 [Empty Collection]: empty collection produces no group output. +LAST_EMPTY_COLLECTION_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "empty_collection", + docs=None, + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + ], + expected=[], + msg="$last on empty collection should produce no output", + ), + AccumulatorTestCase( + "all_filtered_out", + docs=[ + {"_id": 0, "cat": "A", "v": 10}, + {"_id": 1, "cat": "A", "v": 20}, + ], + pipeline=[ + {"$match": {"cat": "Z"}}, + {"$group": {"_id": "$cat", "result": {"$last": "$v"}}}, + ], + expected=[], + msg="$last should produce no output when all documents are filtered out", + ), +] + +# Property [Single Document]: $last on a single-document group returns that +# document's value. +LAST_SINGLE_DOCUMENT_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "single_document", + docs=[{"_id": 0, "v": 42}], + pipeline=[ + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": 42}], + msg="$last should return value from single document", + ), + AccumulatorTestCase( + "single_per_group", + docs=[ + {"_id": 0, "cat": "A", "v": 10}, + {"_id": 1, "cat": "B", "v": 20}, + {"_id": 2, "cat": "C", "v": 30}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": "$cat", "result": {"$last": "$v"}}}, + {"$sort": {"_id": 1}}, + {"$project": {"result": 1}}, + ], + expected=[ + {"_id": "A", "result": 10}, + {"_id": "B", "result": 20}, + {"_id": "C", "result": 30}, + ], + msg="$last should return correct value when each group has one document", + ), +] + +# Property [Multiple Groups]: $last computes correct last value per group +# independently. +LAST_MULTIPLE_GROUPS_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "different_group_sizes", + docs=[ + {"_id": 0, "cat": "A", "v": 1}, + {"_id": 1, "cat": "A", "v": 2}, + {"_id": 2, "cat": "A", "v": 3}, + {"_id": 3, "cat": "B", "v": 10}, + {"_id": 4, "cat": "B", "v": 20}, + {"_id": 5, "cat": "C", "v": 100}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": "$cat", "result": {"$last": "$v"}}}, + {"$sort": {"_id": 1}}, + ], + expected=[ + {"_id": "A", "result": 3}, + {"_id": "B", "result": 20}, + {"_id": "C", "result": 100}, + ], + msg="$last should return correct last value for groups of different sizes", + ), + AccumulatorTestCase( + "different_types_per_group", + docs=[ + {"_id": 0, "cat": "int", "v": 10}, + {"_id": 1, "cat": "int", "v": 20}, + {"_id": 2, "cat": "str", "v": "hello"}, + {"_id": 3, "cat": "str", "v": "world"}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": "$cat", "result": {"$last": "$v"}}}, + {"$sort": {"_id": 1}}, + ], + expected=[ + {"_id": "int", "result": 20}, + {"_id": "str", "result": "world"}, + ], + msg="$last should return correct type per group", + ), + AccumulatorTestCase( + "mixed_null_groups", + docs=[ + {"_id": 0, "cat": "A", "v": None}, + {"_id": 1, "cat": "A", "v": None}, + {"_id": 2, "cat": "B", "v": 10}, + {"_id": 3, "cat": "B", "v": 20}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": "$cat", "result": {"$last": "$v"}}}, + {"$sort": {"_id": 1}}, + ], + expected=[ + {"_id": "A", "result": None}, + {"_id": "B", "result": 20}, + ], + msg="$last should return null for all-null group and value for numeric group", + ), +] + +# Property [Large Group]: $last returns the last value from a large group. +LAST_LARGE_GROUP_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "large_group_1000", + docs=[{"_id": i, "v": i} for i in range(1000)], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": 999}], + msg="$last should return the value from the 1000th document", + ), +] + +# Property [Multiple Accumulators]: $last works correctly alongside other +# accumulators in the same $group stage. +LAST_MULTIPLE_ACCUMULATORS_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "last_and_first", + docs=[ + {"_id": 0, "v": 10}, + {"_id": 1, "v": 20}, + {"_id": 2, "v": 30}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + { + "$group": { + "_id": None, + "last": {"$last": "$v"}, + "first": {"$first": "$v"}, + } + }, + {"$project": {"_id": 0, "last": 1, "first": 1}}, + ], + expected=[{"last": 30, "first": 10}], + msg="$last and $first should return different values on same field", + ), + AccumulatorTestCase( + "last_on_two_fields", + docs=[ + {"_id": 0, "a": 1, "b": "x"}, + {"_id": 1, "a": 2, "b": "y"}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + { + "$group": { + "_id": None, + "lastA": {"$last": "$a"}, + "lastB": {"$last": "$b"}, + } + }, + {"$project": {"_id": 0, "lastA": 1, "lastB": 1}}, + ], + expected=[{"lastA": 2, "lastB": "y"}], + msg="$last should return correct values for multiple fields", + ), + AccumulatorTestCase( + "last_and_sum", + docs=[ + {"_id": 0, "v": 10}, + {"_id": 1, "v": 20}, + {"_id": 2, "v": 30}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + { + "$group": { + "_id": None, + "last": {"$last": "$v"}, + "total": {"$sum": "$v"}, + } + }, + {"$project": {"_id": 0, "last": 1, "total": 1}}, + ], + expected=[{"last": 30, "total": 60}], + msg="$last passthrough and $sum computation should coexist", + ), +] + +# Property [Compound Group ID]: $last works with compound and expression-based +# group _id. +LAST_COMPOUND_ID_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "compound_id", + docs=[ + {"_id": 0, "cat": "A", "sub": "x", "v": 1}, + {"_id": 1, "cat": "A", "sub": "x", "v": 2}, + {"_id": 2, "cat": "B", "sub": "y", "v": 10}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + { + "$group": { + "_id": {"cat": "$cat", "sub": "$sub"}, + "result": {"$last": "$v"}, + } + }, + {"$sort": {"_id": 1}}, + ], + expected=[ + {"_id": {"cat": "A", "sub": "x"}, "result": 2}, + {"_id": {"cat": "B", "sub": "y"}, "result": 10}, + ], + msg="$last should work with compound group _id", + ), + AccumulatorTestCase( + "expression_id", + docs=[ + {"_id": 0, "cat": "hello", "v": 1}, + {"_id": 1, "cat": "hello", "v": 2}, + {"_id": 2, "cat": "world", "v": 10}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + { + "$group": { + "_id": {"$toUpper": "$cat"}, + "result": {"$last": "$v"}, + } + }, + {"$sort": {"_id": 1}}, + ], + expected=[ + {"_id": "HELLO", "result": 2}, + {"_id": "WORLD", "result": 10}, + ], + msg="$last should work with expression-based group _id", + ), +] + +# Property [Pipeline Interactions]: $last works correctly when preceded by +# various pipeline stages. +LAST_PIPELINE_INTERACTION_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "after_match", + docs=[ + {"_id": 0, "cat": "A", "v": 10}, + {"_id": 1, "cat": "B", "v": 20}, + {"_id": 2, "cat": "A", "v": 30}, + ], + pipeline=[ + {"$match": {"cat": "A"}}, + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": 30}], + msg="$last should work after $match filtering", + ), + AccumulatorTestCase( + "after_project", + docs=[ + {"_id": 0, "x": 10}, + {"_id": 1, "x": 20}, + ], + pipeline=[ + {"$project": {"_id": 1, "v": "$x"}}, + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": 20}], + msg="$last should work after $project field rename", + ), + AccumulatorTestCase( + "after_unwind", + docs=[ + {"_id": 0, "tags": ["a", "b"]}, + {"_id": 1, "tags": ["c"]}, + ], + pipeline=[ + {"$unwind": "$tags"}, + {"$sort": {"_id": 1, "tags": 1}}, + {"$group": {"_id": None, "result": {"$last": "$tags"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": "c"}], + msg="$last should work after $unwind", + ), +] + +# Property [Bucket Smoke]: $last works correctly in $bucket context. +LAST_BUCKET_SMOKE_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "bucket_basic", + docs=[ + {"_id": 0, "v": 10}, + {"_id": 1, "v": 20}, + {"_id": 2, "v": 30}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + { + "$bucket": { + "groupBy": {"$literal": 0}, + "boundaries": [-1, 1], + "output": {"result": {"$last": "$v"}}, + } + }, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": 30}], + msg="$last should return last value in $bucket context", + ), +] + +# Property [BucketAuto Smoke]: $last works correctly in $bucketAuto context. +LAST_BUCKET_AUTO_SMOKE_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "bucket_auto_basic", + docs=[ + {"_id": 0, "v": 10}, + {"_id": 1, "v": 20}, + {"_id": 2, "v": 30}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + { + "$bucketAuto": { + "groupBy": {"$literal": 0}, + "buckets": 1, + "output": {"result": {"$last": "$v"}}, + } + }, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": 30}], + msg="$last should return last value in $bucketAuto context", + ), +] + +LAST_GROUP_CONTEXT_TESTS: list[AccumulatorTestCase] = ( + LAST_EMPTY_COLLECTION_TESTS + + LAST_SINGLE_DOCUMENT_TESTS + + LAST_MULTIPLE_GROUPS_TESTS + + LAST_LARGE_GROUP_TESTS + + LAST_MULTIPLE_ACCUMULATORS_TESTS + + LAST_COMPOUND_ID_TESTS + + LAST_PIPELINE_INTERACTION_TESTS + + LAST_BUCKET_SMOKE_TESTS + + LAST_BUCKET_AUTO_SMOKE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(LAST_GROUP_CONTEXT_TESTS)) +def test_last_group_context(collection, test_case: AccumulatorTestCase): + """Test $last in group context with grouping behavior.""" + if test_case.docs: + collection.insert_many(test_case.docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult(result, expected=test_case.expected, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_group_types.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_group_types.py new file mode 100644 index 00000000..995d0df7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_group_types.py @@ -0,0 +1,175 @@ +"""Tests for $last accumulator: return type verification via $type projection. + +Verifies that $last preserves the exact BSON type of the last document's value +without any coercion (unlike numeric accumulators which promote types). +""" + +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 assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + + +def _group_last_with_type(): + """Build a $group pipeline for $last with $type projection.""" + return [ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "value": "$result", "type": {"$type": "$result"}}}, + ] + + +# Property [Return Type Preservation]: $last preserves the BSON type of the +# last value, verified via $type projection. +LAST_RETURN_TYPE_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "type_int32", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 42}], + pipeline=_group_last_with_type(), + expected=[{"value": 42, "type": "int"}], + msg="$last should preserve int32 type", + ), + AccumulatorTestCase( + "type_int64", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Int64(42)}], + pipeline=_group_last_with_type(), + expected=[{"value": Int64(42), "type": "long"}], + msg="$last should preserve int64 type", + ), + AccumulatorTestCase( + "type_double", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 3.14}], + pipeline=_group_last_with_type(), + expected=[{"value": 3.14, "type": "double"}], + msg="$last should preserve double type", + ), + AccumulatorTestCase( + "type_decimal128", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Decimal128("3.14")}], + pipeline=_group_last_with_type(), + expected=[{"value": Decimal128("3.14"), "type": "decimal"}], + msg="$last should preserve Decimal128 type", + ), + AccumulatorTestCase( + "type_string", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": "hello"}], + pipeline=_group_last_with_type(), + expected=[{"value": "hello", "type": "string"}], + msg="$last should preserve string type", + ), + AccumulatorTestCase( + "type_bool", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": True}], + pipeline=_group_last_with_type(), + expected=[{"value": True, "type": "bool"}], + msg="$last should preserve bool type", + ), + AccumulatorTestCase( + "type_date", + docs=[ + {"_id": 0, "v": 1}, + {"_id": 1, "v": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + ], + pipeline=_group_last_with_type(), + expected=[{"value": datetime(2024, 1, 1, tzinfo=timezone.utc), "type": "date"}], + msg="$last should preserve date type", + ), + AccumulatorTestCase( + "type_null", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": None}], + pipeline=_group_last_with_type(), + expected=[{"value": None, "type": "null"}], + msg="$last should preserve null type", + ), + AccumulatorTestCase( + "type_object", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": {"a": 1}}], + pipeline=_group_last_with_type(), + expected=[{"value": {"a": 1}, "type": "object"}], + msg="$last should preserve object type", + ), + AccumulatorTestCase( + "type_array", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": [1, 2]}], + pipeline=_group_last_with_type(), + expected=[{"value": [1, 2], "type": "array"}], + msg="$last should preserve array type", + ), + AccumulatorTestCase( + "type_binary", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Binary(b"\x01")}], + pipeline=_group_last_with_type(), + expected=[{"value": b"\x01", "type": "binData"}], + msg="$last should preserve Binary type", + ), + AccumulatorTestCase( + "type_objectid", + docs=[ + {"_id": 0, "v": 1}, + {"_id": 1, "v": ObjectId("507f1f77bcf86cd799439011")}, + ], + pipeline=_group_last_with_type(), + expected=[{"value": ObjectId("507f1f77bcf86cd799439011"), "type": "objectId"}], + msg="$last should preserve ObjectId type", + ), + AccumulatorTestCase( + "type_regex", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Regex("abc", "i")}], + pipeline=_group_last_with_type(), + expected=[{"value": Regex("abc", "i"), "type": "regex"}], + msg="$last should preserve Regex type", + ), + AccumulatorTestCase( + "type_code", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Code("function(){}")}], + pipeline=_group_last_with_type(), + expected=[{"value": "function(){}", "type": "string"}], + msg="$last should return Code as string via runCommand", + ), + AccumulatorTestCase( + "type_timestamp", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Timestamp(1, 1)}], + pipeline=_group_last_with_type(), + expected=[{"value": Timestamp(1, 1), "type": "timestamp"}], + msg="$last should preserve Timestamp type", + ), + AccumulatorTestCase( + "type_minkey", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": MinKey()}], + pipeline=_group_last_with_type(), + expected=[{"value": {"": MinKey()}, "type": "object"}], + msg="$last should return MinKey wrapped as object via runCommand", + ), + AccumulatorTestCase( + "type_maxkey", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": MaxKey()}], + pipeline=_group_last_with_type(), + expected=[{"value": {"": MaxKey()}, "type": "object"}], + msg="$last should return MaxKey wrapped as object via runCommand", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(LAST_RETURN_TYPE_TESTS)) +def test_last_group_types(collection, test_case: AccumulatorTestCase): + """Test $last return type preservation via $type projection.""" + if test_case.docs: + collection.insert_many(test_case.docs) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult(result, expected=test_case.expected, msg=test_case.msg) From 5a1ead51c0d9b5a5ee47d4cb2a86416401f1fde0 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" Date: Tue, 19 May 2026 13:04:36 -0700 Subject: [PATCH 02/14] Follow style_guide.md Signed-off-by: Alina (Xi) Li --- .../last/test_accumulator_last.py | 26 +++---------------- .../accumulators/last/test_last_errors.py | 13 +++++++--- .../last/test_last_group_context.py | 19 +++++++------- .../last/test_last_group_types.py | 19 +++++++------- 4 files changed, 33 insertions(+), 44 deletions(-) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last.py index ec712cd0..f1a2a4da 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last.py @@ -2,6 +2,7 @@ from __future__ import annotations +import math from datetime import datetime, timezone import pytest @@ -20,7 +21,7 @@ from documentdb_tests.compatibility.tests.core.operator.accumulators.utils.accumulator_test_case import ( # noqa: E501 AccumulatorTestCase, ) -from documentdb_tests.framework.assertions import assertSuccess, assertSuccessNaN +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 ( @@ -39,10 +40,6 @@ INT64_MIN, ) -# --------------------------------------------------------------------------- -# Pipeline helpers -# --------------------------------------------------------------------------- - def _group_last(acc): """Build a $sort + $group pipeline for $last.""" @@ -53,10 +50,6 @@ def _group_last(acc): ] -# --------------------------------------------------------------------------- -# Property lists -# --------------------------------------------------------------------------- - # Property [BSON Type Passthrough]: $last returns the last value in a group # unchanged, preserving its exact BSON type without coercion. LAST_BSON_TYPE_TESTS: list[AccumulatorTestCase] = [ @@ -341,13 +334,12 @@ def _group_last(acc): # Property [Special Numeric Passthrough]: $last passes through special numeric # values (NaN, Infinity, negative zero) without transformation. -# NaN tests are separated because they require assertSuccessNaN for comparison. -LAST_NAN_TESTS: list[AccumulatorTestCase] = [ +LAST_SPECIAL_NUMERIC_TESTS: list[AccumulatorTestCase] = [ AccumulatorTestCase( "nan_double", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": FLOAT_NAN}], pipeline=_group_last("$v"), - expected=[{"result": FLOAT_NAN}], + expected=[{"result": pytest.approx(math.nan, nan_ok=True)}], msg="$last should return double NaN unchanged", ), AccumulatorTestCase( @@ -357,9 +349,6 @@ def _group_last(acc): expected=[{"result": Decimal128("NaN")}], msg="$last should return Decimal128 NaN unchanged", ), -] - -LAST_SPECIAL_NUMERIC_TESTS: list[AccumulatorTestCase] = [ AccumulatorTestCase( "inf_double", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": FLOAT_INFINITY}], @@ -658,10 +647,3 @@ def test_accumulator_last(collection, test_case: AccumulatorTestCase): """Test $last accumulator success cases.""" result = _run(collection, test_case) assertSuccess(result, test_case.expected, msg=test_case.msg) - - -@pytest.mark.parametrize("test_case", pytest_params(LAST_NAN_TESTS)) -def test_accumulator_last_nan(collection, test_case: AccumulatorTestCase): - """Test $last accumulator NaN passthrough (requires NaN-aware comparison).""" - result = _run(collection, test_case) - assertSuccessNaN(result, test_case.expected, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_errors.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_errors.py index 116fdac0..141ac59c 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_errors.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_errors.py @@ -162,13 +162,18 @@ ) -@pytest.mark.parametrize("test_case", pytest_params(LAST_ERROR_TESTS)) -def test_last_errors(collection, test_case: AccumulatorTestCase): - """Test $last error cases.""" +def _run(collection, test_case: AccumulatorTestCase): + """Insert docs and execute the pipeline.""" if test_case.docs: collection.insert_many(test_case.docs) - result = execute_command( + return execute_command( collection, {"aggregate": collection.name, "pipeline": test_case.pipeline or [], "cursor": {}}, ) + + +@pytest.mark.parametrize("test_case", pytest_params(LAST_ERROR_TESTS)) +def test_last_errors(collection, test_case: AccumulatorTestCase): + """Test $last error cases.""" + result = _run(collection, test_case) assertFailureCode(result, test_case.error_code, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_group_context.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_group_context.py index 3f4058c6..e9f6931f 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_group_context.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_group_context.py @@ -390,17 +390,18 @@ ) -@pytest.mark.parametrize("test_case", pytest_params(LAST_GROUP_CONTEXT_TESTS)) -def test_last_group_context(collection, test_case: AccumulatorTestCase): - """Test $last in group context with grouping behavior.""" +def _run(collection, test_case: AccumulatorTestCase): + """Insert docs and execute the pipeline.""" if test_case.docs: collection.insert_many(test_case.docs) - result = execute_command( + return execute_command( collection, - { - "aggregate": collection.name, - "pipeline": test_case.pipeline, - "cursor": {}, - }, + {"aggregate": collection.name, "pipeline": test_case.pipeline, "cursor": {}}, ) + + +@pytest.mark.parametrize("test_case", pytest_params(LAST_GROUP_CONTEXT_TESTS)) +def test_last_group_context(collection, test_case: AccumulatorTestCase): + """Test $last in group context with grouping behavior.""" + result = _run(collection, test_case) assertResult(result, expected=test_case.expected, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_group_types.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_group_types.py index 995d0df7..120db177 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_group_types.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_group_types.py @@ -159,17 +159,18 @@ def _group_last_with_type(): ] -@pytest.mark.parametrize("test_case", pytest_params(LAST_RETURN_TYPE_TESTS)) -def test_last_group_types(collection, test_case: AccumulatorTestCase): - """Test $last return type preservation via $type projection.""" +def _run(collection, test_case: AccumulatorTestCase): + """Insert docs and execute the pipeline.""" if test_case.docs: collection.insert_many(test_case.docs) - result = execute_command( + return execute_command( collection, - { - "aggregate": collection.name, - "pipeline": test_case.pipeline, - "cursor": {}, - }, + {"aggregate": collection.name, "pipeline": test_case.pipeline, "cursor": {}}, ) + + +@pytest.mark.parametrize("test_case", pytest_params(LAST_RETURN_TYPE_TESTS)) +def test_last_group_types(collection, test_case: AccumulatorTestCase): + """Test $last return type preservation via $type projection.""" + result = _run(collection, test_case) assertResult(result, expected=test_case.expected, msg=test_case.msg) From f8ecd72f25d2e0d767a3d04eada9294f95238519 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" Date: Fri, 22 May 2026 14:35:20 -0700 Subject: [PATCH 03/14] Fix mypy issue Signed-off-by: Alina (Xi) Li --- .../tests/core/operator/accumulators/last/test_last_errors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_errors.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_errors.py index 141ac59c..24008a2e 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_errors.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_errors.py @@ -173,7 +173,7 @@ def _run(collection, test_case: AccumulatorTestCase): @pytest.mark.parametrize("test_case", pytest_params(LAST_ERROR_TESTS)) -def test_last_errors(collection, test_case: AccumulatorTestCase): +def test_last_errors(collection, test_case): """Test $last error cases.""" result = _run(collection, test_case) assertFailureCode(result, test_case.error_code, msg=test_case.msg) From eab0ae6aef8e30ef4284002c306e6426185b3430 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" Date: Fri, 22 May 2026 15:19:54 -0700 Subject: [PATCH 04/14] init.py Signed-off-by: Alina (Xi) Li --- .../tests/core/operator/accumulators/last/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/core/operator/accumulators/last/__init__.py diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/__init__.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/__init__.py new file mode 100644 index 00000000..e69de29b From 54a6ffb50661296f5bc79b45f2d7bb4ba5dee9a0 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" Date: Fri, 22 May 2026 15:23:42 -0700 Subject: [PATCH 05/14] remove out of scope tests Signed-off-by: Alina (Xi) Li --- ...y => test_accumulator_last_group_types.py} | 0 .../accumulators/last/test_last_errors.py | 179 -------- .../last/test_last_group_context.py | 407 ------------------ 3 files changed, 586 deletions(-) rename documentdb_tests/compatibility/tests/core/operator/accumulators/last/{test_last_group_types.py => test_accumulator_last_group_types.py} (100%) delete mode 100644 documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_errors.py delete mode 100644 documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_group_context.py diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_group_types.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_group_types.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_group_types.py rename to documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_group_types.py diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_errors.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_errors.py deleted file mode 100644 index 24008a2e..00000000 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_errors.py +++ /dev/null @@ -1,179 +0,0 @@ -"""Tests for $last accumulator: arity rejection and expression 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, - DIVIDE_BY_ZERO_V2_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. -LAST_SYNTAX_VALIDATION_TESTS: list[AccumulatorTestCase] = [ - AccumulatorTestCase( - "syntax_bare_dollar", - docs=[{"v": 1}], - pipeline=[{"$group": {"_id": None, "result": {"$last": "$"}}}], - error_code=INVALID_DOLLAR_FIELD_PATH, - msg="$last should reject '$' as an invalid FieldPath", - ), -] - -# Property [Arity Rejection]: $last rejects array syntax in accumulator -# context ($group, $bucket, $bucketAuto), and multi-key expression objects -# produce an expression parsing error. -LAST_ARITY_ERROR_TESTS: list[AccumulatorTestCase] = [ - AccumulatorTestCase( - "arity_empty_array_group", - docs=[{"v": 1}], - pipeline=[{"$group": {"_id": None, "result": {"$last": []}}}], - error_code=GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, - msg="$last should reject empty array in $group", - ), - AccumulatorTestCase( - "arity_single_element_array", - docs=[{"v": 1}], - pipeline=[{"$group": {"_id": None, "result": {"$last": [1]}}}], - error_code=GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, - msg="$last should reject single-element array in accumulator context", - ), - AccumulatorTestCase( - "arity_single_field_ref_array", - docs=[{"v": 1}], - pipeline=[{"$group": {"_id": None, "result": {"$last": ["$v"]}}}], - error_code=GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, - msg="$last should reject single field ref in array in accumulator context", - ), - AccumulatorTestCase( - "arity_multi_element_array_group", - docs=[{"v": 1}], - pipeline=[{"$group": {"_id": None, "result": {"$last": [1, 2, 3]}}}], - error_code=GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, - msg="$last should reject multi-element array in $group", - ), - AccumulatorTestCase( - "arity_multi_key_expression_object", - docs=[{"v": 1}], - pipeline=[ - { - "$group": { - "_id": None, - "result": {"$last": {"$add": [1, 2], "$multiply": [3, 4]}}, - } - } - ], - error_code=EXPRESSION_OBJECT_MULTIPLE_FIELDS_ERROR, - msg="$last should reject multi-key expression object", - ), - AccumulatorTestCase( - "arity_empty_array_bucket", - docs=[{"v": 1}], - pipeline=[ - { - "$bucket": { - "groupBy": "$v", - "boundaries": [0, 10], - "output": {"result": {"$last": []}}, - } - } - ], - error_code=GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, - msg="$last should reject empty array in $bucket", - ), - AccumulatorTestCase( - "arity_multi_element_array_bucket", - docs=[{"v": 1}], - pipeline=[ - { - "$bucket": { - "groupBy": "$v", - "boundaries": [0, 10], - "output": {"result": {"$last": [1, 2, 3]}}, - } - } - ], - error_code=GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, - msg="$last should reject multi-element array in $bucket", - ), - AccumulatorTestCase( - "arity_empty_array_bucket_auto", - docs=[{"v": 1}], - pipeline=[ - { - "$bucketAuto": { - "groupBy": "$v", - "buckets": 1, - "output": {"result": {"$last": []}}, - } - } - ], - error_code=GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, - msg="$last should reject empty array in $bucketAuto", - ), - AccumulatorTestCase( - "arity_multi_element_array_bucket_auto", - docs=[{"v": 1}], - pipeline=[ - { - "$bucketAuto": { - "groupBy": "$v", - "buckets": 1, - "output": {"result": {"$last": [1, 2, 3]}}, - } - } - ], - error_code=GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, - msg="$last should reject multi-element array in $bucketAuto", - ), -] - -# Property [Expression Error Propagation]: when the accumulator expression -# errors for any document in the group, the error propagates to the caller. -LAST_EXPRESSION_ERROR_PROPAGATION_TESTS: list[AccumulatorTestCase] = [ - AccumulatorTestCase( - "expr_error_to_int_invalid_string", - docs=[{"v": "abc"}], - pipeline=[{"$group": {"_id": None, "result": {"$last": {"$toInt": "$v"}}}}], - error_code=CONVERSION_FAILURE_ERROR, - msg="$last should propagate $toInt conversion error from expression", - ), - AccumulatorTestCase( - "expr_error_divide_by_zero", - docs=[{"v": 1}], - pipeline=[{"$group": {"_id": None, "result": {"$last": {"$divide": ["$v", 0]}}}}], - error_code=DIVIDE_BY_ZERO_V2_ERROR, - msg="$last should propagate $divide by zero error", - ), -] - -LAST_ERROR_TESTS = ( - LAST_SYNTAX_VALIDATION_TESTS + LAST_ARITY_ERROR_TESTS + LAST_EXPRESSION_ERROR_PROPAGATION_TESTS -) - - -def _run(collection, test_case: AccumulatorTestCase): - """Insert docs and execute the pipeline.""" - if test_case.docs: - collection.insert_many(test_case.docs) - return execute_command( - collection, - {"aggregate": collection.name, "pipeline": test_case.pipeline or [], "cursor": {}}, - ) - - -@pytest.mark.parametrize("test_case", pytest_params(LAST_ERROR_TESTS)) -def test_last_errors(collection, test_case): - """Test $last error cases.""" - result = _run(collection, test_case) - assertFailureCode(result, test_case.error_code, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_group_context.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_group_context.py deleted file mode 100644 index e9f6931f..00000000 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_last_group_context.py +++ /dev/null @@ -1,407 +0,0 @@ -"""Tests for $last accumulator in $group context. - -Covers empty collection, single document, multiple groups, large groups, -pipeline interactions, compound _id, multiple accumulators, and stage contexts -($bucket, $bucketAuto). -""" - -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 [Empty Collection]: empty collection produces no group output. -LAST_EMPTY_COLLECTION_TESTS: list[AccumulatorTestCase] = [ - AccumulatorTestCase( - "empty_collection", - docs=None, - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$last": "$v"}}}, - ], - expected=[], - msg="$last on empty collection should produce no output", - ), - AccumulatorTestCase( - "all_filtered_out", - docs=[ - {"_id": 0, "cat": "A", "v": 10}, - {"_id": 1, "cat": "A", "v": 20}, - ], - pipeline=[ - {"$match": {"cat": "Z"}}, - {"$group": {"_id": "$cat", "result": {"$last": "$v"}}}, - ], - expected=[], - msg="$last should produce no output when all documents are filtered out", - ), -] - -# Property [Single Document]: $last on a single-document group returns that -# document's value. -LAST_SINGLE_DOCUMENT_TESTS: list[AccumulatorTestCase] = [ - AccumulatorTestCase( - "single_document", - docs=[{"_id": 0, "v": 42}], - pipeline=[ - {"$group": {"_id": None, "result": {"$last": "$v"}}}, - {"$project": {"_id": 0, "result": 1}}, - ], - expected=[{"result": 42}], - msg="$last should return value from single document", - ), - AccumulatorTestCase( - "single_per_group", - docs=[ - {"_id": 0, "cat": "A", "v": 10}, - {"_id": 1, "cat": "B", "v": 20}, - {"_id": 2, "cat": "C", "v": 30}, - ], - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": "$cat", "result": {"$last": "$v"}}}, - {"$sort": {"_id": 1}}, - {"$project": {"result": 1}}, - ], - expected=[ - {"_id": "A", "result": 10}, - {"_id": "B", "result": 20}, - {"_id": "C", "result": 30}, - ], - msg="$last should return correct value when each group has one document", - ), -] - -# Property [Multiple Groups]: $last computes correct last value per group -# independently. -LAST_MULTIPLE_GROUPS_TESTS: list[AccumulatorTestCase] = [ - AccumulatorTestCase( - "different_group_sizes", - docs=[ - {"_id": 0, "cat": "A", "v": 1}, - {"_id": 1, "cat": "A", "v": 2}, - {"_id": 2, "cat": "A", "v": 3}, - {"_id": 3, "cat": "B", "v": 10}, - {"_id": 4, "cat": "B", "v": 20}, - {"_id": 5, "cat": "C", "v": 100}, - ], - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": "$cat", "result": {"$last": "$v"}}}, - {"$sort": {"_id": 1}}, - ], - expected=[ - {"_id": "A", "result": 3}, - {"_id": "B", "result": 20}, - {"_id": "C", "result": 100}, - ], - msg="$last should return correct last value for groups of different sizes", - ), - AccumulatorTestCase( - "different_types_per_group", - docs=[ - {"_id": 0, "cat": "int", "v": 10}, - {"_id": 1, "cat": "int", "v": 20}, - {"_id": 2, "cat": "str", "v": "hello"}, - {"_id": 3, "cat": "str", "v": "world"}, - ], - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": "$cat", "result": {"$last": "$v"}}}, - {"$sort": {"_id": 1}}, - ], - expected=[ - {"_id": "int", "result": 20}, - {"_id": "str", "result": "world"}, - ], - msg="$last should return correct type per group", - ), - AccumulatorTestCase( - "mixed_null_groups", - docs=[ - {"_id": 0, "cat": "A", "v": None}, - {"_id": 1, "cat": "A", "v": None}, - {"_id": 2, "cat": "B", "v": 10}, - {"_id": 3, "cat": "B", "v": 20}, - ], - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": "$cat", "result": {"$last": "$v"}}}, - {"$sort": {"_id": 1}}, - ], - expected=[ - {"_id": "A", "result": None}, - {"_id": "B", "result": 20}, - ], - msg="$last should return null for all-null group and value for numeric group", - ), -] - -# Property [Large Group]: $last returns the last value from a large group. -LAST_LARGE_GROUP_TESTS: list[AccumulatorTestCase] = [ - AccumulatorTestCase( - "large_group_1000", - docs=[{"_id": i, "v": i} for i in range(1000)], - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$last": "$v"}}}, - {"$project": {"_id": 0, "result": 1}}, - ], - expected=[{"result": 999}], - msg="$last should return the value from the 1000th document", - ), -] - -# Property [Multiple Accumulators]: $last works correctly alongside other -# accumulators in the same $group stage. -LAST_MULTIPLE_ACCUMULATORS_TESTS: list[AccumulatorTestCase] = [ - AccumulatorTestCase( - "last_and_first", - docs=[ - {"_id": 0, "v": 10}, - {"_id": 1, "v": 20}, - {"_id": 2, "v": 30}, - ], - pipeline=[ - {"$sort": {"_id": 1}}, - { - "$group": { - "_id": None, - "last": {"$last": "$v"}, - "first": {"$first": "$v"}, - } - }, - {"$project": {"_id": 0, "last": 1, "first": 1}}, - ], - expected=[{"last": 30, "first": 10}], - msg="$last and $first should return different values on same field", - ), - AccumulatorTestCase( - "last_on_two_fields", - docs=[ - {"_id": 0, "a": 1, "b": "x"}, - {"_id": 1, "a": 2, "b": "y"}, - ], - pipeline=[ - {"$sort": {"_id": 1}}, - { - "$group": { - "_id": None, - "lastA": {"$last": "$a"}, - "lastB": {"$last": "$b"}, - } - }, - {"$project": {"_id": 0, "lastA": 1, "lastB": 1}}, - ], - expected=[{"lastA": 2, "lastB": "y"}], - msg="$last should return correct values for multiple fields", - ), - AccumulatorTestCase( - "last_and_sum", - docs=[ - {"_id": 0, "v": 10}, - {"_id": 1, "v": 20}, - {"_id": 2, "v": 30}, - ], - pipeline=[ - {"$sort": {"_id": 1}}, - { - "$group": { - "_id": None, - "last": {"$last": "$v"}, - "total": {"$sum": "$v"}, - } - }, - {"$project": {"_id": 0, "last": 1, "total": 1}}, - ], - expected=[{"last": 30, "total": 60}], - msg="$last passthrough and $sum computation should coexist", - ), -] - -# Property [Compound Group ID]: $last works with compound and expression-based -# group _id. -LAST_COMPOUND_ID_TESTS: list[AccumulatorTestCase] = [ - AccumulatorTestCase( - "compound_id", - docs=[ - {"_id": 0, "cat": "A", "sub": "x", "v": 1}, - {"_id": 1, "cat": "A", "sub": "x", "v": 2}, - {"_id": 2, "cat": "B", "sub": "y", "v": 10}, - ], - pipeline=[ - {"$sort": {"_id": 1}}, - { - "$group": { - "_id": {"cat": "$cat", "sub": "$sub"}, - "result": {"$last": "$v"}, - } - }, - {"$sort": {"_id": 1}}, - ], - expected=[ - {"_id": {"cat": "A", "sub": "x"}, "result": 2}, - {"_id": {"cat": "B", "sub": "y"}, "result": 10}, - ], - msg="$last should work with compound group _id", - ), - AccumulatorTestCase( - "expression_id", - docs=[ - {"_id": 0, "cat": "hello", "v": 1}, - {"_id": 1, "cat": "hello", "v": 2}, - {"_id": 2, "cat": "world", "v": 10}, - ], - pipeline=[ - {"$sort": {"_id": 1}}, - { - "$group": { - "_id": {"$toUpper": "$cat"}, - "result": {"$last": "$v"}, - } - }, - {"$sort": {"_id": 1}}, - ], - expected=[ - {"_id": "HELLO", "result": 2}, - {"_id": "WORLD", "result": 10}, - ], - msg="$last should work with expression-based group _id", - ), -] - -# Property [Pipeline Interactions]: $last works correctly when preceded by -# various pipeline stages. -LAST_PIPELINE_INTERACTION_TESTS: list[AccumulatorTestCase] = [ - AccumulatorTestCase( - "after_match", - docs=[ - {"_id": 0, "cat": "A", "v": 10}, - {"_id": 1, "cat": "B", "v": 20}, - {"_id": 2, "cat": "A", "v": 30}, - ], - pipeline=[ - {"$match": {"cat": "A"}}, - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$last": "$v"}}}, - {"$project": {"_id": 0, "result": 1}}, - ], - expected=[{"result": 30}], - msg="$last should work after $match filtering", - ), - AccumulatorTestCase( - "after_project", - docs=[ - {"_id": 0, "x": 10}, - {"_id": 1, "x": 20}, - ], - pipeline=[ - {"$project": {"_id": 1, "v": "$x"}}, - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$last": "$v"}}}, - {"$project": {"_id": 0, "result": 1}}, - ], - expected=[{"result": 20}], - msg="$last should work after $project field rename", - ), - AccumulatorTestCase( - "after_unwind", - docs=[ - {"_id": 0, "tags": ["a", "b"]}, - {"_id": 1, "tags": ["c"]}, - ], - pipeline=[ - {"$unwind": "$tags"}, - {"$sort": {"_id": 1, "tags": 1}}, - {"$group": {"_id": None, "result": {"$last": "$tags"}}}, - {"$project": {"_id": 0, "result": 1}}, - ], - expected=[{"result": "c"}], - msg="$last should work after $unwind", - ), -] - -# Property [Bucket Smoke]: $last works correctly in $bucket context. -LAST_BUCKET_SMOKE_TESTS: list[AccumulatorTestCase] = [ - AccumulatorTestCase( - "bucket_basic", - docs=[ - {"_id": 0, "v": 10}, - {"_id": 1, "v": 20}, - {"_id": 2, "v": 30}, - ], - pipeline=[ - {"$sort": {"_id": 1}}, - { - "$bucket": { - "groupBy": {"$literal": 0}, - "boundaries": [-1, 1], - "output": {"result": {"$last": "$v"}}, - } - }, - {"$project": {"_id": 0, "result": 1}}, - ], - expected=[{"result": 30}], - msg="$last should return last value in $bucket context", - ), -] - -# Property [BucketAuto Smoke]: $last works correctly in $bucketAuto context. -LAST_BUCKET_AUTO_SMOKE_TESTS: list[AccumulatorTestCase] = [ - AccumulatorTestCase( - "bucket_auto_basic", - docs=[ - {"_id": 0, "v": 10}, - {"_id": 1, "v": 20}, - {"_id": 2, "v": 30}, - ], - pipeline=[ - {"$sort": {"_id": 1}}, - { - "$bucketAuto": { - "groupBy": {"$literal": 0}, - "buckets": 1, - "output": {"result": {"$last": "$v"}}, - } - }, - {"$project": {"_id": 0, "result": 1}}, - ], - expected=[{"result": 30}], - msg="$last should return last value in $bucketAuto context", - ), -] - -LAST_GROUP_CONTEXT_TESTS: list[AccumulatorTestCase] = ( - LAST_EMPTY_COLLECTION_TESTS - + LAST_SINGLE_DOCUMENT_TESTS - + LAST_MULTIPLE_GROUPS_TESTS - + LAST_LARGE_GROUP_TESTS - + LAST_MULTIPLE_ACCUMULATORS_TESTS - + LAST_COMPOUND_ID_TESTS - + LAST_PIPELINE_INTERACTION_TESTS - + LAST_BUCKET_SMOKE_TESTS - + LAST_BUCKET_AUTO_SMOKE_TESTS -) - - -def _run(collection, test_case: AccumulatorTestCase): - """Insert docs and execute the pipeline.""" - if test_case.docs: - collection.insert_many(test_case.docs) - return execute_command( - collection, - {"aggregate": collection.name, "pipeline": test_case.pipeline, "cursor": {}}, - ) - - -@pytest.mark.parametrize("test_case", pytest_params(LAST_GROUP_CONTEXT_TESTS)) -def test_last_group_context(collection, test_case: AccumulatorTestCase): - """Test $last in group context with grouping behavior.""" - result = _run(collection, test_case) - assertResult(result, expected=test_case.expected, msg=test_case.msg) From d1063953953808dbca899c2977a52fc9c6987c51 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" Date: Fri, 22 May 2026 15:28:36 -0700 Subject: [PATCH 06/14] inline helper functions Signed-off-by: Alina (Xi) Li --- .../last/test_accumulator_last.py | 341 ++++++++++++++---- .../last/test_accumulator_last_group_types.py | 125 +++++-- 2 files changed, 358 insertions(+), 108 deletions(-) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last.py index f1a2a4da..957c97cf 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last.py @@ -40,37 +40,39 @@ INT64_MIN, ) - -def _group_last(acc): - """Build a $sort + $group pipeline for $last.""" - return [ - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$last": acc}}}, - {"$project": {"_id": 0, "result": 1}}, - ] - - # Property [BSON Type Passthrough]: $last returns the last value in a group # unchanged, preserving its exact BSON type without coercion. LAST_BSON_TYPE_TESTS: list[AccumulatorTestCase] = [ AccumulatorTestCase( "bson_double", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 3.14}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": 3.14}], msg="$last should return double value unchanged", ), AccumulatorTestCase( "bson_int32", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 42}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": 42}], msg="$last should return int32 value unchanged", ), AccumulatorTestCase( "bson_int64", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Int64(9223372036854775807)}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": Int64(9223372036854775807)}], msg="$last should return int64 value unchanged", ), @@ -80,28 +82,44 @@ def _group_last(acc): {"_id": 0, "v": 1}, {"_id": 1, "v": Decimal128("123.456")}, ], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": Decimal128("123.456")}], msg="$last should return Decimal128 value unchanged", ), AccumulatorTestCase( "bson_string", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": "hello"}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": "hello"}], msg="$last should return string value unchanged", ), AccumulatorTestCase( "bson_bool_true", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": True}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": True}], msg="$last should return boolean true unchanged", ), AccumulatorTestCase( "bson_bool_false", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": False}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": False}], msg="$last should return boolean false unchanged", ), @@ -111,35 +129,55 @@ def _group_last(acc): {"_id": 0, "v": 1}, {"_id": 1, "v": datetime(2024, 1, 1, tzinfo=timezone.utc)}, ], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": datetime(2024, 1, 1, tzinfo=timezone.utc)}], msg="$last should return datetime value unchanged", ), AccumulatorTestCase( "bson_null", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": None}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": None}], msg="$last should return null value unchanged", ), AccumulatorTestCase( "bson_object", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": {"nested": "doc"}}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": {"nested": "doc"}}], msg="$last should return embedded document unchanged", ), AccumulatorTestCase( "bson_array", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": [1, 2, 3]}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": [1, 2, 3]}], msg="$last should return entire array unchanged without traversal", ), AccumulatorTestCase( "bson_binary", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Binary(b"\x00\x01\x02")}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": b"\x00\x01\x02"}], msg="$last should return Binary value unchanged", ), @@ -149,42 +187,66 @@ def _group_last(acc): {"_id": 0, "v": 1}, {"_id": 1, "v": ObjectId("507f1f77bcf86cd799439011")}, ], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": ObjectId("507f1f77bcf86cd799439011")}], msg="$last should return ObjectId unchanged", ), AccumulatorTestCase( "bson_regex", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Regex("^abc", "i")}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": Regex("^abc", "i")}], msg="$last should return Regex unchanged", ), AccumulatorTestCase( "bson_code", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Code("function(){}")}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": "function(){}"}], msg="$last should return Code as string via runCommand", ), AccumulatorTestCase( "bson_timestamp", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Timestamp(1, 1)}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": Timestamp(1, 1)}], msg="$last should return Timestamp unchanged", ), AccumulatorTestCase( "bson_minkey", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": MinKey()}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": {"": MinKey()}}], msg="$last should return MinKey wrapped as object via runCommand", ), AccumulatorTestCase( "bson_maxkey", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": MaxKey()}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": {"": MaxKey()}}], msg="$last should return MaxKey wrapped as object via runCommand", ), @@ -197,56 +259,88 @@ def _group_last(acc): AccumulatorTestCase( "null_last_doc", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": None}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": None}], msg="$last should return null when last document has null value", ), AccumulatorTestCase( "missing_last_doc", docs=[{"_id": 0, "v": 1}, {"_id": 1}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": None}], msg="$last should return null when last document has missing field", ), AccumulatorTestCase( "null_all", docs=[{"_id": 0, "v": None}, {"_id": 1, "v": None}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": None}], msg="$last should return null when all values are null", ), AccumulatorTestCase( "missing_all", docs=[{"_id": 0}, {"_id": 1}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": None}], msg="$last should return null when all documents have missing field", ), AccumulatorTestCase( "null_not_last", docs=[{"_id": 0, "v": None}, {"_id": 1, "v": 10}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": 10}], msg="$last should return last value even when earlier values are null", ), AccumulatorTestCase( "missing_not_last", docs=[{"_id": 0}, {"_id": 1, "v": 10}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": 10}], msg="$last should return last value even when earlier fields are missing", ), AccumulatorTestCase( "null_among_values", docs=[{"_id": 0, "v": 10}, {"_id": 1, "v": None}, {"_id": 2, "v": 20}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": 20}], msg="$last should return value from last document regardless of intermediate nulls", ), AccumulatorTestCase( "missing_among_values", docs=[{"_id": 0, "v": 10}, {"_id": 1}, {"_id": 2, "v": 20}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": 20}], msg="$last should return value from last doc regardless of intermediate missing fields", ), @@ -338,56 +432,88 @@ def _group_last(acc): AccumulatorTestCase( "nan_double", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": FLOAT_NAN}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": pytest.approx(math.nan, nan_ok=True)}], msg="$last should return double NaN unchanged", ), AccumulatorTestCase( "nan_decimal128", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Decimal128("NaN")}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": Decimal128("NaN")}], msg="$last should return Decimal128 NaN unchanged", ), AccumulatorTestCase( "inf_double", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": FLOAT_INFINITY}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": FLOAT_INFINITY}], msg="$last should return double Infinity unchanged", ), AccumulatorTestCase( "neg_inf_double", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": FLOAT_NEGATIVE_INFINITY}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": FLOAT_NEGATIVE_INFINITY}], msg="$last should return double -Infinity unchanged", ), AccumulatorTestCase( "inf_decimal128", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Decimal128("Infinity")}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": Decimal128("Infinity")}], msg="$last should return Decimal128 Infinity unchanged", ), AccumulatorTestCase( "neg_inf_decimal128", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Decimal128("-Infinity")}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": Decimal128("-Infinity")}], msg="$last should return Decimal128 -Infinity unchanged", ), AccumulatorTestCase( "neg_zero_double", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": DOUBLE_NEGATIVE_ZERO}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": DOUBLE_NEGATIVE_ZERO}], msg="$last should preserve double -0.0 unchanged", ), AccumulatorTestCase( "neg_zero_decimal128", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": DECIMAL128_NEGATIVE_ZERO}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": DECIMAL128_NEGATIVE_ZERO}], msg="$last should preserve Decimal128 -0 unchanged", ), @@ -399,56 +525,88 @@ def _group_last(acc): AccumulatorTestCase( "boundary_int32_max", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": INT32_MAX}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": INT32_MAX}], msg="$last should return INT32_MAX unchanged", ), AccumulatorTestCase( "boundary_int32_min", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": INT32_MIN}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": INT32_MIN}], msg="$last should return INT32_MIN unchanged", ), AccumulatorTestCase( "boundary_int64_max", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": INT64_MAX}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": INT64_MAX}], msg="$last should return INT64_MAX unchanged", ), AccumulatorTestCase( "boundary_int64_min", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": INT64_MIN}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": INT64_MIN}], msg="$last should return INT64_MIN unchanged", ), AccumulatorTestCase( "boundary_double_max", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": DOUBLE_MAX}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": DOUBLE_MAX}], msg="$last should return DOUBLE_MAX unchanged", ), AccumulatorTestCase( "boundary_double_min_subnormal", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": DOUBLE_MIN_SUBNORMAL}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": DOUBLE_MIN_SUBNORMAL}], msg="$last should return double min subnormal unchanged", ), AccumulatorTestCase( "boundary_decimal128_max", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": DECIMAL128_MAX}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": DECIMAL128_MAX}], msg="$last should return DECIMAL128_MAX unchanged", ), AccumulatorTestCase( "boundary_decimal128_min", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": DECIMAL128_MIN}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": DECIMAL128_MIN}], msg="$last should return DECIMAL128_MIN unchanged", ), @@ -460,21 +618,33 @@ def _group_last(acc): AccumulatorTestCase( "array_whole", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": [1, 2, 3]}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": [1, 2, 3]}], msg="$last should return entire array without traversal", ), AccumulatorTestCase( "array_nested", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": [[1, 2], [3, 4]]}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": [[1, 2], [3, 4]]}], msg="$last should return nested array unchanged", ), AccumulatorTestCase( "array_empty", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": []}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": []}], msg="$last should return empty array unchanged", ), @@ -484,14 +654,22 @@ def _group_last(acc): {"_id": 0, "v": 1}, {"_id": 1, "v": [{"a": 1}, {"a": 2}]}, ], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": [{"a": 1}, {"a": 2}]}], msg="$last should return array of objects unchanged", ), AccumulatorTestCase( "array_single_element", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": [42]}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": [42]}], msg="$last should return single-element array as array, not scalar", ), @@ -501,7 +679,11 @@ def _group_last(acc): {"_id": 0, "v": 1}, {"_id": 1, "v": [1, "two", None, True]}, ], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": [1, "two", None, True]}], msg="$last should return mixed-type array unchanged", ), @@ -513,7 +695,11 @@ def _group_last(acc): AccumulatorTestCase( "expr_field_path", docs=[{"_id": 0, "v": 10}, {"_id": 1, "v": 20}], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": 20}], msg="$last should accept field path expression", ), @@ -523,7 +709,11 @@ def _group_last(acc): {"_id": 0, "a": {"b": 10}}, {"_id": 1, "a": {"b": 20}}, ], - pipeline=_group_last("$a.b"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$a.b"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": 20}], msg="$last should accept nested field path", ), @@ -533,7 +723,11 @@ def _group_last(acc): {"_id": 0, "a": {"b": {"c": 10}}}, {"_id": 1, "a": {"b": {"c": 20}}}, ], - pipeline=_group_last("$a.b.c"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$a.b.c"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": 20}], msg="$last should accept deeply nested field path", ), @@ -598,7 +792,11 @@ def _group_last(acc): {"_id": 0, "a": {"b": 10}}, {"_id": 1, "a": {}}, ], - pipeline=_group_last("$a.b"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$a.b"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": None}], msg="$last should return null when nested field is missing in last document", ), @@ -614,7 +812,11 @@ def _group_last(acc): {"_id": 1, "v": "hello"}, {"_id": 2, "v": True}, ], - pipeline=_group_last("$v"), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], expected=[{"result": True}], msg="$last should return last value regardless of mixed types in group", ), @@ -632,18 +834,13 @@ def _group_last(acc): ) -def _run(collection, test_case: AccumulatorTestCase): - """Insert docs and execute the pipeline.""" +@pytest.mark.parametrize("test_case", pytest_params(LAST_SUCCESS_TESTS)) +def test_accumulator_last(collection, test_case: AccumulatorTestCase): + """Test $last accumulator success cases.""" if test_case.docs: collection.insert_many(test_case.docs) - return execute_command( + result = execute_command( collection, {"aggregate": collection.name, "pipeline": test_case.pipeline, "cursor": {}}, ) - - -@pytest.mark.parametrize("test_case", pytest_params(LAST_SUCCESS_TESTS)) -def test_accumulator_last(collection, test_case: AccumulatorTestCase): - """Test $last accumulator success cases.""" - result = _run(collection, test_case) assertSuccess(result, test_case.expected, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_group_types.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_group_types.py index 120db177..f3b06208 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_group_types.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_group_types.py @@ -18,58 +18,72 @@ from documentdb_tests.framework.executor import execute_command from documentdb_tests.framework.parametrize import pytest_params - -def _group_last_with_type(): - """Build a $group pipeline for $last with $type projection.""" - return [ - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$last": "$v"}}}, - {"$project": {"_id": 0, "value": "$result", "type": {"$type": "$result"}}}, - ] - - # Property [Return Type Preservation]: $last preserves the BSON type of the # last value, verified via $type projection. LAST_RETURN_TYPE_TESTS: list[AccumulatorTestCase] = [ AccumulatorTestCase( "type_int32", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 42}], - pipeline=_group_last_with_type(), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "value": "$result", "type": {"$type": "$result"}}}, + ], expected=[{"value": 42, "type": "int"}], msg="$last should preserve int32 type", ), AccumulatorTestCase( "type_int64", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Int64(42)}], - pipeline=_group_last_with_type(), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "value": "$result", "type": {"$type": "$result"}}}, + ], expected=[{"value": Int64(42), "type": "long"}], msg="$last should preserve int64 type", ), AccumulatorTestCase( "type_double", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 3.14}], - pipeline=_group_last_with_type(), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "value": "$result", "type": {"$type": "$result"}}}, + ], expected=[{"value": 3.14, "type": "double"}], msg="$last should preserve double type", ), AccumulatorTestCase( "type_decimal128", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Decimal128("3.14")}], - pipeline=_group_last_with_type(), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "value": "$result", "type": {"$type": "$result"}}}, + ], expected=[{"value": Decimal128("3.14"), "type": "decimal"}], msg="$last should preserve Decimal128 type", ), AccumulatorTestCase( "type_string", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": "hello"}], - pipeline=_group_last_with_type(), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "value": "$result", "type": {"$type": "$result"}}}, + ], expected=[{"value": "hello", "type": "string"}], msg="$last should preserve string type", ), AccumulatorTestCase( "type_bool", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": True}], - pipeline=_group_last_with_type(), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "value": "$result", "type": {"$type": "$result"}}}, + ], expected=[{"value": True, "type": "bool"}], msg="$last should preserve bool type", ), @@ -79,35 +93,55 @@ def _group_last_with_type(): {"_id": 0, "v": 1}, {"_id": 1, "v": datetime(2024, 1, 1, tzinfo=timezone.utc)}, ], - pipeline=_group_last_with_type(), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "value": "$result", "type": {"$type": "$result"}}}, + ], expected=[{"value": datetime(2024, 1, 1, tzinfo=timezone.utc), "type": "date"}], msg="$last should preserve date type", ), AccumulatorTestCase( "type_null", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": None}], - pipeline=_group_last_with_type(), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "value": "$result", "type": {"$type": "$result"}}}, + ], expected=[{"value": None, "type": "null"}], msg="$last should preserve null type", ), AccumulatorTestCase( "type_object", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": {"a": 1}}], - pipeline=_group_last_with_type(), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "value": "$result", "type": {"$type": "$result"}}}, + ], expected=[{"value": {"a": 1}, "type": "object"}], msg="$last should preserve object type", ), AccumulatorTestCase( "type_array", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": [1, 2]}], - pipeline=_group_last_with_type(), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "value": "$result", "type": {"$type": "$result"}}}, + ], expected=[{"value": [1, 2], "type": "array"}], msg="$last should preserve array type", ), AccumulatorTestCase( "type_binary", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Binary(b"\x01")}], - pipeline=_group_last_with_type(), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "value": "$result", "type": {"$type": "$result"}}}, + ], expected=[{"value": b"\x01", "type": "binData"}], msg="$last should preserve Binary type", ), @@ -117,60 +151,79 @@ def _group_last_with_type(): {"_id": 0, "v": 1}, {"_id": 1, "v": ObjectId("507f1f77bcf86cd799439011")}, ], - pipeline=_group_last_with_type(), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "value": "$result", "type": {"$type": "$result"}}}, + ], expected=[{"value": ObjectId("507f1f77bcf86cd799439011"), "type": "objectId"}], msg="$last should preserve ObjectId type", ), AccumulatorTestCase( "type_regex", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Regex("abc", "i")}], - pipeline=_group_last_with_type(), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "value": "$result", "type": {"$type": "$result"}}}, + ], expected=[{"value": Regex("abc", "i"), "type": "regex"}], msg="$last should preserve Regex type", ), AccumulatorTestCase( "type_code", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Code("function(){}")}], - pipeline=_group_last_with_type(), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "value": "$result", "type": {"$type": "$result"}}}, + ], expected=[{"value": "function(){}", "type": "string"}], msg="$last should return Code as string via runCommand", ), AccumulatorTestCase( "type_timestamp", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Timestamp(1, 1)}], - pipeline=_group_last_with_type(), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "value": "$result", "type": {"$type": "$result"}}}, + ], expected=[{"value": Timestamp(1, 1), "type": "timestamp"}], msg="$last should preserve Timestamp type", ), AccumulatorTestCase( "type_minkey", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": MinKey()}], - pipeline=_group_last_with_type(), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "value": "$result", "type": {"$type": "$result"}}}, + ], expected=[{"value": {"": MinKey()}, "type": "object"}], msg="$last should return MinKey wrapped as object via runCommand", ), AccumulatorTestCase( "type_maxkey", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": MaxKey()}], - pipeline=_group_last_with_type(), + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "value": "$result", "type": {"$type": "$result"}}}, + ], expected=[{"value": {"": MaxKey()}, "type": "object"}], msg="$last should return MaxKey wrapped as object via runCommand", ), ] -def _run(collection, test_case: AccumulatorTestCase): - """Insert docs and execute the pipeline.""" +@pytest.mark.parametrize("test_case", pytest_params(LAST_RETURN_TYPE_TESTS)) +def test_last_group_types(collection, test_case: AccumulatorTestCase): + """Test $last return type preservation via $type projection.""" if test_case.docs: collection.insert_many(test_case.docs) - return execute_command( + result = execute_command( collection, {"aggregate": collection.name, "pipeline": test_case.pipeline, "cursor": {}}, ) - - -@pytest.mark.parametrize("test_case", pytest_params(LAST_RETURN_TYPE_TESTS)) -def test_last_group_types(collection, test_case: AccumulatorTestCase): - """Test $last return type preservation via $type projection.""" - result = _run(collection, test_case) assertResult(result, expected=test_case.expected, msg=test_case.msg) From 7564297bba29acd4b5a2d70422536f7e62a7a145 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" Date: Fri, 22 May 2026 15:33:29 -0700 Subject: [PATCH 07/14] separate BSON into different file Signed-off-by: Alina (Xi) Li --- .../last/test_accumulator_last.py | 231 +--------------- .../last/test_accumulator_last_bson_types.py | 249 ++++++++++++++++++ 2 files changed, 253 insertions(+), 227 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_bson_types.py diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last.py index 957c97cf..e49c5016 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last.py @@ -1,22 +1,12 @@ -"""Tests for $last accumulator: BSON type passthrough, null/missing, sort order, expressions.""" +"""Tests for $last accumulator: null/missing, sort order, special numerics, boundaries, +arrays, expressions, mixed types.""" from __future__ import annotations import math -from datetime import datetime, timezone import pytest -from bson import ( - Binary, - Code, - Decimal128, - Int64, - MaxKey, - MinKey, - ObjectId, - Regex, - Timestamp, -) +from bson import Decimal128 from documentdb_tests.compatibility.tests.core.operator.accumulators.utils.accumulator_test_case import ( # noqa: E501 AccumulatorTestCase, @@ -40,218 +30,6 @@ INT64_MIN, ) -# Property [BSON Type Passthrough]: $last returns the last value in a group -# unchanged, preserving its exact BSON type without coercion. -LAST_BSON_TYPE_TESTS: list[AccumulatorTestCase] = [ - AccumulatorTestCase( - "bson_double", - docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 3.14}], - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$last": "$v"}}}, - {"$project": {"_id": 0, "result": 1}}, - ], - expected=[{"result": 3.14}], - msg="$last should return double value unchanged", - ), - AccumulatorTestCase( - "bson_int32", - docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 42}], - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$last": "$v"}}}, - {"$project": {"_id": 0, "result": 1}}, - ], - expected=[{"result": 42}], - msg="$last should return int32 value unchanged", - ), - AccumulatorTestCase( - "bson_int64", - docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Int64(9223372036854775807)}], - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$last": "$v"}}}, - {"$project": {"_id": 0, "result": 1}}, - ], - expected=[{"result": Int64(9223372036854775807)}], - msg="$last should return int64 value unchanged", - ), - AccumulatorTestCase( - "bson_decimal128", - docs=[ - {"_id": 0, "v": 1}, - {"_id": 1, "v": Decimal128("123.456")}, - ], - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$last": "$v"}}}, - {"$project": {"_id": 0, "result": 1}}, - ], - expected=[{"result": Decimal128("123.456")}], - msg="$last should return Decimal128 value unchanged", - ), - AccumulatorTestCase( - "bson_string", - docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": "hello"}], - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$last": "$v"}}}, - {"$project": {"_id": 0, "result": 1}}, - ], - expected=[{"result": "hello"}], - msg="$last should return string value unchanged", - ), - AccumulatorTestCase( - "bson_bool_true", - docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": True}], - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$last": "$v"}}}, - {"$project": {"_id": 0, "result": 1}}, - ], - expected=[{"result": True}], - msg="$last should return boolean true unchanged", - ), - AccumulatorTestCase( - "bson_bool_false", - docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": False}], - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$last": "$v"}}}, - {"$project": {"_id": 0, "result": 1}}, - ], - expected=[{"result": False}], - msg="$last should return boolean false unchanged", - ), - AccumulatorTestCase( - "bson_date", - docs=[ - {"_id": 0, "v": 1}, - {"_id": 1, "v": datetime(2024, 1, 1, tzinfo=timezone.utc)}, - ], - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$last": "$v"}}}, - {"$project": {"_id": 0, "result": 1}}, - ], - expected=[{"result": datetime(2024, 1, 1, tzinfo=timezone.utc)}], - msg="$last should return datetime value unchanged", - ), - AccumulatorTestCase( - "bson_null", - docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": None}], - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$last": "$v"}}}, - {"$project": {"_id": 0, "result": 1}}, - ], - expected=[{"result": None}], - msg="$last should return null value unchanged", - ), - AccumulatorTestCase( - "bson_object", - docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": {"nested": "doc"}}], - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$last": "$v"}}}, - {"$project": {"_id": 0, "result": 1}}, - ], - expected=[{"result": {"nested": "doc"}}], - msg="$last should return embedded document unchanged", - ), - AccumulatorTestCase( - "bson_array", - docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": [1, 2, 3]}], - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$last": "$v"}}}, - {"$project": {"_id": 0, "result": 1}}, - ], - expected=[{"result": [1, 2, 3]}], - msg="$last should return entire array unchanged without traversal", - ), - AccumulatorTestCase( - "bson_binary", - docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Binary(b"\x00\x01\x02")}], - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$last": "$v"}}}, - {"$project": {"_id": 0, "result": 1}}, - ], - expected=[{"result": b"\x00\x01\x02"}], - msg="$last should return Binary value unchanged", - ), - AccumulatorTestCase( - "bson_objectid", - docs=[ - {"_id": 0, "v": 1}, - {"_id": 1, "v": ObjectId("507f1f77bcf86cd799439011")}, - ], - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$last": "$v"}}}, - {"$project": {"_id": 0, "result": 1}}, - ], - expected=[{"result": ObjectId("507f1f77bcf86cd799439011")}], - msg="$last should return ObjectId unchanged", - ), - AccumulatorTestCase( - "bson_regex", - docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Regex("^abc", "i")}], - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$last": "$v"}}}, - {"$project": {"_id": 0, "result": 1}}, - ], - expected=[{"result": Regex("^abc", "i")}], - msg="$last should return Regex unchanged", - ), - AccumulatorTestCase( - "bson_code", - docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Code("function(){}")}], - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$last": "$v"}}}, - {"$project": {"_id": 0, "result": 1}}, - ], - expected=[{"result": "function(){}"}], - msg="$last should return Code as string via runCommand", - ), - AccumulatorTestCase( - "bson_timestamp", - docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Timestamp(1, 1)}], - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$last": "$v"}}}, - {"$project": {"_id": 0, "result": 1}}, - ], - expected=[{"result": Timestamp(1, 1)}], - msg="$last should return Timestamp unchanged", - ), - AccumulatorTestCase( - "bson_minkey", - docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": MinKey()}], - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$last": "$v"}}}, - {"$project": {"_id": 0, "result": 1}}, - ], - expected=[{"result": {"": MinKey()}}], - msg="$last should return MinKey wrapped as object via runCommand", - ), - AccumulatorTestCase( - "bson_maxkey", - docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": MaxKey()}], - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$last": "$v"}}}, - {"$project": {"_id": 0, "result": 1}}, - ], - expected=[{"result": {"": MaxKey()}}], - msg="$last should return MaxKey wrapped as object via runCommand", - ), -] - # Property [Null and Missing Handling]: $last returns whatever value the last # document has. If the field is missing, $last returns null. Unlike numeric # accumulators, $last does NOT ignore nulls. @@ -823,8 +601,7 @@ ] LAST_SUCCESS_TESTS = ( - LAST_BSON_TYPE_TESTS - + LAST_NULL_MISSING_TESTS + LAST_NULL_MISSING_TESTS + LAST_SORT_ORDER_TESTS + LAST_SPECIAL_NUMERIC_TESTS + LAST_BOUNDARY_TESTS diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_bson_types.py new file mode 100644 index 00000000..faee9c7b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_bson_types.py @@ -0,0 +1,249 @@ +"""Tests for $last accumulator: BSON type passthrough.""" + +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 [BSON Type Passthrough]: $last returns the last value in a group +# unchanged, preserving its exact BSON type without coercion. +LAST_BSON_TYPE_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "bson_double", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 3.14}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": 3.14}], + msg="$last should return double value unchanged", + ), + AccumulatorTestCase( + "bson_int32", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 42}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": 42}], + msg="$last should return int32 value unchanged", + ), + AccumulatorTestCase( + "bson_int64", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Int64(9223372036854775807)}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": Int64(9223372036854775807)}], + msg="$last should return int64 value unchanged", + ), + AccumulatorTestCase( + "bson_decimal128", + docs=[ + {"_id": 0, "v": 1}, + {"_id": 1, "v": Decimal128("123.456")}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": Decimal128("123.456")}], + msg="$last should return Decimal128 value unchanged", + ), + AccumulatorTestCase( + "bson_string", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": "hello"}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": "hello"}], + msg="$last should return string value unchanged", + ), + AccumulatorTestCase( + "bson_bool_true", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": True}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": True}], + msg="$last should return boolean true unchanged", + ), + AccumulatorTestCase( + "bson_bool_false", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": False}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": False}], + msg="$last should return boolean false unchanged", + ), + AccumulatorTestCase( + "bson_date", + docs=[ + {"_id": 0, "v": 1}, + {"_id": 1, "v": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": datetime(2024, 1, 1, tzinfo=timezone.utc)}], + msg="$last should return datetime value unchanged", + ), + AccumulatorTestCase( + "bson_null", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": None}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": None}], + msg="$last should return null value unchanged", + ), + AccumulatorTestCase( + "bson_object", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": {"nested": "doc"}}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": {"nested": "doc"}}], + msg="$last should return embedded document unchanged", + ), + AccumulatorTestCase( + "bson_array", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": [1, 2, 3]}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": [1, 2, 3]}], + msg="$last should return entire array unchanged without traversal", + ), + AccumulatorTestCase( + "bson_binary", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Binary(b"\x00\x01\x02")}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": b"\x00\x01\x02"}], + msg="$last should return Binary value unchanged", + ), + AccumulatorTestCase( + "bson_objectid", + docs=[ + {"_id": 0, "v": 1}, + {"_id": 1, "v": ObjectId("507f1f77bcf86cd799439011")}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": ObjectId("507f1f77bcf86cd799439011")}], + msg="$last should return ObjectId unchanged", + ), + AccumulatorTestCase( + "bson_regex", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Regex("^abc", "i")}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": Regex("^abc", "i")}], + msg="$last should return Regex unchanged", + ), + AccumulatorTestCase( + "bson_code", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Code("function(){}")}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": "function(){}"}], + msg="$last should return Code as string via runCommand", + ), + AccumulatorTestCase( + "bson_timestamp", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Timestamp(1, 1)}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": Timestamp(1, 1)}], + msg="$last should return Timestamp unchanged", + ), + AccumulatorTestCase( + "bson_minkey", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": MinKey()}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": {"": MinKey()}}], + msg="$last should return MinKey wrapped as object via runCommand", + ), + AccumulatorTestCase( + "bson_maxkey", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": MaxKey()}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": {"": MaxKey()}}], + msg="$last should return MaxKey wrapped as object via runCommand", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(LAST_BSON_TYPE_TESTS)) +def test_accumulator_last_bson_types(collection, test_case: AccumulatorTestCase): + """Test $last accumulator BSON type passthrough.""" + 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 659211d530cb2f9a6f02323a2eaf01229fa518b7 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" Date: Fri, 22 May 2026 15:45:23 -0700 Subject: [PATCH 08/14] rename to test_accumulator_*.py for consistency Signed-off-by: Alina (Xi) Li --- ...t_smoke_accumulator_last.py => test_accumulator_last_smoke.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename documentdb_tests/compatibility/tests/core/operator/accumulators/last/{test_smoke_accumulator_last.py => test_accumulator_last_smoke.py} (100%) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_smoke_accumulator_last.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_smoke.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_smoke_accumulator_last.py rename to documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_smoke.py From 987b79d2a67a66c1ec376ac757ffe1bda9e7fd30 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" Date: Fri, 22 May 2026 15:51:40 -0700 Subject: [PATCH 09/14] rename tests to be clearer Signed-off-by: Alina (Xi) Li --- .../last/test_accumulator_last.py | 25 ++++++------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last.py index e49c5016..38619364 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last.py @@ -79,7 +79,7 @@ msg="$last should return null when all documents have missing field", ), AccumulatorTestCase( - "null_not_last", + "null_first_value_last", docs=[{"_id": 0, "v": None}, {"_id": 1, "v": 10}], pipeline=[ {"$sort": {"_id": 1}}, @@ -90,7 +90,7 @@ msg="$last should return last value even when earlier values are null", ), AccumulatorTestCase( - "missing_not_last", + "missing_first_value_last", docs=[{"_id": 0}, {"_id": 1, "v": 10}], pipeline=[ {"$sort": {"_id": 1}}, @@ -471,18 +471,7 @@ # beyond simple field paths. LAST_EXPRESSION_TESTS: list[AccumulatorTestCase] = [ AccumulatorTestCase( - "expr_field_path", - docs=[{"_id": 0, "v": 10}, {"_id": 1, "v": 20}], - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$last": "$v"}}}, - {"$project": {"_id": 0, "result": 1}}, - ], - expected=[{"result": 20}], - msg="$last should accept field path expression", - ), - AccumulatorTestCase( - "expr_nested_field", + "expr_nested_field_path", docs=[ {"_id": 0, "a": {"b": 10}}, {"_id": 1, "a": {"b": 20}}, @@ -496,7 +485,7 @@ msg="$last should accept nested field path", ), AccumulatorTestCase( - "expr_deep_nested", + "expr_three_level_field_path", docs=[ {"_id": 0, "a": {"b": {"c": 10}}}, {"_id": 1, "a": {"b": {"c": 20}}}, @@ -521,7 +510,7 @@ msg="$last should accept literal expression", ), AccumulatorTestCase( - "expr_computed", + "expr_multiply_subexpression", docs=[ {"_id": 0, "a": 2, "b": 3}, {"_id": 1, "a": 4, "b": 5}, @@ -535,7 +524,7 @@ msg="$last should accept computed sub-expression", ), AccumulatorTestCase( - "expr_conditional", + "expr_cond_subexpression", docs=[ {"_id": 0, "v": -5}, {"_id": 1, "v": 10}, @@ -554,7 +543,7 @@ msg="$last should accept conditional expression", ), AccumulatorTestCase( - "expr_constant_value", + "expr_bare_constant", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 2}], pipeline=[ {"$sort": {"_id": 1}}, From 37754127893e58dabe911654ac8dbff896fbefe6 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" Date: Fri, 22 May 2026 15:54:41 -0700 Subject: [PATCH 10/14] Add initial integration tests Signed-off-by: Alina (Xi) Li --- .../test_accumulators_last_integration.py | 426 ++++++++++++++++++ 1 file changed, 426 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/accumulators/test_accumulators_last_integration.py diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/test_accumulators_last_integration.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/test_accumulators_last_integration.py new file mode 100644 index 00000000..21089bb2 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/test_accumulators_last_integration.py @@ -0,0 +1,426 @@ +"""Tests for $last 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 [Last with Sum]: $last picks the last value while $sum independently +# computes the total. $last is order-dependent; $sum is order-independent. +LAST_WITH_SUM_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "last_sum_single_group", + docs=[ + {"cat": "a", "v": 10}, + {"cat": "a", "v": 20}, + {"cat": "a", "v": 30}, + ], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": "$cat", + "last_v": {"$last": "$v"}, + "total": {"$sum": "$v"}, + } + }, + ], + expected=[{"_id": "a", "last_v": 30, "total": 60}], + msg="$last should return last sorted value while $sum computes total", + ), + AccumulatorTestCase( + "last_sum_multiple_groups", + docs=[ + {"cat": "a", "v": 10}, + {"cat": "a", "v": 20}, + {"cat": "b", "v": 5}, + {"cat": "b", "v": 15}, + ], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": "$cat", + "last_v": {"$last": "$v"}, + "total": {"$sum": "$v"}, + } + }, + ], + expected=[ + {"_id": "a", "last_v": 20, "total": 30}, + {"_id": "b", "last_v": 15, "total": 20}, + ], + msg="$last and $sum should produce correct results per group", + ), + AccumulatorTestCase( + "last_sum_null_handling_diverges", + docs=[ + {"cat": "a", "v": 10}, + {"cat": "a", "v": None}, + ], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": "$cat", + "last_v": {"$last": "$v"}, + "total": {"$sum": "$v"}, + } + }, + ], + expected=[{"_id": "a", "last_v": 10, "total": 10}], + msg="$last returns last sorted value (10 after null) while $sum ignores null", + ), +] + +# Property [Last with Avg]: $last picks the last value while $avg computes +# the mean. $last preserves the original type; $avg always returns double. +LAST_WITH_AVG_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "last_avg_basic", + docs=[ + {"cat": "a", "v": 10}, + {"cat": "a", "v": 20}, + {"cat": "a", "v": 30}, + ], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": "$cat", + "last_v": {"$last": "$v"}, + "mean": {"$avg": "$v"}, + } + }, + ], + expected=[{"_id": "a", "last_v": 30, "mean": 20.0}], + msg="$last should return last sorted value while $avg computes mean", + ), + AccumulatorTestCase( + "last_avg_all_null", + docs=[ + {"cat": "a", "v": None}, + {"cat": "a", "v": None}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + { + "$group": { + "_id": "$cat", + "last_v": {"$last": "$v"}, + "mean": {"$avg": "$v"}, + } + }, + ], + expected=[{"_id": "a", "last_v": None, "mean": None}], + msg="$last returns null and $avg returns null when all values are null", + ), +] + +# Property [Last with Min/Max]: $last picks the last sorted value while +# $min/$max pick the extreme values regardless of sort order. +LAST_WITH_MIN_MAX_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "last_min_max_basic", + docs=[ + {"cat": "a", "v": 30}, + {"cat": "a", "v": 10}, + {"cat": "a", "v": 20}, + ], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": "$cat", + "last_v": {"$last": "$v"}, + "lo": {"$min": "$v"}, + "hi": {"$max": "$v"}, + } + }, + ], + expected=[{"_id": "a", "last_v": 30, "lo": 10, "hi": 30}], + msg="$last returns last sorted value while $min/$max return extremes", + ), + AccumulatorTestCase( + "last_min_max_mixed_types", + docs=[ + {"cat": "a", "v": 5}, + {"cat": "a", "v": Int64(100)}, + {"cat": "a", "v": 2.5}, + ], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": "$cat", + "last_v": {"$last": "$v"}, + "lo": {"$min": "$v"}, + "hi": {"$max": "$v"}, + } + }, + ], + expected=[{"_id": "a", "last_v": Int64(100), "lo": 2.5, "hi": Int64(100)}], + msg="$last preserves Int64 type of last value while $min/$max pick extremes", + ), +] + +# Property [Last with Push]: $last picks the last value while $push collects +# all values into an array. The $push array order matches the $sort order. +LAST_WITH_PUSH_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "last_push_basic", + docs=[ + {"cat": "a", "v": 30}, + {"cat": "a", "v": 10}, + {"cat": "a", "v": 20}, + ], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": "$cat", + "last_v": {"$last": "$v"}, + "all_vals": {"$push": "$v"}, + } + }, + ], + expected=[{"_id": "a", "last_v": 30, "all_vals": [10, 20, 30]}], + msg="$last returns last sorted value while $push collects all in sort order", + ), +] + +# Property [Last with AddToSet]: $last picks the last value while $addToSet +# collects unique values. +LAST_WITH_ADDTOSET_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "last_addtoset_with_duplicates", + docs=[ + {"cat": "a", "v": 10}, + {"cat": "a", "v": 20}, + {"cat": "a", "v": 10}, + ], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": "$cat", + "last_v": {"$last": "$v"}, + "unique_vals": {"$addToSet": "$v"}, + } + }, + ], + expected=[{"_id": "a", "last_v": 20, "unique_vals": [10, 20]}], + msg="$last returns last sorted value while $addToSet deduplicates", + ), +] + +# Property [Last with Count]: $last picks the last value while $count +# (via $sum: 1) counts all documents including those with null/missing values. +LAST_WITH_COUNT_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "last_count_basic", + docs=[ + {"cat": "a", "v": 10}, + {"cat": "a", "v": 20}, + {"cat": "a", "v": 30}, + ], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": "$cat", + "last_v": {"$last": "$v"}, + "count": {"$sum": 1}, + } + }, + ], + expected=[{"_id": "a", "last_v": 30, "count": 3}], + msg="$last returns last sorted value while $sum(1) counts all documents", + ), + AccumulatorTestCase( + "last_count_with_null", + docs=[ + {"cat": "a", "v": 10}, + {"cat": "a", "v": None}, + {"cat": "a", "v": 30}, + ], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": "$cat", + "last_v": {"$last": "$v"}, + "count": {"$sum": 1}, + } + }, + ], + expected=[{"_id": "a", "last_v": 30, "count": 3}], + msg="$last returns last sorted value while $sum(1) counts all docs including null", + ), +] + +# Property [Last with MergeObjects]: $last picks the last value while +# $mergeObjects combines per-document metadata into a single object. +LAST_WITH_MERGE_OBJECTS_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "last_merge_objects", + docs=[ + {"cat": "a", "v": 10, "meta": {"src": "x"}}, + {"cat": "a", "v": 20, "meta": {"quality": "high"}}, + ], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": "$cat", + "last_v": {"$last": "$v"}, + "merged": {"$mergeObjects": "$meta"}, + } + }, + ], + expected=[ + {"_id": "a", "last_v": 20, "merged": {"src": "x", "quality": "high"}}, + ], + msg="$last returns last sorted value while $mergeObjects combines metadata", + ), +] + +# Property [Last with Multiple Siblings]: $last coexists with several +# accumulators in the same $group, all computing independently. +LAST_WITH_MULTIPLE_SIBLINGS_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "last_sum_min_max_count", + docs=[ + {"cat": "a", "v": 30}, + {"cat": "a", "v": 10}, + {"cat": "a", "v": 20}, + ], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": "$cat", + "last_v": {"$last": "$v"}, + "total": {"$sum": "$v"}, + "lo": {"$min": "$v"}, + "hi": {"$max": "$v"}, + "count": {"$sum": 1}, + } + }, + ], + expected=[ + {"_id": "a", "last_v": 30, "total": 60, "lo": 10, "hi": 30, "count": 3}, + ], + msg="$last should coexist with $sum, $min, $max, and $sum(1) in same $group", + ), +] + +# Property [Last Type Preservation with Numeric Sibling]: $last preserves +# Decimal128 type while $sum promotes to Decimal128 independently. +LAST_TYPE_PRESERVATION_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "last_decimal128_with_int_sum", + docs=[ + {"cat": "a", "v": Decimal128("1.5")}, + {"cat": "a", "v": Decimal128("2.5")}, + ], + pipeline=[ + {"$sort": {"v": 1}}, + { + "$group": { + "_id": "$cat", + "last_v": {"$last": "$v"}, + "total": {"$sum": "$v"}, + "count": {"$sum": 1}, + } + }, + ], + expected=[ + {"_id": "a", "last_v": Decimal128("2.5"), "total": Decimal128("4.0"), "count": 2}, + ], + msg="$last preserves Decimal128 passthrough while $sum promotes to Decimal128", + ), +] + +# Property [Multiple Last on Different Fields]: multiple $last accumulators +# in the same $group independently pick the last value of different fields. +MULTIPLE_LAST_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "multiple_last_different_fields", + docs=[ + {"cat": "a", "x": 1, "y": "p"}, + {"cat": "a", "x": 2, "y": "q"}, + {"cat": "a", "x": 3, "y": "r"}, + ], + pipeline=[ + {"$sort": {"x": 1}}, + { + "$group": { + "_id": "$cat", + "last_x": {"$last": "$x"}, + "last_y": {"$last": "$y"}, + } + }, + ], + expected=[{"_id": "a", "last_x": 3, "last_y": "r"}], + msg="Multiple $last accumulators should independently pick last value per field", + ), + AccumulatorTestCase( + "multiple_last_field_and_expression", + docs=[ + {"cat": "a", "x": 2, "y": 3}, + {"cat": "a", "x": 4, "y": 5}, + ], + pipeline=[ + {"$sort": {"x": 1}}, + { + "$group": { + "_id": "$cat", + "last_x": {"$last": "$x"}, + "last_product": {"$last": {"$multiply": ["$x", "$y"]}}, + } + }, + ], + expected=[{"_id": "a", "last_x": 4, "last_product": 20}], + msg="$last should independently pick last field value and last computed expression", + ), +] + +LAST_INTEGRATION_TESTS = ( + LAST_WITH_SUM_TESTS + + LAST_WITH_AVG_TESTS + + LAST_WITH_MIN_MAX_TESTS + + LAST_WITH_PUSH_TESTS + + LAST_WITH_ADDTOSET_TESTS + + LAST_WITH_COUNT_TESTS + + LAST_WITH_MERGE_OBJECTS_TESTS + + LAST_WITH_MULTIPLE_SIBLINGS_TESTS + + LAST_TYPE_PRESERVATION_TESTS + + MULTIPLE_LAST_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(LAST_INTEGRATION_TESTS)) +def test_accumulators_last_integration(collection, test_case: AccumulatorTestCase): + """Test $last 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"], + ) From 765eb72b45de2678244869c4dab8e91f11dea3f3 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" Date: Fri, 22 May 2026 15:58:59 -0700 Subject: [PATCH 11/14] Use assertResult to be consistent Signed-off-by: Alina (Xi) Li --- .../core/operator/accumulators/last/test_accumulator_last.py | 4 ++-- .../accumulators/last/test_accumulator_last_bson_types.py | 4 ++-- .../operator/accumulators/last/test_accumulator_last_smoke.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last.py index 38619364..7a03a770 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last.py @@ -11,7 +11,7 @@ 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.assertions import assertResult from documentdb_tests.framework.executor import execute_command from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import ( @@ -609,4 +609,4 @@ def test_accumulator_last(collection, test_case: AccumulatorTestCase): collection, {"aggregate": collection.name, "pipeline": test_case.pipeline, "cursor": {}}, ) - assertSuccess(result, test_case.expected, msg=test_case.msg) + assertResult(result, expected=test_case.expected, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_bson_types.py index faee9c7b..22267c2a 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_bson_types.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_bson_types.py @@ -20,7 +20,7 @@ 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.assertions import assertResult from documentdb_tests.framework.executor import execute_command from documentdb_tests.framework.parametrize import pytest_params @@ -246,4 +246,4 @@ def test_accumulator_last_bson_types(collection, test_case: AccumulatorTestCase) collection, {"aggregate": collection.name, "pipeline": test_case.pipeline, "cursor": {}}, ) - assertSuccess(result, test_case.expected, msg=test_case.msg) + assertResult(result, expected=test_case.expected, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_smoke.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_smoke.py index 09faf9bd..86f1c3c7 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_smoke.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_smoke.py @@ -6,7 +6,7 @@ import pytest -from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.assertions import assertResult from documentdb_tests.framework.executor import execute_command pytestmark = pytest.mark.smoke @@ -35,4 +35,4 @@ def test_smoke_accumulator_last(collection): ) expected = [{"_id": "A", "lastValue": 30}] - assertSuccess(result, expected, msg="Should support $last accumulator") + assertResult(result, expected=expected, msg="Should support $last accumulator") From 987fce24ed13d628d7544dcbcf1c92a43608285c Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" Date: Fri, 22 May 2026 16:02:12 -0700 Subject: [PATCH 12/14] rename to be clearer Signed-off-by: Alina (Xi) Li --- ...ast_group_types.py => test_accumulator_last_return_types.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename documentdb_tests/compatibility/tests/core/operator/accumulators/last/{test_accumulator_last_group_types.py => test_accumulator_last_return_types.py} (99%) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_group_types.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_return_types.py similarity index 99% rename from documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_group_types.py rename to documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_return_types.py index f3b06208..eaf4d4d8 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_group_types.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_return_types.py @@ -218,7 +218,7 @@ @pytest.mark.parametrize("test_case", pytest_params(LAST_RETURN_TYPE_TESTS)) -def test_last_group_types(collection, test_case: AccumulatorTestCase): +def test_accumulator_last_return_types(collection, test_case: AccumulatorTestCase): """Test $last return type preservation via $type projection.""" if test_case.docs: collection.insert_many(test_case.docs) From d96d43c4f282390d429e56892581046b93b4b423 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" Date: Tue, 26 May 2026 12:14:18 -0700 Subject: [PATCH 13/14] Rename smoke tests Signed-off-by: Alina (Xi) Li --- ...t_accumulator_last_smoke.py => test_smoke_accumulator_last.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename documentdb_tests/compatibility/tests/core/operator/accumulators/last/{test_accumulator_last_smoke.py => test_smoke_accumulator_last.py} (100%) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_smoke.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_smoke_accumulator_last.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_smoke.py rename to documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_smoke_accumulator_last.py From 5567d63b26586f68fbdc3c6fefb65490b4333983 Mon Sep 17 00:00:00 2001 From: "Alina (Xi) Li" Date: Wed, 27 May 2026 15:44:56 -0700 Subject: [PATCH 14/14] Address comments Add tests: arity error tests, BSON constant tests, expression tests, empty-group behavior, and order dependence tests. Removed tests. Signed-off-by: Alina (Xi) Li --- .../last/test_accumulator_last.py | 313 +++++++++++++++++- .../last/test_accumulator_last_bson_types.py | 12 - .../last/test_accumulator_last_errors.py | 117 +++++++ .../test_accumulator_last_return_types.py | 13 +- 4 files changed, 429 insertions(+), 26 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_errors.py diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last.py index 7a03a770..bee03b0a 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last.py @@ -1,12 +1,22 @@ """Tests for $last accumulator: null/missing, sort order, special numerics, boundaries, -arrays, expressions, mixed types.""" +arrays, expressions, BSON constants, mixed types.""" from __future__ import annotations import math +from datetime import datetime, timezone import pytest -from bson import Decimal128 +from bson import ( + Binary, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) from documentdb_tests.compatibility.tests.core.operator.accumulators.utils.accumulator_test_case import ( # noqa: E501 AccumulatorTestCase, @@ -589,6 +599,302 @@ ), ] +# --------------------------------------------------------------------------- +# Property [BSON Constant Arguments]: $last accepts BSON constants as the +# accumulator argument. The constant is returned for every document, so +# the "last" value is that constant. +# --------------------------------------------------------------------------- +LAST_BSON_CONSTANT_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "const_true", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 2}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": True}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": True}], + msg="$last with boolean True constant should return True", + ), + AccumulatorTestCase( + "const_false", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 2}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": False}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": False}], + msg="$last with boolean False constant should return False", + ), + AccumulatorTestCase( + "const_int64", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 2}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": Int64(42)}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": Int64(42)}], + msg="$last with Int64 constant should return that Int64 value", + ), + AccumulatorTestCase( + "const_double", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 2}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": 3.14}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": 3.14}], + msg="$last with double constant should return that double value", + ), + AccumulatorTestCase( + "const_decimal128", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 2}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": Decimal128("3.14")}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": Decimal128("3.14")}], + msg="$last with Decimal128 constant should return that Decimal128 value", + ), + AccumulatorTestCase( + "const_string", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 2}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "hello"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": "hello"}], + msg="$last with string constant (no $) should return that string", + ), + AccumulatorTestCase( + "const_binary", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 2}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": Binary(b"\x01\x02")}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": b"\x01\x02"}], + msg="$last with Binary constant should return that Binary value", + ), + AccumulatorTestCase( + "const_objectid", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 2}], + pipeline=[ + {"$sort": {"_id": 1}}, + { + "$group": { + "_id": None, + "result": {"$last": ObjectId("000000000000000000000000")}, + } + }, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": ObjectId("000000000000000000000000")}], + msg="$last with ObjectId constant should return that ObjectId", + ), + AccumulatorTestCase( + "const_datetime", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 2}], + pipeline=[ + {"$sort": {"_id": 1}}, + { + "$group": { + "_id": None, + "result": {"$last": datetime(2020, 1, 1, tzinfo=timezone.utc)}, + } + }, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": datetime(2020, 1, 1, tzinfo=timezone.utc)}], + msg="$last with datetime constant should return that datetime", + ), + AccumulatorTestCase( + "const_timestamp", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 2}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": Timestamp(1, 1)}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": Timestamp(1, 1)}], + msg="$last with Timestamp constant should return that Timestamp", + ), + AccumulatorTestCase( + "const_regex", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 2}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": Regex("abc", "i")}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": Regex("abc", "i")}], + msg="$last with Regex constant should return that Regex", + ), + AccumulatorTestCase( + "const_null", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 2}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": None}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": None}], + msg="$last with null constant should return null", + ), + AccumulatorTestCase( + "const_minkey", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 2}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": MinKey()}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": {"": MinKey()}}], + msg="$last with MinKey constant should return MinKey wrapped in document", + ), + AccumulatorTestCase( + "const_maxkey", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 2}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": MaxKey()}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": {"": MaxKey()}}], + msg="$last with MaxKey constant should return MaxKey wrapped in document", + ), +] + +# --------------------------------------------------------------------------- +# Property [Expression Types]: $last accepts various expression types as +# its operand and evaluates them per document before picking the last. +# --------------------------------------------------------------------------- +LAST_EXPRESSION_TYPE_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "expr_type_operator_single", + docs=[{"_id": 0, "v": -10}, {"_id": 1, "v": 20}, {"_id": 2, "v": -5}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": {"$abs": "$v"}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": 5}], + msg="$last should accept single-input expression operator", + ), + AccumulatorTestCase( + "expr_type_operator_multi_arg", + docs=[ + {"_id": 0, "v": -10, "w": 3}, + {"_id": 1, "v": 20, "w": 7}, + {"_id": 2, "v": -5, "w": 1}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + { + "$group": { + "_id": None, + "result": {"$last": {"$add": ["$v", "$w"]}}, + } + }, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": -4}], + msg="$last should accept a multi-arg expression operator", + ), + AccumulatorTestCase( + "expr_type_nested", + docs=[{"_id": 0, "v": -10}, {"_id": 1, "v": 20}, {"_id": 2, "v": -5}], + pipeline=[ + {"$sort": {"_id": 1}}, + { + "$group": { + "_id": None, + "result": {"$last": {"$add": [1, {"$abs": "$v"}]}}, + } + }, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": 6}], + msg="$last should accept nested expression operators", + ), + AccumulatorTestCase( + "expr_type_sysvar_remove", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 2}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$$REMOVE"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": None}], + msg="$last with $$REMOVE should treat value as missing and return null", + ), + AccumulatorTestCase( + "expr_type_object_expression", + docs=[{"_id": 0, "v": 10}, {"_id": 1, "v": 20}, {"_id": 2, "v": 5}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": {"a": "$v"}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": {"a": 5}}], + msg="$last should accept an object expression", + ), + AccumulatorTestCase( + "expr_type_object_with_operator", + docs=[{"_id": 0, "v": -10}, {"_id": 1, "v": 20}, {"_id": 2, "v": -5}], + pipeline=[ + {"$sort": {"_id": 1}}, + { + "$group": { + "_id": None, + "result": {"$last": {"a": {"$abs": "$v"}}}, + } + }, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": {"a": 5}}], + msg="$last should accept an object expression containing an operator", + ), + AccumulatorTestCase( + "expr_type_let", + docs=[{"_id": 0, "v": 10}, {"_id": 1, "v": 20}, {"_id": 2, "v": 5}], + pipeline=[ + {"$sort": {"_id": 1}}, + { + "$group": { + "_id": None, + "result": {"$last": {"$let": {"vars": {"x": "$v"}, "in": "$$x"}}}, + } + }, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": 5}], + msg="$last should accept a $let expression as its operand", + ), +] + +# --------------------------------------------------------------------------- +# Property [Empty-Group Behavior]: $last on empty collection produces no groups. +# --------------------------------------------------------------------------- +LAST_EMPTY_GROUP_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "empty_collection", + docs=[], + pipeline=[ + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[], + msg="$last on empty collection should produce no groups (empty result)", + ), +] + LAST_SUCCESS_TESTS = ( LAST_NULL_MISSING_TESTS + LAST_SORT_ORDER_TESTS @@ -597,6 +903,9 @@ + LAST_ARRAY_TESTS + LAST_EXPRESSION_TESTS + LAST_MIXED_TYPE_TESTS + + LAST_BSON_CONSTANT_TESTS + + LAST_EXPRESSION_TYPE_TESTS + + LAST_EMPTY_GROUP_TESTS ) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_bson_types.py index 22267c2a..a8abccda 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_bson_types.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_bson_types.py @@ -7,7 +7,6 @@ import pytest from bson import ( Binary, - Code, Decimal128, Int64, MaxKey, @@ -190,17 +189,6 @@ expected=[{"result": Regex("^abc", "i")}], msg="$last should return Regex unchanged", ), - AccumulatorTestCase( - "bson_code", - docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Code("function(){}")}], - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$last": "$v"}}}, - {"$project": {"_id": 0, "result": 1}}, - ], - expected=[{"result": "function(){}"}], - msg="$last should return Code as string via runCommand", - ), AccumulatorTestCase( "bson_timestamp", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Timestamp(1, 1)}], diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_errors.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_errors.py new file mode 100644 index 00000000..a4a2dac2 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_errors.py @@ -0,0 +1,117 @@ +"""Tests for $last accumulator error cases: arity rejection and expression 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, + 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]: $last in accumulator context is a unary operator and +# rejects array syntax. +LAST_ARITY_ERROR_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "arity_empty_array", + docs=[{"v": 1}], + pipeline=[ + {"$group": {"_id": None, "result": {"$last": []}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + error_code=GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, + msg="$last should reject empty array in accumulator context", + ), + AccumulatorTestCase( + "arity_single_element_array", + docs=[{"v": 1}], + pipeline=[ + {"$group": {"_id": None, "result": {"$last": [1]}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + error_code=GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, + msg="$last should reject single-element array in accumulator context", + ), + AccumulatorTestCase( + "arity_single_field_ref_array", + docs=[{"v": 1}], + pipeline=[ + {"$group": {"_id": None, "result": {"$last": ["$v"]}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + error_code=GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, + msg="$last should reject single field ref in array in accumulator context", + ), + AccumulatorTestCase( + "arity_multi_element_array", + docs=[{"v": 1}], + pipeline=[ + {"$group": {"_id": None, "result": {"$last": [1, 2, 3]}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + error_code=GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, + msg="$last should reject multi-element array in accumulator context", + ), + AccumulatorTestCase( + "arity_multi_key_expression_object", + docs=[{"v": 1}], + pipeline=[ + { + "$group": { + "_id": None, + "result": {"$last": {"$add": [1, 2], "$multiply": [3, 4]}}, + } + }, + {"$project": {"_id": 0, "result": 1}}, + ], + error_code=EXPRESSION_OBJECT_MULTIPLE_FIELDS_ERROR, + msg="$last should reject multi-key expression object", + ), +] + +# Property [Expression Error Propagation]: errors raised during sub-expression +# evaluation propagate through the accumulator without being caught. +LAST_EXPRESSION_ERROR_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "expr_error_divide_by_zero", + docs=[{"v": 1}], + pipeline=[ + {"$group": {"_id": None, "result": {"$last": {"$divide": ["$v", 0]}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + error_code=DIVIDE_BY_ZERO_V2_ERROR, + msg="$last should propagate $divide by zero error", + ), + AccumulatorTestCase( + "expr_error_conversion_failure", + docs=[{"v": "not_a_number"}], + pipeline=[ + {"$group": {"_id": None, "result": {"$last": {"$toInt": "$v"}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + error_code=CONVERSION_FAILURE_ERROR, + msg="$last should propagate $toInt conversion error", + ), +] + +LAST_ERROR_TESTS = LAST_ARITY_ERROR_TESTS + LAST_EXPRESSION_ERROR_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(LAST_ERROR_TESTS)) +def test_accumulator_last_errors(collection, test_case): + """Test $last 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/last/test_accumulator_last_return_types.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_return_types.py index eaf4d4d8..204d7e4d 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_return_types.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_return_types.py @@ -9,7 +9,7 @@ from datetime import datetime, timezone import pytest -from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp from documentdb_tests.compatibility.tests.core.operator.accumulators.utils.accumulator_test_case import ( # noqa: E501 AccumulatorTestCase, @@ -170,17 +170,6 @@ expected=[{"value": Regex("abc", "i"), "type": "regex"}], msg="$last should preserve Regex type", ), - AccumulatorTestCase( - "type_code", - docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Code("function(){}")}], - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$last": "$v"}}}, - {"$project": {"_id": 0, "value": "$result", "type": {"$type": "$result"}}}, - ], - expected=[{"value": "function(){}", "type": "string"}], - msg="$last should return Code as string via runCommand", - ), AccumulatorTestCase( "type_timestamp", docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": Timestamp(1, 1)}],