From 66708d57339daa5a5d4f33ee7cdbb1c19363216e Mon Sep 17 00:00:00 2001 From: Sunita Bhattacharya Date: Tue, 30 Jun 2026 21:50:20 -0700 Subject: [PATCH 1/4] Add $lastN accumulator compatibility tests Add compatibility tests for the $lastN accumulator, covering both success and error behavior: - test_accumulator_lastN.py: n vs group size, sort-order dependence, null/missing handling (included, not skipped), mixed BSON types, and empty groups. - test_accumulator_lastN_errors.py: missing, zero, negative, and non-integer n. Missing n fails with N_ACCUMULATOR_MISSING_N_FIRSTN_FAMILY_ERROR (5787906); any other invalid n fails with N_ACCUMULATOR_INVALID_N_ERROR (7548606), matching the existing $group N-accumulator error tests. Signed-off-by: Sunita Bhattacharya --- .../operator/accumulators/lastN/__init__.py | 0 .../lastN/test_accumulator_lastN.py | 279 ++++++++++++++++++ .../lastN/test_accumulator_lastN_errors.py | 112 +++++++ 3 files changed, 391 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/accumulators/lastN/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/accumulators/lastN/test_accumulator_lastN.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/accumulators/lastN/test_accumulator_lastN_errors.py diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/lastN/__init__.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/lastN/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/lastN/test_accumulator_lastN.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/lastN/test_accumulator_lastN.py new file mode 100644 index 000000000..da7a1fe44 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/lastN/test_accumulator_lastN.py @@ -0,0 +1,279 @@ +"""Tests for $lastN accumulator: n-vs-size behavior, sort order, null/missing, +mixed types, and empty-group handling. + +$lastN returns the last ``n`` elements of a group as an array, in the order +determined by the preceding $sort stage. Like $last (and unlike numeric +accumulators), $lastN does NOT skip null or missing values -- they are included +in the returned array as null.""" + +from __future__ import annotations + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.accumulators.utils.accumulator_test_case import ( # noqa: E501 + AccumulatorTestCase, +) +from documentdb_tests.framework.assertions import assertSuccessNaN +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + FLOAT_INFINITY, + FLOAT_NAN, +) + +# Property [n vs Group Size]: $lastN returns min(n, group size) elements. When n +# exceeds the number of documents, all available values are returned; n == 1 +# returns a single-element list (not a scalar). +LASTN_N_VALUE_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "n_greater_than_size", + docs=[{"_id": 0, "v": 10}, {"_id": 1, "v": 20}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$lastN": {"n": 5, "input": "$v"}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": [10, 20]}], + msg="$lastN should return all available values when n exceeds group size", + ), + AccumulatorTestCase( + "n_equal_to_size", + docs=[{"_id": 0, "v": 10}, {"_id": 1, "v": 20}, {"_id": 2, "v": 30}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$lastN": {"n": 3, "input": "$v"}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": [10, 20, 30]}], + msg="$lastN should return all values when n equals group size", + ), + AccumulatorTestCase( + "n_less_than_size", + docs=[{"_id": 0, "v": 10}, {"_id": 1, "v": 20}, {"_id": 2, "v": 30}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$lastN": {"n": 2, "input": "$v"}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": [20, 30]}], + msg="$lastN should return only the last n values when n is less than group size", + ), + AccumulatorTestCase( + "n_equals_one", + docs=[{"_id": 0, "v": 10}, {"_id": 1, "v": 20}, {"_id": 2, "v": 30}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$lastN": {"n": 1, "input": "$v"}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": [30]}], + msg="$lastN with n=1 should return a single-element list, not a scalar", + ), + AccumulatorTestCase( + "n_as_long", + docs=[{"_id": 0, "v": 10}, {"_id": 1, "v": 20}, {"_id": 2, "v": 30}], + pipeline=[ + {"$sort": {"_id": 1}}, + { + "$group": { + "_id": None, + "result": {"$lastN": {"n": {"$toLong": 2}, "input": "$v"}}, + } + }, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": [20, 30]}], + msg="$lastN should accept a long-typed n value", + ), +] + +# Property [Sort Order Dependency]: $lastN returns the last n values as ordered +# by the preceding $sort stage, preserving that order in the output array. +LASTN_SORT_ORDER_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "sort_ascending", + docs=[{"_id": 0, "v": 30}, {"_id": 1, "v": 10}, {"_id": 2, "v": 20}], + pipeline=[ + {"$sort": {"v": 1}}, + {"$group": {"_id": None, "result": {"$lastN": {"n": 2, "input": "$v"}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": [20, 30]}], + msg="$lastN should return the highest values, in order, when sorted ascending", + ), + AccumulatorTestCase( + "sort_descending", + docs=[{"_id": 0, "v": 30}, {"_id": 1, "v": 10}, {"_id": 2, "v": 20}], + pipeline=[ + {"$sort": {"v": -1}}, + {"$group": {"_id": None, "result": {"$lastN": {"n": 2, "input": "$v"}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": [20, 10]}], + msg="$lastN should return the lowest values, in order, 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": {"$lastN": {"n": 2, "input": "$v"}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": ["b", "c"]}], + msg="$lastN should return values from the documents with the highest sort keys", + ), + 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": {"$lastN": {"n": 2, "input": "$v"}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": ["a2", "b1"]}], + msg="$lastN should return the last values by compound sort order", + ), +] + +# Property [Null and Missing Handling]: $lastN includes null and missing values +# in the returned array (missing fields become null). It does NOT skip them. +LASTN_NULL_MISSING_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "null_in_last_n", + docs=[{"_id": 0, "v": 10}, {"_id": 1, "v": 20}, {"_id": 2, "v": None}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$lastN": {"n": 2, "input": "$v"}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": [20, None]}], + msg="$lastN should include a null value present in the last n documents", + ), + AccumulatorTestCase( + "missing_in_last_n", + docs=[{"_id": 0, "v": 10}, {"_id": 1, "v": 20}, {"_id": 2}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$lastN": {"n": 2, "input": "$v"}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": [20, None]}], + msg="$lastN should include a missing field in the last n documents as null", + ), + AccumulatorTestCase( + "all_null", + docs=[{"_id": 0, "v": None}, {"_id": 1, "v": None}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$lastN": {"n": 2, "input": "$v"}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": [None, None]}], + msg="$lastN should return all null values when every document is null", + ), + AccumulatorTestCase( + "all_missing", + docs=[{"_id": 0}, {"_id": 1}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$lastN": {"n": 2, "input": "$v"}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": [None, None]}], + msg="$lastN should return nulls when every document is missing the field", + ), +] + +# Property [Mixed BSON Types]: $lastN performs no type checking and returns +# whatever values the last n documents hold, preserving type and order. +LASTN_MIXED_TYPE_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "mixed_types_preserved", + docs=[ + {"_id": 0, "v": 1}, + {"_id": 1, "v": "hello"}, + {"_id": 2, "v": True}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$lastN": {"n": 3, "input": "$v"}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": [1, "hello", True]}], + msg="$lastN should preserve mixed BSON types in the returned array", + ), + AccumulatorTestCase( + "arrays_preserved", + docs=[ + {"_id": 0, "v": [1, 2]}, + {"_id": 1, "v": [3, 4]}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$lastN": {"n": 2, "input": "$v"}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": [[1, 2], [3, 4]]}], + msg="$lastN should return array-valued elements without traversal", + ), + AccumulatorTestCase( + "special_numerics_preserved", + docs=[ + {"_id": 0, "v": FLOAT_NAN}, + {"_id": 1, "v": FLOAT_INFINITY}, + {"_id": 2, "v": Decimal128("NaN")}, + ], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$lastN": {"n": 3, "input": "$v"}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[{"result": [FLOAT_NAN, FLOAT_INFINITY, Decimal128("NaN")]}], + msg="$lastN should pass through special numeric values unchanged", + ), +] + +# Property [Empty-Group Behavior]: $lastN on an empty collection produces no +# groups (an empty result set), matching $last. +LASTN_EMPTY_GROUP_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "empty_collection", + docs=[], + pipeline=[ + {"$group": {"_id": None, "result": {"$lastN": {"n": 2, "input": "$v"}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[], + msg="$lastN on an empty collection should produce no groups", + ), +] + +LASTN_SUCCESS_TESTS = ( + LASTN_N_VALUE_TESTS + + LASTN_SORT_ORDER_TESTS + + LASTN_NULL_MISSING_TESTS + + LASTN_MIXED_TYPE_TESTS + + LASTN_EMPTY_GROUP_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(LASTN_SUCCESS_TESTS)) +def test_accumulator_lastN(collection, test_case: AccumulatorTestCase): + """Test $lastN 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": {}}, + ) + assertSuccessNaN(result, test_case.expected, msg=test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/lastN/test_accumulator_lastN_errors.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/lastN/test_accumulator_lastN_errors.py new file mode 100644 index 000000000..da36cccd9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/lastN/test_accumulator_lastN_errors.py @@ -0,0 +1,112 @@ +"""Tests for $lastN accumulator error cases: missing ``n`` and invalid ``n`` values. + +$lastN requires an ``n`` argument that evaluates to a positive integer. A +missing ``n`` raises N_ACCUMULATOR_MISSING_N_FIRSTN_FAMILY_ERROR (5787906); +any other invalid ``n`` -- zero, negative, or non-integer -- raises the generic +N_ACCUMULATOR_INVALID_N_ERROR (7548606).""" + +from __future__ import annotations + +import pytest +from bson import Decimal128 + +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 ( + N_ACCUMULATOR_INVALID_N_ERROR, + N_ACCUMULATOR_MISSING_N_FIRSTN_FAMILY_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Missing n]: $lastN requires the ``n`` field in its argument object. +LASTN_MISSING_N_ERROR_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "missing_n", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 2}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$lastN": {"input": "$v"}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + error_code=N_ACCUMULATOR_MISSING_N_FIRSTN_FAMILY_ERROR, + msg="$lastN should reject an argument object that omits n", + ), +] + +# Property [Non-Positive n]: ``n`` must be greater than zero. Zero and negative +# values are rejected with N_ACCUMULATOR_INVALID_N_ERROR. +LASTN_NON_POSITIVE_N_ERROR_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "n_zero", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 2}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$lastN": {"n": 0, "input": "$v"}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + error_code=N_ACCUMULATOR_INVALID_N_ERROR, + msg="$lastN should reject n = 0", + ), + AccumulatorTestCase( + "n_negative", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 2}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$lastN": {"n": -1, "input": "$v"}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + error_code=N_ACCUMULATOR_INVALID_N_ERROR, + msg="$lastN should reject a negative n", + ), +] + +# Property [Non-Integer n]: ``n`` must be integral. Fractional double and +# Decimal128 values are rejected with N_ACCUMULATOR_INVALID_N_ERROR. +LASTN_NON_INTEGER_N_ERROR_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "n_non_integer_double", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 2}], + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": None, "result": {"$lastN": {"n": 2.5, "input": "$v"}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + error_code=N_ACCUMULATOR_INVALID_N_ERROR, + msg="$lastN should reject a non-integer double n", + ), + AccumulatorTestCase( + "n_non_integer_decimal128", + docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 2}], + pipeline=[ + {"$sort": {"_id": 1}}, + { + "$group": { + "_id": None, + "result": {"$lastN": {"n": Decimal128("1.5"), "input": "$v"}}, + } + }, + {"$project": {"_id": 0, "result": 1}}, + ], + error_code=N_ACCUMULATOR_INVALID_N_ERROR, + msg="$lastN should reject a non-integer Decimal128 n", + ), +] + +LASTN_ERROR_TESTS = ( + LASTN_MISSING_N_ERROR_TESTS + LASTN_NON_POSITIVE_N_ERROR_TESTS + LASTN_NON_INTEGER_N_ERROR_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(LASTN_ERROR_TESTS)) +def test_accumulator_lastN_errors(collection, test_case): + """Test $lastN 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) From fbb4bf8c79299c8cecbd3302f1abc9e1c010b627 Mon Sep 17 00:00:00 2001 From: Sunita Bhattacharya Date: Tue, 21 Jul 2026 16:57:28 -0700 Subject: [PATCH 2/4] Added 7 error codes for a related issue (expression operator tests) Signed-off-by: Sunita Bhattacharya --- documentdb_tests/framework/error_codes.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 2375b9dcd..03e352c76 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -465,8 +465,15 @@ MODULO_DECIMAL128_ZERO_REMAINDER_ERROR = 5733415 MERGE_INTO_EMPTY_STRING_ERROR = 5786800 MERGE_INTO_COLL_NULL_ERROR = 5786801 +N_ACCUMULATOR_SPEC_NOT_OBJECT_ERROR = 5787801 +N_ACCUMULATOR_UNKNOWN_ARGUMENT_ERROR = 5787901 +N_ACCUMULATOR_N_NOT_NUMERIC_ERROR = 5787902 +N_ACCUMULATOR_N_NOT_INTEGRAL_ERROR = 5787903 N_ACCUMULATOR_MISSING_N_FIRSTN_FAMILY_ERROR = 5787906 +N_ACCUMULATOR_MISSING_INPUT_FIRSTN_FAMILY_ERROR = 5787907 +N_ACCUMULATOR_N_NOT_POSITIVE_ERROR = 5787908 N_ACCUMULATOR_MISSING_N_TOPN_FAMILY_ERROR = 5788003 +N_EXPRESSION_INPUT_NOT_ARRAY_ERROR = 5788200 GEO_NEAR_NEAR_REQUIRED_ERROR = 5860400 GEO_NEAR_NEAR_TYPE_ERROR = 5860401 GEO_NEAR_NEAR_NOT_CONSTANT_ERROR = 5860402 From 6fde77a232a3d6593dcb89404ad741eaba4042ba Mon Sep 17 00:00:00 2001 From: Sunita Bhattacharya Date: Sun, 9 Aug 2026 02:47:23 -0700 Subject: [PATCH 3/4] Expand $lastN accumulator test coverage for #473 test_accumulator_lastN_errors.py: add error cases for malformed specification, missing input, and non-coercible n. test_accumulator_lastN.py: add behavior cases for per-group accumulation and filtered-to-empty groups. Signed-off-by: Sunita Bhattacharya --- .../lastN/test_accumulator_lastN.py | 60 +++++-- .../lastN/test_accumulator_lastN_errors.py | 167 ++++++++++++++---- 2 files changed, 181 insertions(+), 46 deletions(-) diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/lastN/test_accumulator_lastN.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/lastN/test_accumulator_lastN.py index da7a1fe44..95f52f2d1 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/lastN/test_accumulator_lastN.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/lastN/test_accumulator_lastN.py @@ -1,10 +1,11 @@ -"""Tests for $lastN accumulator: n-vs-size behavior, sort order, null/missing, -mixed types, and empty-group handling. +"""Tests for $lastN accumulator: n-vs-size behavior, per-group accumulation, sort +order, null/missing, mixed types, and empty-group handling. -$lastN returns the last ``n`` elements of a group as an array, in the order -determined by the preceding $sort stage. Like $last (and unlike numeric -accumulators), $lastN does NOT skip null or missing values -- they are included -in the returned array as null.""" +$lastN returns the last ``n`` elements of a group as an array, ordered by the +preceding $sort stage. + +Not covered by design: pipelines with no $sort, and tied sort keys -- which +documents land in the last n is unspecified, so any assertion would be flaky.""" from __future__ import annotations @@ -22,9 +23,8 @@ FLOAT_NAN, ) -# Property [n vs Group Size]: $lastN returns min(n, group size) elements. When n -# exceeds the number of documents, all available values are returned; n == 1 -# returns a single-element list (not a scalar). +# Property [n vs Group Size]: $lastN returns min(n, group size) elements, and +# n == 1 returns a single-element list rather than a scalar. LASTN_N_VALUE_TESTS: list[AccumulatorTestCase] = [ AccumulatorTestCase( "n_greater_than_size", @@ -88,6 +88,29 @@ ), ] +# Property [Per-Group Accumulation]: each group accumulates independently and n +# applies per group, so a group with fewer than n documents yields only what it has. +LASTN_MULTIPLE_GROUP_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "groups_two_independent", + docs=[ + {"_id": 0, "cat": "A", "v": 10}, + {"_id": 1, "cat": "A", "v": 20}, + {"_id": 2, "cat": "A", "v": 30}, + {"_id": 3, "cat": "B", "v": 40}, + ], + # No $project: _id carries the group identity under test. The trailing + # $sort makes document order deterministic. + pipeline=[ + {"$sort": {"_id": 1}}, + {"$group": {"_id": "$cat", "result": {"$lastN": {"n": 2, "input": "$v"}}}}, + {"$sort": {"_id": 1}}, + ], + expected=[{"_id": "A", "result": [20, 30]}, {"_id": "B", "result": [40]}], + msg="$lastN should accumulate independently per group", + ), +] + # Property [Sort Order Dependency]: $lastN returns the last n values as ordered # by the preceding $sort stage, preserving that order in the output array. LASTN_SORT_ORDER_TESTS: list[AccumulatorTestCase] = [ @@ -146,7 +169,7 @@ ] # Property [Null and Missing Handling]: $lastN includes null and missing values -# in the returned array (missing fields become null). It does NOT skip them. +# (missing becomes null) rather than skipping them, unlike numeric accumulators. LASTN_NULL_MISSING_TESTS: list[AccumulatorTestCase] = [ AccumulatorTestCase( "null_in_last_n", @@ -243,8 +266,8 @@ ), ] -# Property [Empty-Group Behavior]: $lastN on an empty collection produces no -# groups (an empty result set), matching $last. +# Property [Empty-Group Behavior]: $lastN produces no groups when no documents +# reach $group, whether the collection is empty or $match excluded them all. LASTN_EMPTY_GROUP_TESTS: list[AccumulatorTestCase] = [ AccumulatorTestCase( "empty_collection", @@ -256,10 +279,22 @@ expected=[], msg="$lastN on an empty collection should produce no groups", ), + AccumulatorTestCase( + "all_documents_filtered_out", + docs=[{"_id": 0, "cat": "A", "v": 10}, {"_id": 1, "cat": "B", "v": 20}], + pipeline=[ + {"$match": {"cat": "Z"}}, + {"$group": {"_id": None, "result": {"$lastN": {"n": 2, "input": "$v"}}}}, + {"$project": {"_id": 0, "result": 1}}, + ], + expected=[], + msg="$lastN should produce no groups when every document is filtered out", + ), ] LASTN_SUCCESS_TESTS = ( LASTN_N_VALUE_TESTS + + LASTN_MULTIPLE_GROUP_TESTS + LASTN_SORT_ORDER_TESTS + LASTN_NULL_MISSING_TESTS + LASTN_MIXED_TYPE_TESTS @@ -267,6 +302,7 @@ ) +@pytest.mark.aggregate @pytest.mark.parametrize("test_case", pytest_params(LASTN_SUCCESS_TESTS)) def test_accumulator_lastN(collection, test_case: AccumulatorTestCase): """Test $lastN accumulator success cases.""" diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/lastN/test_accumulator_lastN_errors.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/lastN/test_accumulator_lastN_errors.py index da36cccd9..ed9d9bbde 100644 --- a/documentdb_tests/compatibility/tests/core/operator/accumulators/lastN/test_accumulator_lastN_errors.py +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/lastN/test_accumulator_lastN_errors.py @@ -1,36 +1,96 @@ -"""Tests for $lastN accumulator error cases: missing ``n`` and invalid ``n`` values. +"""Tests for $lastN accumulator error cases: malformed specification, missing +arguments, and invalid ``n`` values. -$lastN requires an ``n`` argument that evaluates to a positive integer. A -missing ``n`` raises N_ACCUMULATOR_MISSING_N_FIRSTN_FAMILY_ERROR (5787906); -any other invalid ``n`` -- zero, negative, or non-integer -- raises the generic -N_ACCUMULATOR_INVALID_N_ERROR (7548606).""" +$lastN takes an object with exactly the fields ``n`` and ``input``, where ``n`` +must evaluate to a positive integer. + +Two divergences from the array-expression form in expressions/array/lastN/: this +form collapses invalid-``n`` into the generic 7548606, and reports 40237 rather +than 5787801 for an array specification. + +The missing/zero/negative ``n`` cases intentionally overlap +stages/group/test_group_n_accumulator_errors.py, which sweeps those values across +all six N-accumulators.""" from __future__ import annotations import pytest from bson import Decimal128 -from documentdb_tests.compatibility.tests.core.operator.accumulators.utils.accumulator_test_case import ( # noqa: E501 +from documentdb_tests.compatibility.tests.core.operator.accumulators.utils import ( AccumulatorTestCase, ) from documentdb_tests.framework.assertions import assertFailureCode from documentdb_tests.framework.error_codes import ( + GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, N_ACCUMULATOR_INVALID_N_ERROR, + N_ACCUMULATOR_MISSING_INPUT_FIRSTN_FAMILY_ERROR, N_ACCUMULATOR_MISSING_N_FIRSTN_FAMILY_ERROR, + N_ACCUMULATOR_SPEC_NOT_OBJECT_ERROR, + N_ACCUMULATOR_UNKNOWN_ARGUMENT_ERROR, + OUT_OF_RANGE_CONVERSION_ERROR, ) from documentdb_tests.framework.executor import execute_command from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import FLOAT_INFINITY, FLOAT_NAN + +# These cases fail at pipeline parse time, before any document is read, so one +# document and a bare $group stage are sufficient. +DOCS: list[dict] = [{"_id": 0, "v": 1}] + +# Property [Malformed Specification]: the argument must be an object containing +# only the known n / input fields. A scalar argument is a bad specification; an +# array argument is a unary-operator violation. +LASTN_MALFORMED_SPEC_ERROR_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "spec_not_object", + docs=DOCS, + pipeline=[{"$group": {"_id": None, "result": {"$lastN": 5}}}], + error_code=N_ACCUMULATOR_SPEC_NOT_OBJECT_ERROR, + msg="$lastN should reject a scalar (non-object) specification", + ), + AccumulatorTestCase( + "spec_array", + docs=DOCS, + pipeline=[{"$group": {"_id": None, "result": {"$lastN": [1, 2]}}}], + error_code=GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR, + msg="$lastN should reject array syntax in accumulator context", + ), + AccumulatorTestCase( + "unknown_argument", + docs=DOCS, + pipeline=[ + { + "$group": { + "_id": None, + "result": {"$lastN": {"n": 2, "input": "$v", "extra": 1}}, + } + } + ], + error_code=N_ACCUMULATOR_UNKNOWN_ARGUMENT_ERROR, + msg="$lastN should reject an unknown argument field rather than ignoring it", + ), +] + +# Property [Missing input]: the mirror of [Missing n]. The error code is shared +# by the firstN family ($firstN/$lastN/$minN/$maxN); there is no $lastN-specific +# one. +LASTN_MISSING_INPUT_ERROR_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "missing_input", + docs=DOCS, + pipeline=[{"$group": {"_id": None, "result": {"$lastN": {"n": 2}}}}], + error_code=N_ACCUMULATOR_MISSING_INPUT_FIRSTN_FAMILY_ERROR, + msg="$lastN should reject an argument object that omits input", + ), +] # Property [Missing n]: $lastN requires the ``n`` field in its argument object. LASTN_MISSING_N_ERROR_TESTS: list[AccumulatorTestCase] = [ AccumulatorTestCase( "missing_n", - docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 2}], - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$lastN": {"input": "$v"}}}}, - {"$project": {"_id": 0, "result": 1}}, - ], + docs=DOCS, + pipeline=[{"$group": {"_id": None, "result": {"$lastN": {"input": "$v"}}}}], error_code=N_ACCUMULATOR_MISSING_N_FIRSTN_FAMILY_ERROR, msg="$lastN should reject an argument object that omits n", ), @@ -41,25 +101,64 @@ LASTN_NON_POSITIVE_N_ERROR_TESTS: list[AccumulatorTestCase] = [ AccumulatorTestCase( "n_zero", - docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 2}], - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$lastN": {"n": 0, "input": "$v"}}}}, - {"$project": {"_id": 0, "result": 1}}, - ], + docs=DOCS, + pipeline=[{"$group": {"_id": None, "result": {"$lastN": {"n": 0, "input": "$v"}}}}], error_code=N_ACCUMULATOR_INVALID_N_ERROR, msg="$lastN should reject n = 0", ), AccumulatorTestCase( "n_negative", - docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 2}], + docs=DOCS, + pipeline=[{"$group": {"_id": None, "result": {"$lastN": {"n": -1, "input": "$v"}}}}], + error_code=N_ACCUMULATOR_INVALID_N_ERROR, + msg="$lastN should reject a negative n", + ), + AccumulatorTestCase( + "n_expression_negative", + docs=DOCS, pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$lastN": {"n": -1, "input": "$v"}}}}, - {"$project": {"_id": 0, "result": 1}}, + { + "$group": { + "_id": None, + "result": {"$lastN": {"n": {"$add": [1, -3]}, "input": "$v"}}, + } + } ], error_code=N_ACCUMULATOR_INVALID_N_ERROR, - msg="$lastN should reject a negative n", + msg="$lastN should validate n after evaluating it as an expression", + ), +] + +# Property [Non-Coercible n]: NaN, infinity, and out-of-range doubles fail during +# conversion to a 64-bit integer, not with the generic invalid-n code. +LASTN_NON_COERCIBLE_N_ERROR_TESTS: list[AccumulatorTestCase] = [ + AccumulatorTestCase( + "n_out_of_range_double", + docs=DOCS, + pipeline=[{"$group": {"_id": None, "result": {"$lastN": {"n": 1e19, "input": "$v"}}}}], + error_code=OUT_OF_RANGE_CONVERSION_ERROR, + msg="$lastN should reject an n too large to coerce to a 64-bit integer", + ), + AccumulatorTestCase( + "n_nan", + docs=DOCS, + pipeline=[{"$group": {"_id": None, "result": {"$lastN": {"n": FLOAT_NAN, "input": "$v"}}}}], + error_code=OUT_OF_RANGE_CONVERSION_ERROR, + msg="$lastN should reject NaN as n", + ), + AccumulatorTestCase( + "n_infinity", + docs=DOCS, + pipeline=[ + { + "$group": { + "_id": None, + "result": {"$lastN": {"n": FLOAT_INFINITY, "input": "$v"}}, + } + } + ], + error_code=OUT_OF_RANGE_CONVERSION_ERROR, + msg="$lastN should reject infinity as n", ), ] @@ -68,27 +167,21 @@ LASTN_NON_INTEGER_N_ERROR_TESTS: list[AccumulatorTestCase] = [ AccumulatorTestCase( "n_non_integer_double", - docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 2}], - pipeline=[ - {"$sort": {"_id": 1}}, - {"$group": {"_id": None, "result": {"$lastN": {"n": 2.5, "input": "$v"}}}}, - {"$project": {"_id": 0, "result": 1}}, - ], + docs=DOCS, + pipeline=[{"$group": {"_id": None, "result": {"$lastN": {"n": 2.5, "input": "$v"}}}}], error_code=N_ACCUMULATOR_INVALID_N_ERROR, msg="$lastN should reject a non-integer double n", ), AccumulatorTestCase( "n_non_integer_decimal128", - docs=[{"_id": 0, "v": 1}, {"_id": 1, "v": 2}], + docs=DOCS, pipeline=[ - {"$sort": {"_id": 1}}, { "$group": { "_id": None, "result": {"$lastN": {"n": Decimal128("1.5"), "input": "$v"}}, } - }, - {"$project": {"_id": 0, "result": 1}}, + } ], error_code=N_ACCUMULATOR_INVALID_N_ERROR, msg="$lastN should reject a non-integer Decimal128 n", @@ -96,10 +189,16 @@ ] LASTN_ERROR_TESTS = ( - LASTN_MISSING_N_ERROR_TESTS + LASTN_NON_POSITIVE_N_ERROR_TESTS + LASTN_NON_INTEGER_N_ERROR_TESTS + LASTN_MALFORMED_SPEC_ERROR_TESTS + + LASTN_MISSING_INPUT_ERROR_TESTS + + LASTN_MISSING_N_ERROR_TESTS + + LASTN_NON_POSITIVE_N_ERROR_TESTS + + LASTN_NON_INTEGER_N_ERROR_TESTS + + LASTN_NON_COERCIBLE_N_ERROR_TESTS ) +@pytest.mark.aggregate @pytest.mark.parametrize("test_case", pytest_params(LASTN_ERROR_TESTS)) def test_accumulator_lastN_errors(collection, test_case): """Test $lastN accumulator error cases.""" From 80f6fa4ede40435aca7ae7ffe9b3a46d1de9e784 Mon Sep 17 00:00:00 2001 From: Sunita Bhattacharya Date: Sun, 9 Aug 2026 03:56:14 -0700 Subject: [PATCH 4/4] Expand $lastN expression test coverage for #199 test_expression_lastN.py: cover n-vs-length, n typing, element preservation, field references, and invalid n / input handling. Includes the non-coercible n path (NaN, infinity, overflow), where the expression and accumulator forms report the same code. Signed-off-by: Sunita Bhattacharya --- .../array/lastN/test_expression_lastN.py | 372 ++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/array/lastN/test_expression_lastN.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/lastN/test_expression_lastN.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/lastN/test_expression_lastN.py new file mode 100644 index 000000000..26c954c47 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/lastN/test_expression_lastN.py @@ -0,0 +1,372 @@ +"""Tests for the $lastN array expression: n-vs-length behavior, n typing, +element preservation, and invalid n / input handling. + +The form ``{"$lastN": {"n": , "input": }}`` (used inside $project) +returns the last ``n`` elements of ``input``. + +Differences from the $lastN accumulator: ``input`` must resolve to a real array +(null, missing, or scalar raises 5788200 rather than being included), and invalid +``n`` gets granular codes 5787902/5787903/5787908 instead of the generic 7548606. +The exception is an ``n`` that is numeric but not int64-coercible (NaN, infinity, +overflow), where both forms report 31109.""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + N_ACCUMULATOR_MISSING_INPUT_FIRSTN_FAMILY_ERROR, + N_ACCUMULATOR_MISSING_N_FIRSTN_FAMILY_ERROR, + N_ACCUMULATOR_N_NOT_INTEGRAL_ERROR, + N_ACCUMULATOR_N_NOT_NUMERIC_ERROR, + N_ACCUMULATOR_N_NOT_POSITIVE_ERROR, + N_ACCUMULATOR_SPEC_NOT_OBJECT_ERROR, + N_ACCUMULATOR_UNKNOWN_ARGUMENT_ERROR, + N_EXPRESSION_INPUT_NOT_ARRAY_ERROR, + OUT_OF_RANGE_CONVERSION_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NAN, + FLOAT_INFINITY, + FLOAT_NAN, +) + +pytestmark = pytest.mark.aggregate + +# Property [n vs Array Length]: $lastN returns min(n, len) elements from the end. +# n == 1 returns a single-element list, not a scalar; an empty input returns []. +LASTN_N_VS_LENGTH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "n_less_than_length", + expression={"$lastN": {"n": 2, "input": [10, 20, 30, 40]}}, + expected=[30, 40], + msg="$lastN should return the last n elements when n is less than the array length", + ), + ExpressionTestCase( + "n_equal_to_length", + expression={"$lastN": {"n": 4, "input": [10, 20, 30, 40]}}, + expected=[10, 20, 30, 40], + msg="$lastN should return the whole array when n equals the array length", + ), + ExpressionTestCase( + "n_greater_than_length", + expression={"$lastN": {"n": 9, "input": [10, 20, 30, 40]}}, + expected=[10, 20, 30, 40], + msg="$lastN should return all elements when n exceeds the array length", + ), + ExpressionTestCase( + "n_equals_one", + expression={"$lastN": {"n": 1, "input": [10, 20, 30, 40]}}, + expected=[40], + msg="$lastN with n=1 should return a single-element list, not a scalar", + ), + ExpressionTestCase( + "empty_input_array", + expression={"$lastN": {"n": 2, "input": []}}, + expected=[], + msg="$lastN should return an empty list for an empty input array", + ), +] + +# Property [n Type Handling]: n may be any integral-valued numeric (int, long, +# integral double) or an expression that resolves to one. +LASTN_N_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "n_as_long", + expression={"$lastN": {"n": Int64(2), "input": [10, 20, 30]}}, + expected=[20, 30], + msg="$lastN should accept a long-typed n", + ), + ExpressionTestCase( + "n_as_expression", + expression={"$lastN": {"n": {"$toLong": 2}, "input": [10, 20, 30]}}, + expected=[20, 30], + msg="$lastN should accept an expression that resolves to n", + ), + ExpressionTestCase( + "n_as_integral_double", + expression={"$lastN": {"n": 2.0, "input": [10, 20, 30]}}, + expected=[20, 30], + msg="$lastN should accept an integral-valued double n", + ), +] + +# Property [Element Preservation]: $lastN does no traversal or type checking; it +# returns the trailing elements exactly as they appear, whatever they hold. +LASTN_ELEMENT_PRESERVATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_arrays_preserved", + expression={"$lastN": {"n": 2, "input": [[1, 2], [3, 4], [5, 6]]}}, + expected=[[3, 4], [5, 6]], + msg="$lastN should return nested array elements without traversal", + ), + ExpressionTestCase( + "mixed_types_preserved", + expression={"$lastN": {"n": 2, "input": [1, "two", None, True]}}, + expected=[None, True], + msg="$lastN should preserve mixed BSON types in the returned slice", + ), + ExpressionTestCase( + "null_element_preserved", + expression={"$lastN": {"n": 2, "input": [10, 20, None]}}, + expected=[20, None], + msg="$lastN should preserve a null element present in the last n", + ), + ExpressionTestCase( + "objects_preserved", + expression={"$lastN": {"n": 2, "input": [{"a": 1}, {"b": 2}, {"c": 3}]}}, + expected=[{"b": 2}, {"c": 3}], + msg="$lastN should return object elements unchanged", + ), + ExpressionTestCase( + "special_numerics_preserved", + expression={"$lastN": {"n": 2, "input": [FLOAT_NAN, FLOAT_INFINITY, DECIMAL128_NAN]}}, + expected=[FLOAT_INFINITY, DECIMAL128_NAN], + msg="$lastN should pass through special numeric elements unchanged", + ), +] + +LASTN_SUCCESS_TESTS = ( + LASTN_N_VS_LENGTH_TESTS + LASTN_N_TYPE_TESTS + LASTN_ELEMENT_PRESERVATION_TESTS +) + +# Property [Field References]: n and input may be field paths resolved from the +# document rather than literals. +LASTN_INSERT_SUCCESS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "input_field_ref", + expression={"$lastN": {"n": 2, "input": "$values"}}, + doc={"values": [10, 20, 30, 40]}, + expected=[30, 40], + msg="$lastN should resolve input from a field reference", + ), + ExpressionTestCase( + "n_field_ref", + expression={"$lastN": {"n": "$k", "input": "$values"}}, + doc={"values": [10, 20, 30, 40], "k": 3}, + expected=[20, 30, 40], + msg="$lastN should resolve n from a field reference", + ), + ExpressionTestCase( + "n_long_field_ref", + expression={"$lastN": {"n": Int64(2), "input": "$values"}}, + doc={"values": [1, 2, 3]}, + expected=[2, 3], + msg="$lastN should resolve a long n against a referenced array", + ), + ExpressionTestCase( + "empty_input_field_ref", + expression={"$lastN": {"n": 2, "input": "$values"}}, + doc={"values": []}, + expected=[], + msg="$lastN should return an empty list for a referenced empty array", + ), +] + +# Property [Invalid n]: n must resolve to a positive integral value. The array +# expression uses granular codes for each failure mode (unlike the accumulator). +LASTN_INVALID_N_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_n", + expression={"$lastN": {"input": [1, 2, 3]}}, + error_code=N_ACCUMULATOR_MISSING_N_FIRSTN_FAMILY_ERROR, + msg="$lastN should reject an argument object that omits n", + ), + ExpressionTestCase( + "n_zero", + expression={"$lastN": {"n": 0, "input": [1, 2, 3]}}, + error_code=N_ACCUMULATOR_N_NOT_POSITIVE_ERROR, + msg="$lastN should reject n = 0", + ), + ExpressionTestCase( + "n_negative", + expression={"$lastN": {"n": -1, "input": [1, 2, 3]}}, + error_code=N_ACCUMULATOR_N_NOT_POSITIVE_ERROR, + msg="$lastN should reject a negative n", + ), + ExpressionTestCase( + "n_non_integer_double", + expression={"$lastN": {"n": 2.5, "input": [1, 2, 3]}}, + error_code=N_ACCUMULATOR_N_NOT_INTEGRAL_ERROR, + msg="$lastN should reject a non-integer double n", + ), + ExpressionTestCase( + "n_non_integer_decimal128", + expression={"$lastN": {"n": Decimal128("1.5"), "input": [1, 2, 3]}}, + error_code=N_ACCUMULATOR_N_NOT_INTEGRAL_ERROR, + msg="$lastN should reject a non-integer Decimal128 n", + ), + ExpressionTestCase( + "n_string", + expression={"$lastN": {"n": "2", "input": [1, 2, 3]}}, + error_code=N_ACCUMULATOR_N_NOT_NUMERIC_ERROR, + msg="$lastN should reject a non-numeric string n", + ), + ExpressionTestCase( + "n_null", + expression={"$lastN": {"n": None, "input": [1, 2, 3]}}, + error_code=N_ACCUMULATOR_N_NOT_NUMERIC_ERROR, + msg="$lastN should reject a null n", + ), +] + +# Property [Non-Coercible n]: NaN, infinity, and out-of-range doubles are numeric +# (so they clear the check that rejects null) but fail int64 conversion. An +# overflowing expression reaches this path without an explicit infinity. +LASTN_NON_COERCIBLE_N_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "n_nan", + expression={"$lastN": {"n": FLOAT_NAN, "input": [1, 2, 3]}}, + error_code=OUT_OF_RANGE_CONVERSION_ERROR, + msg="$lastN should reject NaN as n", + ), + ExpressionTestCase( + "n_infinity", + expression={"$lastN": {"n": FLOAT_INFINITY, "input": [1, 2, 3]}}, + error_code=OUT_OF_RANGE_CONVERSION_ERROR, + msg="$lastN should reject infinity as n", + ), + ExpressionTestCase( + "n_out_of_range_double", + expression={"$lastN": {"n": 1e19, "input": [1, 2, 3]}}, + error_code=OUT_OF_RANGE_CONVERSION_ERROR, + msg="$lastN should reject an n too large to coerce to a 64-bit integer", + ), +] + +# Property [Invalid input]: input must be present and resolve to an array. A +# missing, null, or non-array input is rejected (not coerced to an empty list). +LASTN_INVALID_INPUT_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_input", + expression={"$lastN": {"n": 2}}, + error_code=N_ACCUMULATOR_MISSING_INPUT_FIRSTN_FAMILY_ERROR, + msg="$lastN should reject an argument object that omits input", + ), + ExpressionTestCase( + "input_null_literal", + expression={"$lastN": {"n": 2, "input": None}}, + error_code=N_EXPRESSION_INPUT_NOT_ARRAY_ERROR, + msg="$lastN should reject a null input literal", + ), + ExpressionTestCase( + "input_missing_via_remove", + expression={"$lastN": {"n": 2, "input": "$$REMOVE"}}, + error_code=N_EXPRESSION_INPUT_NOT_ARRAY_ERROR, + msg="$lastN should reject a missing input ($$REMOVE)", + ), + ExpressionTestCase( + "input_scalar_literal", + expression={"$lastN": {"n": 2, "input": 5}}, + error_code=N_EXPRESSION_INPUT_NOT_ARRAY_ERROR, + msg="$lastN should reject a non-array (scalar) input", + ), +] + +# Property [Malformed Specification]: the operator argument must be an object +# containing only the known n / input fields. +LASTN_MALFORMED_SPEC_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "spec_array", + expression={"$lastN": [1, 2]}, + error_code=N_ACCUMULATOR_SPEC_NOT_OBJECT_ERROR, + msg="$lastN should reject an array specification", + ), + ExpressionTestCase( + "spec_scalar", + expression={"$lastN": 5}, + error_code=N_ACCUMULATOR_SPEC_NOT_OBJECT_ERROR, + msg="$lastN should reject a scalar specification", + ), + ExpressionTestCase( + "unknown_argument", + expression={"$lastN": {"n": 2, "input": [1, 2, 3], "extra": 1}}, + error_code=N_ACCUMULATOR_UNKNOWN_ARGUMENT_ERROR, + msg="$lastN should reject an unknown argument field", + ), +] + +LASTN_ERROR_TESTS = ( + LASTN_INVALID_N_ERROR_TESTS + + LASTN_NON_COERCIBLE_N_ERROR_TESTS + + LASTN_INVALID_INPUT_ERROR_TESTS + + LASTN_MALFORMED_SPEC_ERROR_TESTS +) + +# Property [Invalid input, from documents]: a referenced field that is null, +# missing, or non-array is rejected at runtime, mirroring the literal cases. +LASTN_INSERT_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "input_null_field", + expression={"$lastN": {"n": 2, "input": "$values"}}, + doc={"values": None}, + error_code=N_EXPRESSION_INPUT_NOT_ARRAY_ERROR, + msg="$lastN should reject a null input resolved from a field", + ), + ExpressionTestCase( + "input_missing_field", + expression={"$lastN": {"n": 2, "input": "$values"}}, + doc={"other": 1}, + error_code=N_EXPRESSION_INPUT_NOT_ARRAY_ERROR, + msg="$lastN should reject a missing input field", + ), + ExpressionTestCase( + "input_int_field", + expression={"$lastN": {"n": 2, "input": "$values"}}, + doc={"values": 5}, + error_code=N_EXPRESSION_INPUT_NOT_ARRAY_ERROR, + msg="$lastN should reject an int input resolved from a field", + ), + ExpressionTestCase( + "input_string_field", + expression={"$lastN": {"n": 2, "input": "$values"}}, + doc={"values": "hello"}, + error_code=N_EXPRESSION_INPUT_NOT_ARRAY_ERROR, + msg="$lastN should reject a string input resolved from a field", + ), + ExpressionTestCase( + "input_object_field", + expression={"$lastN": {"n": 2, "input": "$values"}}, + doc={"values": {"a": 1}}, + error_code=N_EXPRESSION_INPUT_NOT_ARRAY_ERROR, + msg="$lastN should reject an object input resolved from a field", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(LASTN_SUCCESS_TESTS)) +def test_expression_lastN(collection, test): + """Test $lastN array-expression success cases with literal inputs.""" + result = execute_expression(collection, test.expression) + assert_expression_result(result, expected=test.expected, msg=test.msg) + + +@pytest.mark.parametrize("test", pytest_params(LASTN_INSERT_SUCCESS_TESTS)) +def test_expression_lastN_insert(collection, test): + """Test $lastN array-expression success cases with field references.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result(result, expected=test.expected, msg=test.msg) + + +@pytest.mark.parametrize("test", pytest_params(LASTN_ERROR_TESTS)) +def test_expression_lastN_errors(collection, test): + """Test $lastN array-expression error cases with literal inputs.""" + result = execute_expression(collection, test.expression) + assert_expression_result(result, error_code=test.error_code, msg=test.msg) + + +@pytest.mark.parametrize("test", pytest_params(LASTN_INSERT_ERROR_TESTS)) +def test_expression_lastN_insert_errors(collection, test): + """Test $lastN array-expression error cases with field references.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result(result, error_code=test.error_code, msg=test.msg)