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 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..bee03b0a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last.py @@ -0,0 +1,921 @@ +"""Tests for $last accumulator: null/missing, sort order, special numerics, boundaries, +arrays, expressions, BSON constants, mixed types.""" + +from __future__ import annotations + +import math +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.operator.accumulators.utils.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 +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, +) + +# 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=[ + {"$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=[ + {"$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=[ + {"$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=[ + {"$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_first_value_last", + docs=[{"_id": 0, "v": None}, {"_id": 1, "v": 10}], + 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_first_value_last", + docs=[{"_id": 0}, {"_id": 1, "v": 10}], + 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=[ + {"$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=[ + {"$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", + ), +] + +# 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. +LAST_SPECIAL_NUMERIC_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "nan_double", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": FLOAT_NAN}], + 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=[ + {"$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=[ + {"$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=[ + {"$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=[ + {"$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=[ + {"$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=[ + {"$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=[ + {"$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", + ), +] + +# 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=[ + {"$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=[ + {"$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=[ + {"$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=[ + {"$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=[ + {"$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=[ + {"$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=[ + {"$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=[ + {"$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", + ), +] + +# 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=[ + {"$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=[ + {"$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=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$last": "$v"}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + 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=[ + {"$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=[ + {"$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", + ), + AccumulatorTestCase( + "array_mixed_types", + docs=[ + {"_id": 0, "v": 1}, + {"_id": 1, "v": [1, "two", None, True]}, + ], + 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", + ), +] + +# Property [Expression Arguments]: $last accepts various expression types +# beyond simple field paths. +LAST_EXPRESSION_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "expr_nested_field_path", + docs=[ + {"_id": 0, "a": {"b": 10}}, + {"_id": 1, "a": {"b": 20}}, + ], + 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", + ), + AccumulatorTestCase( + "expr_three_level_field_path", + docs=[ + {"_id": 0, "a": {"b": {"c": 10}}}, + {"_id": 1, "a": {"b": {"c": 20}}}, + ], + 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", + ), + 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_multiply_subexpression", + 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_cond_subexpression", + 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_bare_constant", + 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=[ + {"$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", + ), +] + +# 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=[ + {"$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", + ), +] + +# --------------------------------------------------------------------------- +# 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 + + LAST_SPECIAL_NUMERIC_TESTS + + LAST_BOUNDARY_TESTS + + LAST_ARRAY_TESTS + + LAST_EXPRESSION_TESTS + + LAST_MIXED_TYPE_TESTS + + LAST_BSON_CONSTANT_TESTS + + LAST_EXPRESSION_TYPE_TESTS + + LAST_EMPTY_GROUP_TESTS +) + + +@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) + 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_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..a8abccda --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_bson_types.py @@ -0,0 +1,237 @@ +"""Tests for $last accumulator: BSON type passthrough.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.operator.accumulators.utils.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 [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_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": {}}, + ) + assertResult(result, expected=test_case.expected, msg=test_case.msg) 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 new file mode 100644 index 00000000..204d7e4d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_accumulator_last_return_types.py @@ -0,0 +1,218 @@ +"""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, 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 + +# 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=[ + {"$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=[ + {"$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=[ + {"$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=[ + {"$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=[ + {"$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=[ + {"$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", + ), + AccumulatorTestCase( + "type_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, "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=[ + {"$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=[ + {"$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=[ + {"$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=[ + {"$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", + ), + AccumulatorTestCase( + "type_objectid", + docs=[ + {"_id": 0, "v": 1}, + {"_id": 1, "v": ObjectId("507f1f77bcf86cd799439011")}, + ], + 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=[ + {"$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_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, "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=[ + {"$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=[ + {"$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", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(LAST_RETURN_TYPE_TESTS)) +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) + 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_smoke_accumulator_last.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_smoke_accumulator_last.py index 09faf9bd..86f1c3c7 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_smoke_accumulator_last.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/last/test_smoke_accumulator_last.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") 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"], + )