diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_accumulator_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_accumulator_errors.py new file mode 100644 index 000000000..5832f66be --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_accumulator_errors.py @@ -0,0 +1,73 @@ +"""Tests for $bucketAuto aggregation stage — accumulator error propagation.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Accumulator Error Propagation]: errors raised by an accumulator +# sub-expression propagate out of $bucketAuto. +BUCKET_AUTO_ACCUMULATOR_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "conversion_error_field_driven", + docs=[{"_id": 1, "x": 1, "v": "notnum"}], + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$x", + "buckets": 1, + "output": {"r": {"$sum": {"$add": ["$v", 1]}}}, + } + } + ], + error_code=TYPE_MISMATCH_ERROR, + msg="$bucketAuto should propagate a field-driven accumulator conversion error", + ), + StageTestCase( + "divide_by_zero_literal_driven", + docs=[{"_id": 1, "x": 1}], + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$x", + "buckets": 1, + "output": {"r": {"$sum": {"$divide": [1, 0]}}}, + } + } + ], + error_code=BAD_VALUE_ERROR, + msg="$bucketAuto should propagate a literal-driven divide-by-zero error", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(BUCKET_AUTO_ACCUMULATOR_ERROR_TESTS)) +def test_bucketAuto_accumulator_errors(collection, test_case: StageTestCase): + """Test $bucketAuto accumulator error propagation.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_arg_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_arg_errors.py new file mode 100644 index 000000000..205235e06 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_arg_errors.py @@ -0,0 +1,244 @@ +"""Tests for $bucketAuto aggregation stage — argument and option validation errors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + BUCKET_AUTO_MISSING_REQUIRED_ERROR, + BUCKET_AUTO_UNRECOGNIZED_OPTION_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ZERO, +) + +# Property [Argument Type Rejection]: $bucketAuto rejects all non-object BSON +# types as its argument. +BUCKET_AUTO_ARG_TYPE_TESTS: list[StageTestCase] = [ + StageTestCase( + "arg_type_string", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": "hello"}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject string argument", + ), + StageTestCase( + "arg_type_int32", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": 42}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject int32 argument", + ), + StageTestCase( + "arg_type_int64", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": Int64(42)}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject int64 argument", + ), + StageTestCase( + "arg_type_double", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": 3.14}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject double argument", + ), + StageTestCase( + "arg_type_decimal128", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": DECIMAL128_ZERO}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject Decimal128 argument", + ), + StageTestCase( + "arg_type_bool", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": True}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject boolean argument", + ), + StageTestCase( + "arg_type_null", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": None}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject null argument", + ), + StageTestCase( + "arg_type_array", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": [1, 2]}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject array argument", + ), + StageTestCase( + "arg_type_objectid", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": ObjectId("000000000000000000000001")}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject ObjectId argument", + ), + StageTestCase( + "arg_type_datetime", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": datetime(2024, 1, 1, tzinfo=timezone.utc)}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject datetime argument", + ), + StageTestCase( + "arg_type_timestamp", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": Timestamp(1, 1)}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject Timestamp argument", + ), + StageTestCase( + "arg_type_binary", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": Binary(b"hello")}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject Binary argument", + ), + StageTestCase( + "arg_type_regex", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": Regex("abc")}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject Regex argument", + ), + StageTestCase( + "arg_type_code", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": Code("function(){}")}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject Code argument", + ), + StageTestCase( + "arg_type_minkey", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": MinKey()}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject MinKey argument", + ), + StageTestCase( + "arg_type_maxkey", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": MaxKey()}], + error_code=BUCKET_AUTO_ARG_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject MaxKey argument", + ), +] + +# Property [Unrecognized Option Rejection]: $bucketAuto rejects any key that is +# not one of the four recognized options (groupBy, buckets, output, +# granularity), including case-variant spellings. +BUCKET_AUTO_UNRECOGNIZED_OPTION_TESTS: list[StageTestCase] = [ + StageTestCase( + "extra_key", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "extra": 1}}], + error_code=BUCKET_AUTO_UNRECOGNIZED_OPTION_ERROR, + msg="$bucketAuto should reject unrecognized option 'extra'", + ), + StageTestCase( + "case_GroupBy", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": {"GroupBy": "$x", "buckets": 2}}], + error_code=BUCKET_AUTO_UNRECOGNIZED_OPTION_ERROR, + msg="$bucketAuto should reject case-variant 'GroupBy'", + ), + StageTestCase( + "case_Buckets", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "Buckets": 2}}], + error_code=BUCKET_AUTO_UNRECOGNIZED_OPTION_ERROR, + msg="$bucketAuto should reject case-variant 'Buckets'", + ), + StageTestCase( + "case_Output", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "Output": {}}}], + error_code=BUCKET_AUTO_UNRECOGNIZED_OPTION_ERROR, + msg="$bucketAuto should reject case-variant 'Output'", + ), + StageTestCase( + "case_Granularity", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "Granularity": "R5"}}], + error_code=BUCKET_AUTO_UNRECOGNIZED_OPTION_ERROR, + msg="$bucketAuto should reject case-variant 'Granularity'", + ), +] + +# Property [Missing Required Fields]: $bucketAuto requires both 'groupBy' and +# 'buckets' to be specified. +BUCKET_AUTO_MISSING_REQUIRED_TESTS: list[StageTestCase] = [ + StageTestCase( + "missing_groupBy", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": {"buckets": 2}}], + error_code=BUCKET_AUTO_MISSING_REQUIRED_ERROR, + msg="$bucketAuto should reject missing 'groupBy'", + ), + StageTestCase( + "missing_buckets", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": {"groupBy": "$x"}}], + error_code=BUCKET_AUTO_MISSING_REQUIRED_ERROR, + msg="$bucketAuto should reject missing 'buckets'", + ), + StageTestCase( + "missing_both", + docs=[{"_id": 1}], + pipeline=[{"$bucketAuto": {}}], + error_code=BUCKET_AUTO_MISSING_REQUIRED_ERROR, + msg="$bucketAuto should reject empty object with no required fields", + ), +] + +BUCKET_AUTO_ARG_ERROR_TESTS = ( + BUCKET_AUTO_ARG_TYPE_TESTS + + BUCKET_AUTO_UNRECOGNIZED_OPTION_TESTS + + BUCKET_AUTO_MISSING_REQUIRED_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(BUCKET_AUTO_ARG_ERROR_TESTS)) +def test_bucketAuto_arg_errors(collection, test_case: StageTestCase): + """Test $bucketAuto argument and option validation errors.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_boundaries.py b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_boundaries.py new file mode 100644 index 000000000..ddbff8525 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_boundaries.py @@ -0,0 +1,332 @@ +"""Tests for $bucketAuto aggregation stage — boundary semantics and type preservation.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Decimal128, + Int64, + ObjectId, +) + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +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, +) + +# Property [Boundary Shape and Sharing]: each bucket _id has exactly min and +# max; min is inclusive, interior max is exclusive, the final max is inclusive, +# consecutive buckets share edges, and the outermost bounds equal the global +# min and max. +BUCKET_AUTO_BOUNDARY_SHAPE_TESTS: list[StageTestCase] = [ + StageTestCase( + "shared_edges_and_global_bounds", + docs=[{"_id": i, "x": i} for i in range(1, 7)], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 3}}], + expected=[ + {"_id": {"min": 1, "max": 3}, "count": 2}, + {"_id": {"min": 3, "max": 5}, "count": 2}, + {"_id": {"min": 5, "max": 6}, "count": 2}, + ], + msg=( + "$bucketAuto buckets should share edges, with the first min equal to the" + " global min and the last max equal to the global max" + ), + ), + StageTestCase( + "interior_max_exclusive", + docs=[ + {"_id": 1, "x": 1}, + {"_id": 2, "x": 2}, + {"_id": 3, "x": 3}, + {"_id": 4, "x": 3}, + {"_id": 5, "x": 4}, + {"_id": 6, "x": 5}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 3}}], + expected=[ + {"_id": {"min": 1, "max": 3}, "count": 2}, + {"_id": {"min": 3, "max": 4}, "count": 2}, + {"_id": {"min": 4, "max": 5}, "count": 2}, + ], + msg=( + "$bucketAuto interior max should be exclusive: a value equal to an interior" + " boundary lands in the higher bucket" + ), + ), +] + +# Property [Boundary Type Preservation]: the bucket _id min/max preserve the +# BSON type of the groupBy values. +BUCKET_AUTO_BOUNDARY_TYPE_TESTS: list[StageTestCase] = [ + StageTestCase( + "int32_boundaries", + docs=[ + {"_id": 1, "x": 5}, + {"_id": 2, "x": 15}, + {"_id": 3, "x": 25}, + {"_id": 4, "x": 35}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[ + {"_id": {"min": 5, "max": 25}, "count": 2}, + {"_id": {"min": 25, "max": 35}, "count": 2}, + ], + msg="$bucketAuto should produce int32-typed boundaries for int32 values", + ), + StageTestCase( + "int64_boundaries", + docs=[ + {"_id": 1, "x": Int64(5)}, + {"_id": 2, "x": Int64(15)}, + {"_id": 3, "x": Int64(25)}, + {"_id": 4, "x": Int64(35)}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[ + {"_id": {"min": Int64(5), "max": Int64(25)}, "count": 2}, + {"_id": {"min": Int64(25), "max": Int64(35)}, "count": 2}, + ], + msg="$bucketAuto should produce int64-typed boundaries for int64 values", + ), + StageTestCase( + "double_boundaries", + docs=[ + {"_id": 1, "x": 5.5}, + {"_id": 2, "x": 15.5}, + {"_id": 3, "x": 25.5}, + {"_id": 4, "x": 35.5}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[ + {"_id": {"min": 5.5, "max": 25.5}, "count": 2}, + {"_id": {"min": 25.5, "max": 35.5}, "count": 2}, + ], + msg="$bucketAuto should produce double-typed boundaries for double values", + ), + StageTestCase( + "decimal128_boundaries", + docs=[ + {"_id": 1, "x": Decimal128("5")}, + {"_id": 2, "x": Decimal128("15")}, + {"_id": 3, "x": Decimal128("25")}, + {"_id": 4, "x": Decimal128("35")}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[ + {"_id": {"min": Decimal128("5"), "max": Decimal128("25")}, "count": 2}, + {"_id": {"min": Decimal128("25"), "max": Decimal128("35")}, "count": 2}, + ], + msg="$bucketAuto should produce Decimal128-typed boundaries for Decimal128 values", + ), + StageTestCase( + "string_boundaries", + docs=[ + {"_id": 1, "x": "apple"}, + {"_id": 2, "x": "banana"}, + {"_id": 3, "x": "cherry"}, + {"_id": 4, "x": "date"}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[ + {"_id": {"min": "apple", "max": "cherry"}, "count": 2}, + {"_id": {"min": "cherry", "max": "date"}, "count": 2}, + ], + msg="$bucketAuto should order and bucket string values lexicographically", + ), + StageTestCase( + "date_boundaries", + docs=[ + {"_id": 1, "x": datetime(2020, 1, 1, tzinfo=timezone.utc)}, + {"_id": 2, "x": datetime(2021, 1, 1, tzinfo=timezone.utc)}, + {"_id": 3, "x": datetime(2022, 1, 1, tzinfo=timezone.utc)}, + {"_id": 4, "x": datetime(2023, 1, 1, tzinfo=timezone.utc)}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[ + { + "_id": { + "min": datetime(2020, 1, 1, tzinfo=timezone.utc), + "max": datetime(2022, 1, 1, tzinfo=timezone.utc), + }, + "count": 2, + }, + { + "_id": { + "min": datetime(2022, 1, 1, tzinfo=timezone.utc), + "max": datetime(2023, 1, 1, tzinfo=timezone.utc), + }, + "count": 2, + }, + ], + msg="$bucketAuto should order and bucket date values chronologically", + ), + StageTestCase( + "objectid_boundaries", + docs=[ + {"_id": 1, "x": ObjectId("000000000000000000000001")}, + {"_id": 2, "x": ObjectId("000000000000000000000002")}, + {"_id": 3, "x": ObjectId("000000000000000000000003")}, + {"_id": 4, "x": ObjectId("000000000000000000000004")}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[ + { + "_id": { + "min": ObjectId("000000000000000000000001"), + "max": ObjectId("000000000000000000000003"), + }, + "count": 2, + }, + { + "_id": { + "min": ObjectId("000000000000000000000003"), + "max": ObjectId("000000000000000000000004"), + }, + "count": 2, + }, + ], + msg="$bucketAuto should order and bucket ObjectId values", + ), + StageTestCase( + "bool_boundaries", + docs=[ + {"_id": 1, "x": False}, + {"_id": 2, "x": False}, + {"_id": 3, "x": True}, + {"_id": 4, "x": True}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[ + {"_id": {"min": False, "max": True}, "count": 2}, + {"_id": {"min": True, "max": True}, "count": 2}, + ], + msg="$bucketAuto should order boolean values with false < true", + ), + StageTestCase( + "decimal128_precision_preserved", + docs=[ + {"_id": 1, "x": Decimal128("1.5")}, + {"_id": 2, "x": Decimal128("2.5")}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 1}}], + expected=[ + {"_id": {"min": Decimal128("1.5"), "max": Decimal128("2.5")}, "count": 2}, + ], + msg="$bucketAuto should preserve Decimal128 precision in bucket boundaries", + ), + StageTestCase( + "decimal128_extreme_boundaries_preserved", + docs=[ + {"_id": 1, "x": DECIMAL128_MIN}, + {"_id": 2, "x": DECIMAL128_MAX}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 1}}], + expected=[ + {"_id": {"min": DECIMAL128_MIN, "max": DECIMAL128_MAX}, "count": 2}, + ], + msg="$bucketAuto should preserve extreme high-precision Decimal128 bucket boundaries", + ), +] + +# Property [Numeric Equivalence and Type Distinction]: numerically equivalent +# values across numeric types group together; booleans are distinct from +# numeric 0/1. +BUCKET_AUTO_NUMERIC_EQUIV_TESTS: list[StageTestCase] = [ + StageTestCase( + "numeric_equivalence_same_bucket", + docs=[ + {"_id": 1, "x": 1}, + {"_id": 2, "x": Int64(1)}, + {"_id": 3, "x": 1.0}, + {"_id": 4, "x": Decimal128("1")}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 1}}], + expected=[{"_id": {"min": 1, "max": Decimal128("1")}, "count": 4}], + msg="$bucketAuto should group numerically equivalent values across numeric types", + ), + StageTestCase( + "bool_distinct_from_numeric_zero", + docs=[{"_id": 1, "x": False}, {"_id": 2, "x": 0}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[ + {"_id": {"min": 0, "max": False}, "count": 1}, + {"_id": {"min": False, "max": False}, "count": 1}, + ], + msg="$bucketAuto should treat boolean false as distinct from numeric 0", + ), +] + +# Property [Mixed Type Ordering]: mixed numeric subtypes are ordered +# numerically, and mixed BSON types use canonical BSON type ordering. +BUCKET_AUTO_MIXED_TYPE_TESTS: list[StageTestCase] = [ + StageTestCase( + "mixed_numeric_types_ordered", + docs=[ + {"_id": 1, "x": Decimal128("1")}, + {"_id": 2, "x": 5}, + {"_id": 3, "x": Int64(3)}, + {"_id": 4, "x": 2.5}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[ + {"_id": {"min": Decimal128("1"), "max": Int64(3)}, "count": 2}, + {"_id": {"min": Int64(3), "max": 5}, "count": 2}, + ], + msg="$bucketAuto should order mixed numeric subtypes numerically", + ), + StageTestCase( + "mixed_bson_types_canonical_order", + docs=[ + {"_id": 1, "x": None}, + {"_id": 2, "x": 5}, + {"_id": 3, "x": "str"}, + {"_id": 4, "x": 10}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 4}}], + expected=[ + {"_id": {"min": None, "max": 5}, "count": 1}, + {"_id": {"min": 5, "max": 10}, "count": 1}, + {"_id": {"min": 10, "max": "str"}, "count": 1}, + {"_id": {"min": "str", "max": "str"}, "count": 1}, + ], + msg="$bucketAuto should order mixed BSON types using canonical type ordering", + ), +] + +BUCKET_AUTO_BOUNDARY_TESTS = ( + BUCKET_AUTO_BOUNDARY_SHAPE_TESTS + + BUCKET_AUTO_BOUNDARY_TYPE_TESTS + + BUCKET_AUTO_NUMERIC_EQUIV_TESTS + + BUCKET_AUTO_MIXED_TYPE_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(BUCKET_AUTO_BOUNDARY_TESTS)) +def test_bucketAuto_boundaries(collection, test_case: StageTestCase): + """Test $bucketAuto boundary semantics and type preservation.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_buckets_validation.py b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_buckets_validation.py new file mode 100644 index 000000000..26da3940b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_buckets_validation.py @@ -0,0 +1,281 @@ +"""Tests for $bucketAuto aggregation stage — 'buckets' parameter validation.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BUCKET_AUTO_BUCKETS_NOT_INT_ERROR, + BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR, + BUCKET_AUTO_BUCKETS_NOT_POSITIVE_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, + INT32_OVERFLOW, +) + +_DOCS = [{"_id": i, "x": i} for i in range(1, 6)] + +# Property [Buckets Accepts Whole Numbers]: 'buckets' accepts any numeric type +# whose value is a whole number in 32-bit integer range. +BUCKET_AUTO_BUCKETS_ACCEPT_TESTS: list[StageTestCase] = [ + StageTestCase( + "buckets_int32", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[ + {"_id": {"min": 1, "max": 4}, "count": 3}, + {"_id": {"min": 4, "max": 5}, "count": 2}, + ], + msg="$bucketAuto should accept an int32 'buckets' value", + ), + StageTestCase( + "buckets_int64_whole", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": Int64(2)}}], + expected=[ + {"_id": {"min": 1, "max": 4}, "count": 3}, + {"_id": {"min": 4, "max": 5}, "count": 2}, + ], + msg="$bucketAuto should accept a whole-number int64 'buckets' value", + ), + StageTestCase( + "buckets_double_whole", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2.0}}], + expected=[ + {"_id": {"min": 1, "max": 4}, "count": 3}, + {"_id": {"min": 4, "max": 5}, "count": 2}, + ], + msg="$bucketAuto should accept a whole-number double 'buckets' value", + ), + StageTestCase( + "buckets_decimal128_whole", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": Decimal128("2")}}], + expected=[ + {"_id": {"min": 1, "max": 4}, "count": 3}, + {"_id": {"min": 4, "max": 5}, "count": 2}, + ], + msg="$bucketAuto should accept a whole-number Decimal128 'buckets' value", + ), + StageTestCase( + "buckets_one_single_bucket", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 1}}], + expected=[{"_id": {"min": 1, "max": 5}, "count": 5}], + msg="$bucketAuto with buckets=1 should return a single bucket spanning all values", + ), +] + +# Property [Buckets Not Positive]: 'buckets' must be greater than 0. +BUCKET_AUTO_BUCKETS_NOT_POSITIVE_TESTS: list[StageTestCase] = [ + StageTestCase( + "buckets_zero", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 0}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_POSITIVE_ERROR, + msg="$bucketAuto should reject buckets=0", + ), + StageTestCase( + "buckets_negative", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": -1}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_POSITIVE_ERROR, + msg="$bucketAuto should reject a negative buckets value", + ), +] + +# Property [Buckets Not Integral]: 'buckets' must be representable as a 32-bit +# integer; fractional values, NaN, and overflowing values are rejected. +BUCKET_AUTO_BUCKETS_NOT_INT_TESTS: list[StageTestCase] = [ + StageTestCase( + "buckets_fractional_double", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2.5}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_INT_ERROR, + msg="$bucketAuto should reject a fractional double buckets value", + ), + StageTestCase( + "buckets_fractional_decimal128", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": Decimal128("2.5")}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_INT_ERROR, + msg="$bucketAuto should reject a fractional Decimal128 buckets value", + ), + StageTestCase( + "buckets_nan", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": FLOAT_NAN}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_INT_ERROR, + msg="$bucketAuto should reject a NaN buckets value", + ), + StageTestCase( + "buckets_overflow_32bit", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": Int64(INT32_OVERFLOW)}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_INT_ERROR, + msg="$bucketAuto should reject a buckets value exceeding 32-bit integer range", + ), + StageTestCase( + "buckets_infinity", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": FLOAT_INFINITY}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_INT_ERROR, + msg="$bucketAuto should reject an infinite buckets value", + ), +] + +# Property [Buckets Not Numeric]: 'buckets' must be a numeric value; all +# non-numeric BSON types are rejected. +BUCKET_AUTO_BUCKETS_NOT_NUMERIC_TESTS: list[StageTestCase] = [ + StageTestCase( + "buckets_string", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": "2"}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR, + msg="$bucketAuto should reject a string buckets value", + ), + StageTestCase( + "buckets_bool", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": True}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR, + msg="$bucketAuto should reject a bool buckets value", + ), + StageTestCase( + "buckets_null", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": None}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR, + msg="$bucketAuto should reject a null buckets value", + ), + StageTestCase( + "buckets_array", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": [2]}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR, + msg="$bucketAuto should reject an array buckets value", + ), + StageTestCase( + "buckets_object", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": {"n": 2}}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR, + msg="$bucketAuto should reject an object buckets value", + ), + StageTestCase( + "buckets_objectid", + docs=_DOCS, + pipeline=[ + {"$bucketAuto": {"groupBy": "$x", "buckets": ObjectId("000000000000000000000001")}} + ], + error_code=BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR, + msg="$bucketAuto should reject an ObjectId buckets value", + ), + StageTestCase( + "buckets_datetime", + docs=_DOCS, + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$x", + "buckets": datetime(2024, 1, 1, tzinfo=timezone.utc), + } + } + ], + error_code=BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR, + msg="$bucketAuto should reject a datetime buckets value", + ), + StageTestCase( + "buckets_timestamp", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": Timestamp(1, 1)}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR, + msg="$bucketAuto should reject a Timestamp buckets value", + ), + StageTestCase( + "buckets_binary", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": Binary(b"x")}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR, + msg="$bucketAuto should reject a Binary buckets value", + ), + StageTestCase( + "buckets_regex", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": Regex("abc")}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR, + msg="$bucketAuto should reject a Regex buckets value", + ), + StageTestCase( + "buckets_code", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": Code("function(){}")}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR, + msg="$bucketAuto should reject a Code buckets value", + ), + StageTestCase( + "buckets_minkey", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": MinKey()}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR, + msg="$bucketAuto should reject a MinKey buckets value", + ), + StageTestCase( + "buckets_maxkey", + docs=_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": MaxKey()}}], + error_code=BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR, + msg="$bucketAuto should reject a MaxKey buckets value", + ), +] + +BUCKET_AUTO_BUCKETS_TESTS = ( + BUCKET_AUTO_BUCKETS_ACCEPT_TESTS + + BUCKET_AUTO_BUCKETS_NOT_POSITIVE_TESTS + + BUCKET_AUTO_BUCKETS_NOT_INT_TESTS + + BUCKET_AUTO_BUCKETS_NOT_NUMERIC_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(BUCKET_AUTO_BUCKETS_TESTS)) +def test_bucketAuto_buckets_validation(collection, test_case: StageTestCase): + """Test $bucketAuto 'buckets' parameter validation.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_core_semantics.py b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_core_semantics.py new file mode 100644 index 000000000..47b173f19 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_core_semantics.py @@ -0,0 +1,128 @@ +"""Tests for $bucketAuto aggregation stage — core distribution semantics.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Empty Input]: $bucketAuto over an empty or non-existent collection +# returns no buckets without error. +BUCKET_AUTO_EMPTY_INPUT_TESTS: list[StageTestCase] = [ + StageTestCase( + "empty_collection", + docs=[], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[], + msg="$bucketAuto over an empty collection should return no buckets", + ), + StageTestCase( + "nonexistent_collection", + docs=None, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[], + msg="$bucketAuto over a non-existent collection should return no buckets", + ), +] + +# Property [Bucket Count Bounds]: the number of buckets never exceeds the +# number of distinct groupBy values or the document count. +BUCKET_AUTO_COUNT_BOUNDS_TESTS: list[StageTestCase] = [ + StageTestCase( + "single_document", + docs=[{"_id": 1, "x": 5}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 3}}], + expected=[{"_id": {"min": 5, "max": 5}, "count": 1}], + msg="$bucketAuto with a single document should return exactly one bucket", + ), + StageTestCase( + "all_identical_values", + docs=[{"_id": 1, "x": 5}, {"_id": 2, "x": 5}, {"_id": 3, "x": 5}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[{"_id": {"min": 5, "max": 5}, "count": 3}], + msg="$bucketAuto with all identical values should return one bucket", + ), + StageTestCase( + "fewer_unique_than_buckets", + docs=[ + {"_id": 1, "x": 1}, + {"_id": 2, "x": 1}, + {"_id": 3, "x": 2}, + {"_id": 4, "x": 2}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 4}}], + expected=[ + {"_id": {"min": 1, "max": 2}, "count": 2}, + {"_id": {"min": 2, "max": 2}, "count": 2}, + ], + msg="$bucketAuto should return fewer buckets than requested when unique values are fewer", + ), + StageTestCase( + "fewer_documents_than_buckets", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 5}}], + expected=[ + {"_id": {"min": 1, "max": 2}, "count": 1}, + {"_id": {"min": 2, "max": 2}, "count": 1}, + ], + msg="$bucketAuto should return at most one bucket per document", + ), +] + +# Property [Document Distribution]: documents are distributed as evenly as +# possible; when they do not divide evenly, earlier buckets absorb the extras. +BUCKET_AUTO_DISTRIBUTION_TESTS: list[StageTestCase] = [ + StageTestCase( + "even_distribution", + docs=[{"_id": i, "x": i} for i in range(1, 9)], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 4}}], + expected=[ + {"_id": {"min": 1, "max": 3}, "count": 2}, + {"_id": {"min": 3, "max": 5}, "count": 2}, + {"_id": {"min": 5, "max": 7}, "count": 2}, + {"_id": {"min": 7, "max": 8}, "count": 2}, + ], + msg="$bucketAuto should distribute documents evenly when count divides evenly", + ), + StageTestCase( + "uneven_distribution_earlier_absorbs", + docs=[{"_id": i, "x": i} for i in range(1, 8)], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[ + {"_id": {"min": 1, "max": 5}, "count": 4}, + {"_id": {"min": 5, "max": 7}, "count": 3}, + ], + msg="$bucketAuto should give extra documents to earlier buckets when uneven", + ), +] + +BUCKET_AUTO_CORE_TESTS = ( + BUCKET_AUTO_EMPTY_INPUT_TESTS + BUCKET_AUTO_COUNT_BOUNDS_TESTS + BUCKET_AUTO_DISTRIBUTION_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(BUCKET_AUTO_CORE_TESTS)) +def test_bucketAuto_core_semantics(collection, test_case: StageTestCase): + """Test $bucketAuto core distribution semantics.""" + coll = populate_collection(collection, test_case) + result = execute_command( + coll, + { + "aggregate": coll.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_granularity.py b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_granularity.py new file mode 100644 index 000000000..7769547e5 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_granularity.py @@ -0,0 +1,241 @@ +"""Tests for $bucketAuto aggregation stage — 'granularity' option.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertNotError, assertResult +from documentdb_tests.framework.error_codes import ( + BUCKET_AUTO_GRANULARITY_NAN_ERROR, + BUCKET_AUTO_GRANULARITY_NEGATIVE_ERROR, + BUCKET_AUTO_GRANULARITY_NON_NUMERIC_ERROR, + BUCKET_AUTO_GRANULARITY_NOT_STRING_ERROR, + BUCKET_AUTO_GRANULARITY_UNKNOWN_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_NAN, + FLOAT_NEGATIVE_INFINITY, + GRANULARITY_VALUES, +) + +_GRAN_DOCS = [{"_id": 1, "x": 1}, {"_id": 2, "x": 10}, {"_id": 3, "x": 100}, {"_id": 4, "x": 1000}] + +# Property [Granularity Value Acceptance]: each documented preferred-number +# series string is accepted as a 'granularity' value. +BUCKET_AUTO_GRANULARITY_ACCEPT_TESTS: list[StageTestCase] = [ + StageTestCase( + f"granularity_accept_{series}", + docs=_GRAN_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "granularity": series}}], + msg=f"$bucketAuto should accept granularity '{series}'", + ) + for series in GRANULARITY_VALUES +] + +# Property [Granularity Rounds Boundaries]: granularity rounds bucket +# boundaries to the preferred-number series for a known dataset. +BUCKET_AUTO_GRANULARITY_ROUNDING_TESTS: list[StageTestCase] = [ + StageTestCase( + "granularity_R5_rounds_boundaries", + docs=_GRAN_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 3, "granularity": "R5"}}], + expected=[ + {"_id": {"min": 0.63, "max": 1.6}, "count": 1}, + {"_id": {"min": 1.6, "max": 16.0}, "count": 1}, + {"_id": {"min": 16.0, "max": 1600.0}, "count": 2}, + ], + msg="$bucketAuto granularity 'R5' should round boundaries to the R5 series", + ), + StageTestCase( + "granularity_powersof2_rounds_boundaries", + docs=_GRAN_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 3, "granularity": "POWERSOF2"}}], + expected=[ + {"_id": {"min": 0.5, "max": 2}, "count": 1}, + {"_id": {"min": 2, "max": 16}, "count": 1}, + {"_id": {"min": 16, "max": 1024}, "count": 2}, + ], + msg="$bucketAuto granularity 'POWERSOF2' should round boundaries to powers of two", + ), + StageTestCase( + "granularity_1_2_5_rounds_boundaries", + docs=_GRAN_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 3, "granularity": "1-2-5"}}], + expected=[ + {"_id": {"min": 0.5, "max": 2.0}, "count": 1}, + {"_id": {"min": 2.0, "max": 20.0}, "count": 1}, + {"_id": {"min": 20.0, "max": 2000.0}, "count": 2}, + ], + msg="$bucketAuto granularity '1-2-5' should round boundaries to the 1-2-5 series", + ), + StageTestCase( + "granularity_E6_rounds_boundaries", + docs=_GRAN_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 3, "granularity": "E6"}}], + expected=[ + {"_id": {"min": 0.68, "max": 1.5}, "count": 1}, + {"_id": {"min": 1.5, "max": 15.0}, "count": 1}, + {"_id": {"min": 15.0, "max": 1500.0}, "count": 2}, + ], + msg="$bucketAuto granularity 'E6' should round boundaries to the E6 series", + ), + StageTestCase( + "granularity_fewer_buckets_when_coarse", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 10, "granularity": "R5"}}], + expected=[ + {"_id": {"min": 0.63, "max": 1.6}, "count": 1}, + {"_id": {"min": 1.6, "max": 2.5}, "count": 1}, + ], + msg="$bucketAuto granularity should produce fewer buckets than requested when coarse", + ), +] + +# Property [Granularity Value Rejection]: unknown and empty-string granularity +# values are rejected. +BUCKET_AUTO_GRANULARITY_VALUE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "granularity_unknown_string", + docs=_GRAN_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "granularity": "BOGUS"}}], + error_code=BUCKET_AUTO_GRANULARITY_UNKNOWN_ERROR, + msg="$bucketAuto should reject an unrecognized granularity string", + ), + StageTestCase( + "granularity_empty_string", + docs=_GRAN_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "granularity": ""}}], + error_code=BUCKET_AUTO_GRANULARITY_UNKNOWN_ERROR, + msg="$bucketAuto should reject an empty granularity string", + ), +] + +# Property [Granularity Type Rejection]: 'granularity' must be a string; all +# non-string BSON types are rejected. +BUCKET_AUTO_GRANULARITY_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "granularity_int", + docs=_GRAN_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "granularity": 5}}], + error_code=BUCKET_AUTO_GRANULARITY_NOT_STRING_ERROR, + msg="$bucketAuto should reject an int granularity value", + ), + StageTestCase( + "granularity_double", + docs=_GRAN_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "granularity": 1.0}}], + error_code=BUCKET_AUTO_GRANULARITY_NOT_STRING_ERROR, + msg="$bucketAuto should reject a double granularity value", + ), + StageTestCase( + "granularity_bool", + docs=_GRAN_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "granularity": True}}], + error_code=BUCKET_AUTO_GRANULARITY_NOT_STRING_ERROR, + msg="$bucketAuto should reject a bool granularity value", + ), + StageTestCase( + "granularity_null", + docs=_GRAN_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "granularity": None}}], + error_code=BUCKET_AUTO_GRANULARITY_NOT_STRING_ERROR, + msg="$bucketAuto should reject a null granularity value", + ), + StageTestCase( + "granularity_array", + docs=_GRAN_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "granularity": ["R5"]}}], + error_code=BUCKET_AUTO_GRANULARITY_NOT_STRING_ERROR, + msg="$bucketAuto should reject an array granularity value", + ), + StageTestCase( + "granularity_object", + docs=_GRAN_DOCS, + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "granularity": {"s": "R5"}}}], + error_code=BUCKET_AUTO_GRANULARITY_NOT_STRING_ERROR, + msg="$bucketAuto should reject an object granularity value", + ), +] + +# Property [Granularity Numeric-Only Constraint]: granularity requires all +# groupBy values to be non-negative numbers with none NaN. +BUCKET_AUTO_GRANULARITY_NUMERIC_CONSTRAINT_TESTS: list[StageTestCase] = [ + StageTestCase( + "granularity_non_numeric_groupBy", + docs=[{"_id": 1, "x": "a"}, {"_id": 2, "x": "b"}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "granularity": "R5"}}], + error_code=BUCKET_AUTO_GRANULARITY_NON_NUMERIC_ERROR, + msg="$bucketAuto granularity should be rejected when a groupBy value is non-numeric", + ), + StageTestCase( + "granularity_nan_groupBy", + docs=[{"_id": 1, "x": FLOAT_NAN}, {"_id": 2, "x": FLOAT_NAN}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "granularity": "R5"}}], + error_code=BUCKET_AUTO_GRANULARITY_NAN_ERROR, + msg="$bucketAuto granularity should be rejected when a groupBy value is NaN", + ), + StageTestCase( + "granularity_negative_groupBy", + docs=[{"_id": 1, "x": -5}, {"_id": 2, "x": -1}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "granularity": "R5"}}], + error_code=BUCKET_AUTO_GRANULARITY_NEGATIVE_ERROR, + msg="$bucketAuto granularity should be rejected when a groupBy value is negative", + ), + StageTestCase( + "granularity_negative_infinity_groupBy", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": FLOAT_NEGATIVE_INFINITY}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 1, "granularity": "POWERSOF2"}}], + error_code=BUCKET_AUTO_GRANULARITY_NEGATIVE_ERROR, + msg="$bucketAuto granularity rejects -Infinity via the negative-value check", + ), +] + +BUCKET_AUTO_GRANULARITY_TESTS = ( + BUCKET_AUTO_GRANULARITY_ROUNDING_TESTS + + BUCKET_AUTO_GRANULARITY_VALUE_ERROR_TESTS + + BUCKET_AUTO_GRANULARITY_TYPE_ERROR_TESTS + + BUCKET_AUTO_GRANULARITY_NUMERIC_CONSTRAINT_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(BUCKET_AUTO_GRANULARITY_ACCEPT_TESTS)) +def test_bucketAuto_granularity_accepted(collection, test_case: StageTestCase): + """Test that $bucketAuto accepts each documented granularity series value.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertNotError(result, msg=test_case.msg) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(BUCKET_AUTO_GRANULARITY_TESTS)) +def test_bucketAuto_granularity(collection, test_case: StageTestCase): + """Test $bucketAuto 'granularity' rounding behavior and validation.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_groupby.py b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_groupby.py new file mode 100644 index 000000000..511581eda --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_groupby.py @@ -0,0 +1,135 @@ +"""Tests for $bucketAuto aggregation stage — 'groupBy' expression types and null/missing.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BUCKET_AUTO_GROUPBY_NOT_EXPRESSION_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [GroupBy Expression Types]: 'groupBy' accepts a $-prefixed field +# path, a dotted nested path, an expression operator, or a $literal constant. +BUCKET_AUTO_GROUPBY_EXPR_TESTS: list[StageTestCase] = [ + StageTestCase( + "groupBy_field_path", + docs=[ + {"_id": 1, "x": 5}, + {"_id": 2, "x": 15}, + {"_id": 3, "x": 25}, + {"_id": 4, "x": 35}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[ + {"_id": {"min": 5, "max": 25}, "count": 2}, + {"_id": {"min": 25, "max": 35}, "count": 2}, + ], + msg="$bucketAuto should group by a $-prefixed field path", + ), + StageTestCase( + "groupBy_dotted_path", + docs=[ + {"_id": 1, "a": {"b": 5}}, + {"_id": 2, "a": {"b": 15}}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$a.b", "buckets": 2}}], + expected=[ + {"_id": {"min": 5, "max": 15}, "count": 1}, + {"_id": {"min": 15, "max": 15}, "count": 1}, + ], + msg="$bucketAuto should group by a dotted nested field path", + ), + StageTestCase( + "groupBy_expression_operator", + docs=[ + {"_id": 1, "a": 1, "b": 2}, + {"_id": 2, "a": 3, "b": 4}, + ], + pipeline=[{"$bucketAuto": {"groupBy": {"$add": ["$a", "$b"]}, "buckets": 2}}], + expected=[ + {"_id": {"min": 3, "max": 7}, "count": 1}, + {"_id": {"min": 7, "max": 7}, "count": 1}, + ], + msg="$bucketAuto should group by an expression operator over fields", + ), + StageTestCase( + "groupBy_literal_constant", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + pipeline=[{"$bucketAuto": {"groupBy": {"$literal": "c"}, "buckets": 2}}], + expected=[{"_id": {"min": "c", "max": "c"}, "count": 2}], + msg="$bucketAuto with a $literal constant groupBy should produce a single bucket", + ), +] + +# Property [Null and Missing Grouping]: documents whose groupBy resolves to +# null or a missing field are grouped together into a null-valued bucket. +BUCKET_AUTO_GROUPBY_NULL_TESTS: list[StageTestCase] = [ + StageTestCase( + "groupBy_null_and_missing_grouped", + docs=[ + {"_id": 1, "x": None}, + {"_id": 2}, + {"_id": 3, "x": 5}, + {"_id": 4, "x": 10}, + ], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 2}}], + expected=[ + {"_id": {"min": None, "max": 5}, "count": 2}, + {"_id": {"min": 5, "max": 10}, "count": 2}, + ], + msg="$bucketAuto should group null and missing groupBy values together", + ), + StageTestCase( + "groupBy_all_missing", + docs=[{"_id": 1}, {"_id": 2}, {"_id": 3}], + pipeline=[{"$bucketAuto": {"groupBy": "$nope", "buckets": 2}}], + expected=[{"_id": {"min": None, "max": None}, "count": 3}], + msg="$bucketAuto should place all documents with a missing groupBy field in one bucket", + ), +] + +# Property [GroupBy Expression Rejection]: a non-$-prefixed constant string is +# rejected because groupBy must be a $-prefixed path or an expression. +BUCKET_AUTO_GROUPBY_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + "groupBy_non_dollar_string", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + pipeline=[{"$bucketAuto": {"groupBy": "literalval", "buckets": 2}}], + error_code=BUCKET_AUTO_GROUPBY_NOT_EXPRESSION_ERROR, + msg="$bucketAuto should reject a non-$-prefixed string groupBy", + ), +] + +BUCKET_AUTO_GROUPBY_TESTS = ( + BUCKET_AUTO_GROUPBY_EXPR_TESTS + + BUCKET_AUTO_GROUPBY_NULL_TESTS + + BUCKET_AUTO_GROUPBY_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(BUCKET_AUTO_GROUPBY_TESTS)) +def test_bucketAuto_groupby(collection, test_case: StageTestCase): + """Test $bucketAuto 'groupBy' expression types and null/missing handling.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_output.py b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_output.py new file mode 100644 index 000000000..f217e0545 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_output.py @@ -0,0 +1,282 @@ +"""Tests for $bucketAuto aggregation stage — output specification behavior.""" + +from __future__ import annotations + +import pytest +from bson.son import SON + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Implicit Count Field]: when output is omitted each bucket includes +# a count field; specifying output replaces the implicit count with the named +# fields; an empty output document still yields the implicit count. +BUCKET_AUTO_IMPLICIT_COUNT_TESTS: list[StageTestCase] = [ + StageTestCase( + "output_omitted_includes_count", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 5}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 1}}], + expected=[{"_id": {"min": 1, "max": 5}, "count": 2}], + msg="$bucketAuto without output should include implicit count field", + ), + StageTestCase( + "output_specified_replaces_count", + docs=[{"_id": 1, "x": 1, "v": 10}, {"_id": 2, "x": 5, "v": 20}], + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$x", + "buckets": 1, + "output": {"total": {"$sum": "$v"}}, + } + } + ], + expected=[{"_id": {"min": 1, "max": 5}, "total": 30}], + msg="$bucketAuto with output specified should not include implicit count field", + ), + StageTestCase( + "output_explicit_count", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 5}], + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$x", + "buckets": 1, + "output": {"c": {"$sum": 1}}, + } + } + ], + expected=[{"_id": {"min": 1, "max": 5}, "c": 2}], + msg="$bucketAuto output can re-add count explicitly via {$sum: 1}", + ), + StageTestCase( + "empty_output_yields_count", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 5}], + pipeline=[{"$bucketAuto": {"groupBy": "$x", "buckets": 1, "output": {}}}], + expected=[{"_id": {"min": 1, "max": 5}, "count": 2}], + msg="$bucketAuto with an empty output document should still yield the implicit count", + ), +] + +# Property [Multiple Accumulators]: multiple accumulator operators can be used +# simultaneously in the output specification. +BUCKET_AUTO_MULTIPLE_ACCUMULATORS_TESTS: list[StageTestCase] = [ + StageTestCase( + "multiple_accumulators_in_output", + docs=[ + {"_id": 1, "x": 1, "v": 10}, + {"_id": 2, "x": 1, "v": 20}, + {"_id": 3, "x": 1, "v": 30}, + ], + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$x", + "buckets": 1, + "output": { + "total": {"$sum": "$v"}, + "avg": {"$avg": "$v"}, + "items": {"$push": "$v"}, + }, + } + } + ], + expected=[{"_id": {"min": 1, "max": 1}, "total": 60, "avg": 20.0, "items": [10, 20, 30]}], + msg="$bucketAuto output should accept multiple accumulators simultaneously", + ), +] + +# Property [Accumulator Input Field References]: accumulators reference input +# document fields, not sibling accumulator output fields. +BUCKET_AUTO_ACCUMULATOR_INPUT_REF_TESTS: list[StageTestCase] = [ + StageTestCase( + "accumulator_references_input_not_sibling", + docs=[{"_id": 1, "x": 1, "v": 10}, {"_id": 2, "x": 1, "v": 20}], + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$x", + "buckets": 1, + "output": { + "total": {"$sum": "$v"}, + "ref_sibling": {"$sum": "$total"}, + }, + } + } + ], + expected=[{"_id": {"min": 1, "max": 1}, "total": 30, "ref_sibling": 0}], + msg=( + "$bucketAuto accumulators should reference input document" + " fields, not sibling output fields" + ), + ), +] + +# Property [Nested Expressions in Accumulators]: nested expressions work within +# accumulator arguments. +BUCKET_AUTO_NESTED_EXPR_TESTS: list[StageTestCase] = [ + StageTestCase( + "nested_expression_in_accumulator", + docs=[{"_id": 1, "x": 1, "v": 10}, {"_id": 2, "x": 1, "v": 20}], + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$x", + "buckets": 1, + "output": {"result": {"$sum": {"$add": ["$v", 1]}}}, + } + } + ], + expected=[{"_id": {"min": 1, "max": 1}, "result": 32}], + msg="$bucketAuto should support nested expressions within accumulators", + ), +] + +# Property [Push System Variables]: $push with $$ROOT or $$CURRENT returns full +# input documents; $push with $$REMOVE produces empty arrays. +BUCKET_AUTO_PUSH_SYSTEM_VAR_TESTS: list[StageTestCase] = [ + StageTestCase( + "push_root_returns_full_docs", + docs=[{"_id": 1, "x": 1, "v": 10}], + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$x", + "buckets": 1, + "output": {"docs": {"$push": "$$ROOT"}}, + } + } + ], + expected=[{"_id": {"min": 1, "max": 1}, "docs": [{"_id": 1, "x": 1, "v": 10}]}], + msg="$bucketAuto $push with $$ROOT should return full input documents", + ), + StageTestCase( + "push_current_returns_full_docs", + docs=[{"_id": 1, "x": 1, "v": 10}], + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$x", + "buckets": 1, + "output": {"docs": {"$push": "$$CURRENT"}}, + } + } + ], + expected=[{"_id": {"min": 1, "max": 1}, "docs": [{"_id": 1, "x": 1, "v": 10}]}], + msg="$bucketAuto $push with $$CURRENT should return full input documents", + ), + StageTestCase( + "push_remove_produces_empty_array", + docs=[{"_id": 1, "x": 1}], + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$x", + "buckets": 1, + "output": {"docs": {"$push": "$$REMOVE"}}, + } + } + ], + expected=[{"_id": {"min": 1, "max": 1}, "docs": []}], + msg="$bucketAuto $push with $$REMOVE should produce empty arrays", + ), +] + +# Property [Output Field Name Acceptance]: empty string, Unicode, emoji, +# spaces, and long field names are accepted as output field names. +BUCKET_AUTO_OUTPUT_FIELD_NAME_TESTS: list[StageTestCase] = [ + StageTestCase( + "special_output_field_names", + docs=[{"_id": 1, "x": 1}], + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$x", + "buckets": 1, + "output": { + "": {"$sum": 1}, + "é": {"$sum": 1}, + "\U0001f389": {"$sum": 1}, + " ": {"$sum": 1}, + "a" * 1_000: {"$sum": 1}, + }, + } + } + ], + expected=[ + { + "_id": {"min": 1, "max": 1}, + "": 1, + "é": 1, + "\U0001f389": 1, + " ": 1, + "a" * 1_000: 1, + } + ], + msg=( + "$bucketAuto should accept empty string, Unicode, emoji," + " spaces, and long field names as output field names" + ), + ), +] + +# Property [Duplicate Output Field Names]: duplicate output field names resolve +# to the last definition. +BUCKET_AUTO_DUPLICATE_FIELD_NAME_TESTS: list[StageTestCase] = [ + StageTestCase( + "duplicate_output_field_last_wins", + docs=[{"_id": 1, "x": 1, "v": 10}], + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$x", + "buckets": 1, + "output": SON( + [ + ("total", {"$sum": "$v"}), + ("total", {"$sum": 1}), + ] + ), + } + } + ], + expected=[{"_id": {"min": 1, "max": 1}, "total": 1}], + msg="$bucketAuto duplicate output field names should resolve to the last definition", + ), +] + +BUCKET_AUTO_OUTPUT_TESTS = ( + BUCKET_AUTO_IMPLICIT_COUNT_TESTS + + BUCKET_AUTO_MULTIPLE_ACCUMULATORS_TESTS + + BUCKET_AUTO_ACCUMULATOR_INPUT_REF_TESTS + + BUCKET_AUTO_NESTED_EXPR_TESTS + + BUCKET_AUTO_PUSH_SYSTEM_VAR_TESTS + + BUCKET_AUTO_OUTPUT_FIELD_NAME_TESTS + + BUCKET_AUTO_DUPLICATE_FIELD_NAME_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(BUCKET_AUTO_OUTPUT_TESTS)) +def test_bucketAuto_output(collection, test_case: StageTestCase): + """Test $bucketAuto output specification behavior.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_output_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_output_errors.py new file mode 100644 index 000000000..54f52da31 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/bucketAuto/test_bucketAuto_output_errors.py @@ -0,0 +1,227 @@ +"""Tests for $bucketAuto aggregation stage — output field validation errors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + BUCKET_OUTPUT_DOLLAR_PREFIX_ERROR, + BUCKET_OUTPUT_DOT_ERROR, + BUCKET_OUTPUT_NOT_ACCUMULATOR_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ZERO, +) + + +def _out(value): + return [{"$bucketAuto": {"groupBy": "$x", "buckets": 2, "output": value}}] + + +# Property [Output Type Rejection]: the 'output' field must be an object; all +# non-object types are rejected. +BUCKET_AUTO_OUTPUT_TYPE_TESTS: list[StageTestCase] = [ + StageTestCase( + "output_string", + docs=[{"_id": 1}], + pipeline=_out("bad"), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject string output", + ), + StageTestCase( + "output_int32", + docs=[{"_id": 1}], + pipeline=_out(42), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject int32 output", + ), + StageTestCase( + "output_int64", + docs=[{"_id": 1}], + pipeline=_out(Int64(42)), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject int64 output", + ), + StageTestCase( + "output_double", + docs=[{"_id": 1}], + pipeline=_out(3.14), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject double output", + ), + StageTestCase( + "output_decimal128", + docs=[{"_id": 1}], + pipeline=_out(DECIMAL128_ZERO), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject Decimal128 output", + ), + StageTestCase( + "output_bool", + docs=[{"_id": 1}], + pipeline=_out(True), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject bool output", + ), + StageTestCase( + "output_null", + docs=[{"_id": 1}], + pipeline=_out(None), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject null output", + ), + StageTestCase( + "output_array", + docs=[{"_id": 1}], + pipeline=_out([1]), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject array output", + ), + StageTestCase( + "output_objectid", + docs=[{"_id": 1}], + pipeline=_out(ObjectId("000000000000000000000001")), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject ObjectId output", + ), + StageTestCase( + "output_datetime", + docs=[{"_id": 1}], + pipeline=_out(datetime(2024, 1, 1, tzinfo=timezone.utc)), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject datetime output", + ), + StageTestCase( + "output_timestamp", + docs=[{"_id": 1}], + pipeline=_out(Timestamp(1, 1)), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject Timestamp output", + ), + StageTestCase( + "output_binary", + docs=[{"_id": 1}], + pipeline=_out(Binary(b"hi")), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject Binary output", + ), + StageTestCase( + "output_regex", + docs=[{"_id": 1}], + pipeline=_out(Regex("abc")), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject Regex output", + ), + StageTestCase( + "output_code", + docs=[{"_id": 1}], + pipeline=_out(Code("function(){}")), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject Code output", + ), + StageTestCase( + "output_minkey", + docs=[{"_id": 1}], + pipeline=_out(MinKey()), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject MinKey output", + ), + StageTestCase( + "output_maxkey", + docs=[{"_id": 1}], + pipeline=_out(MaxKey()), + error_code=BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR, + msg="$bucketAuto should reject MaxKey output", + ), +] + +# Property [Output Field Dollar Prefix Rejection]: output field names starting +# with $ are rejected. +BUCKET_AUTO_OUTPUT_DOLLAR_PREFIX_TESTS: list[StageTestCase] = [ + StageTestCase( + "output_field_dollar_prefixed", + docs=[{"_id": 1}], + pipeline=_out({"$foo": {"$sum": 1}}), + error_code=BUCKET_OUTPUT_DOLLAR_PREFIX_ERROR, + msg="$bucketAuto should reject $-prefixed output field name", + ), +] + +# Property [Output Field Dot Rejection]: output field names containing a dot +# are rejected. +BUCKET_AUTO_OUTPUT_DOT_TESTS: list[StageTestCase] = [ + StageTestCase( + "output_field_dot_middle", + docs=[{"_id": 1}], + pipeline=_out({"a.b": {"$sum": 1}}), + error_code=BUCKET_OUTPUT_DOT_ERROR, + msg="$bucketAuto should reject output field name containing a dot", + ), +] + +# Property [Output Field Not Accumulator]: output field values must be +# accumulator objects; non-accumulator values are rejected. +BUCKET_AUTO_OUTPUT_NOT_ACCUMULATOR_TESTS: list[StageTestCase] = [ + StageTestCase( + "output_field_int_value", + docs=[{"_id": 1}], + pipeline=_out({"f": 42}), + error_code=BUCKET_OUTPUT_NOT_ACCUMULATOR_ERROR, + msg="$bucketAuto should reject non-accumulator int value in output", + ), + StageTestCase( + "output_field_string_value", + docs=[{"_id": 1}], + pipeline=_out({"f": "hello"}), + error_code=BUCKET_OUTPUT_NOT_ACCUMULATOR_ERROR, + msg="$bucketAuto should reject non-accumulator string value in output", + ), +] + +BUCKET_AUTO_OUTPUT_ERROR_TESTS = ( + BUCKET_AUTO_OUTPUT_TYPE_TESTS + + BUCKET_AUTO_OUTPUT_DOLLAR_PREFIX_TESTS + + BUCKET_AUTO_OUTPUT_DOT_TESTS + + BUCKET_AUTO_OUTPUT_NOT_ACCUMULATOR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(BUCKET_AUTO_OUTPUT_ERROR_TESTS)) +def test_bucketAuto_output_errors(collection, test_case: StageTestCase): + """Test $bucketAuto output field validation errors.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_bucketAuto.py b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_bucketAuto.py new file mode 100644 index 000000000..dbeb9777a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_bucketAuto.py @@ -0,0 +1,148 @@ +"""Tests for $bucketAuto composing with other stages in common use cases.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Pipeline Composition]: $bucketAuto composes correctly with other +# stages in realistic multi-stage pipelines. +BUCKET_AUTO_PIPELINE_COMPOSITION_TESTS: list[StageTestCase] = [ + StageTestCase( + "match_before_bucketAuto", + docs=[ + {"_id": 1, "status": "active", "score": 15}, + {"_id": 2, "status": "inactive", "score": 25}, + {"_id": 3, "status": "active", "score": 35}, + {"_id": 4, "status": "active", "score": 45}, + ], + pipeline=[ + {"$match": {"status": "active"}}, + {"$bucketAuto": {"groupBy": "$score", "buckets": 2}}, + ], + expected=[ + {"_id": {"min": 15, "max": 45}, "count": 2}, + {"_id": {"min": 45, "max": 45}, "count": 1}, + ], + msg="$bucketAuto should operate on documents filtered by a preceding $match", + ), + StageTestCase( + "sort_after_bucketAuto", + docs=[{"_id": i, "x": i} for i in range(1, 5)], + pipeline=[ + {"$bucketAuto": {"groupBy": "$x", "buckets": 2}}, + {"$sort": {"_id.min": -1}}, + ], + expected=[ + {"_id": {"min": 3, "max": 4}, "count": 2}, + {"_id": {"min": 1, "max": 3}, "count": 2}, + ], + msg="$bucketAuto output should be sortable by a following $sort on the bucket _id", + ), + StageTestCase( + "limit_after_bucketAuto", + docs=[{"_id": i, "x": i} for i in range(1, 7)], + pipeline=[ + {"$bucketAuto": {"groupBy": "$x", "buckets": 3}}, + {"$limit": 2}, + ], + expected=[ + {"_id": {"min": 1, "max": 3}, "count": 2}, + {"_id": {"min": 3, "max": 5}, "count": 2}, + ], + msg="$limit should truncate $bucketAuto output", + ), + StageTestCase( + "skip_after_bucketAuto", + docs=[{"_id": i, "x": i} for i in range(1, 7)], + pipeline=[ + {"$bucketAuto": {"groupBy": "$x", "buckets": 3}}, + {"$skip": 1}, + ], + expected=[ + {"_id": {"min": 3, "max": 5}, "count": 2}, + {"_id": {"min": 5, "max": 6}, "count": 2}, + ], + msg="$skip should skip $bucketAuto output buckets", + ), + StageTestCase( + "facet_multiple_bucketAuto", + docs=[{"_id": 1, "x": 5, "y": 50}, {"_id": 2, "x": 15, "y": 150}], + pipeline=[ + { + "$facet": { + "byX": [{"$bucketAuto": {"groupBy": "$x", "buckets": 1}}], + "byY": [{"$bucketAuto": {"groupBy": "$y", "buckets": 1}}], + } + } + ], + expected=[ + { + "byX": [{"_id": {"min": 5, "max": 15}, "count": 2}], + "byY": [{"_id": {"min": 50, "max": 150}, "count": 2}], + } + ], + msg="$bucketAuto should run inside multiple $facet sub-pipelines over different fields", + ), + StageTestCase( + "group_after_bucketAuto", + docs=[{"_id": i, "x": i} for i in range(1, 7)], + pipeline=[ + {"$bucketAuto": {"groupBy": "$x", "buckets": 3}}, + {"$group": {"_id": None, "avg_count": {"$avg": "$count"}}}, + ], + expected=[{"_id": None, "avg_count": 2.0}], + msg="$group should aggregate across $bucketAuto output", + ), + StageTestCase( + "project_after_bucketAuto", + docs=[ + {"_id": 1, "x": 5, "v": 10}, + {"_id": 2, "x": 5, "v": 20}, + {"_id": 3, "x": 15, "v": 30}, + ], + pipeline=[ + { + "$bucketAuto": { + "groupBy": "$x", + "buckets": 2, + "output": {"total": {"$sum": "$v"}, "n": {"$sum": 1}}, + } + }, + {"$project": {"avg": {"$divide": ["$total", "$n"]}}}, + ], + expected=[ + {"_id": {"min": 5, "max": 15}, "avg": 15.0}, + {"_id": {"min": 15, "max": 15}, "avg": 30.0}, + ], + msg="$project should compute on fields produced by $bucketAuto", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(BUCKET_AUTO_PIPELINE_COMPOSITION_TESTS)) +def test_bucketAuto_pipeline_composition(collection, test_case: StageTestCase): + """Test $bucketAuto composing with other stages in common use cases.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index af7608d53..c805893da 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -318,6 +318,19 @@ BUCKET_OUTPUT_DOLLAR_PREFIX_ERROR = 40236 GROUP_ACCUMULATOR_ARRAY_ARGUMENT_ERROR = 40237 GROUP_ACCUMULATOR_MULTIPLE_KEYS_ERROR = 40238 +BUCKET_AUTO_GROUPBY_NOT_EXPRESSION_ERROR = 40239 +BUCKET_AUTO_ARG_NOT_OBJECT_ERROR = 40240 +BUCKET_AUTO_BUCKETS_NOT_NUMERIC_ERROR = 40241 +BUCKET_AUTO_BUCKETS_NOT_INT_ERROR = 40242 +BUCKET_AUTO_BUCKETS_NOT_POSITIVE_ERROR = 40243 +BUCKET_AUTO_OUTPUT_NOT_OBJECT_ERROR = 40244 +BUCKET_AUTO_UNRECOGNIZED_OPTION_ERROR = 40245 +BUCKET_AUTO_MISSING_REQUIRED_ERROR = 40246 +BUCKET_AUTO_GRANULARITY_UNKNOWN_ERROR = 40257 +BUCKET_AUTO_GRANULARITY_NON_NUMERIC_ERROR = 40258 +BUCKET_AUTO_GRANULARITY_NAN_ERROR = 40259 +BUCKET_AUTO_GRANULARITY_NEGATIVE_ERROR = 40260 +BUCKET_AUTO_GRANULARITY_NOT_STRING_ERROR = 40261 SET_SPECIFICATION_NOT_OBJECT_ERROR = 40272 PIPELINE_STAGE_EXTRA_FIELD_ERROR = 40323 UNKNOWN_PIPELINE_STAGE_ERROR = 40324 diff --git a/documentdb_tests/framework/test_constants.py b/documentdb_tests/framework/test_constants.py index 02002c2aa..4b877aa69 100644 --- a/documentdb_tests/framework/test_constants.py +++ b/documentdb_tests/framework/test_constants.py @@ -81,6 +81,23 @@ DECIMAL128_JUST_BELOW_HALF = Decimal128("0.4999999999999999999999999999999999") DECIMAL128_JUST_ABOVE_HALF = Decimal128("0.5000000000000000000000000000000001") +# $bucketAuto granularity preferred-number series values +GRANULARITY_VALUES = [ + "R5", + "R10", + "R20", + "R40", + "R80", + "1-2-5", + "E6", + "E12", + "E24", + "E48", + "E96", + "E192", + "POWERSOF2", +] + # Other constant values MISSING = "$missing" STRING_SIZE_LIMIT_BYTES = 16 * 1024 * 1024