From c572c40c4a8884433c76fe3e098ce9d40f27984b Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Wed, 8 Jul 2026 16:37:47 -0700 Subject: [PATCH 1/5] Add $dateAdd expression tests This change adds tests for the $dateAdd expression. It was originally authored by @mitchell-elholm. Closes #304 Co-authored-by: Mitchell Elholm Signed-off-by: Daniel Frankcom --- .../expressions/date/dateAdd/__init__.py | 0 .../date/dateAdd/test_dateAdd_basic.py | 304 +++++++++++ .../date/dateAdd/test_dateAdd_dates.py | 514 ++++++++++++++++++ .../date/dateAdd/test_dateAdd_expressions.py | 145 +++++ .../date/dateAdd/test_dateAdd_timezone.py | 369 +++++++++++++ .../date/dateAdd/test_dateAdd_types.py | 380 +++++++++++++ .../test_dateAdd_dateSubtract_equivalence.py | 157 ++++++ ..._expressions_combination_date_operators.py | 138 +++++ 8 files changed, 2007 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_basic.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_dates.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_timezone.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/test_dateAdd_dateSubtract_equivalence.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_date_operators.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_basic.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_basic.py new file mode 100644 index 000000000..b50eaa798 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_basic.py @@ -0,0 +1,304 @@ +"""$dateAdd basic operations, amount types, and boundaries.""" + +from datetime import datetime, timezone + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import DATEADD_INVALID_AMOUNT_ERROR +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEAR_MIN, + DOUBLE_NEGATIVE_ZERO, + INT32_MAX, + INT32_MIN, +) + +# Property [Unit Arithmetic]: adding a positive amount advances the start date by that unit. +DATEADD_UNIT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "year_add", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=datetime(2001, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should add 1 year", + ), + ExpressionTestCase( + "month_add", + doc={"date": datetime(2000, 1, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 2}}, + expected=datetime(2000, 3, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should add 2 months", + ), + ExpressionTestCase( + "day_add", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 10}}, + expected=datetime(2000, 1, 11, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should add 10 days", + ), + ExpressionTestCase( + "hour_add", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "hour", "amount": 5}}, + expected=datetime(2000, 1, 1, 17, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should add 5 hours", + ), + ExpressionTestCase( + "minute_add", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "minute", "amount": 30}}, + expected=datetime(2000, 1, 1, 12, 30, 0, tzinfo=timezone.utc), + msg="$dateAdd should add 30 minutes", + ), + ExpressionTestCase( + "second_add", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "second", "amount": 45}}, + expected=datetime(2000, 1, 1, 12, 0, 45, tzinfo=timezone.utc), + msg="$dateAdd should add 45 seconds", + ), + ExpressionTestCase( + "millisecond_add", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "millisecond", "amount": 500}}, + expected=datetime(2000, 1, 1, 12, 0, 0, 500000, tzinfo=timezone.utc), + msg="$dateAdd should add 500 milliseconds", + ), + ExpressionTestCase( + "week_add", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "week", "amount": 2}}, + expected=datetime(2000, 1, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should add 2 weeks", + ), + ExpressionTestCase( + "quarter_add", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "quarter", "amount": 1}}, + expected=datetime(2000, 4, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should add 1 quarter", + ), +] + +# Property [Negative Amount]: a negative amount subtracts the given unit. +DATEADD_NEGATIVE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "year_subtract", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": -1}}, + expected=datetime(1999, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should subtract 1 year with a negative amount", + ), + ExpressionTestCase( + "month_subtract", + doc={"date": datetime(2000, 3, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": -2}}, + expected=datetime(2000, 1, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should subtract 2 months with a negative amount", + ), + ExpressionTestCase( + "day_subtract", + doc={"date": datetime(2000, 1, 11, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": -10}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should subtract 10 days with a negative amount", + ), +] + +# Property [Zero Amount]: a zero amount for any unit returns the start date unchanged. +DATEADD_ZERO_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"zero_amount_{unit}", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": unit, "amount": 0}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg=f"$dateAdd should return the start date unchanged for a zero {unit} amount", + ) + for unit in ( + "year", + "quarter", + "month", + "week", + "day", + "hour", + "minute", + "second", + "millisecond", + ) +] + +# Property [Amount Numeric Types]: integral int64, double, and decimal128 amounts, including +# negative zero, are accepted. +DATEADD_AMOUNT_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "amount_int64", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": Int64(5)}}, + expected=datetime(2000, 1, 6, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should accept an int64 amount", + ), + ExpressionTestCase( + "amount_double_integral", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 5.0}}, + expected=datetime(2000, 1, 6, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should accept an integral double amount", + ), + ExpressionTestCase( + "amount_decimal", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": Decimal128("5")}}, + expected=datetime(2000, 1, 6, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should accept an integral decimal128 amount", + ), + ExpressionTestCase( + "amount_double_negative_zero", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": {"startDate": "$date", "unit": "day", "amount": DOUBLE_NEGATIVE_ZERO} + }, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should treat a negative-zero double as a zero amount", + ), + ExpressionTestCase( + "amount_decimal128_negative_zero", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": {"startDate": "$date", "unit": "day", "amount": DECIMAL128_NEGATIVE_ZERO} + }, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should treat a negative-zero decimal128 as a zero amount", + ), + ExpressionTestCase( + "amount_decimal128_max_precision_integral", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "day", + "amount": Decimal128("3.0000000000000000000000000000000000"), + } + }, + expected=datetime(2000, 1, 4, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should accept a max-precision integral decimal128 amount", + ), +] + +# Property [Amount Boundary]: large valid int32/int64 amounts are accepted. +DATEADD_AMOUNT_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "amount_long_large", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": {"startDate": "$date", "unit": "second", "amount": Int64(2_200_000_000)} + }, + expected=datetime(2069, 9, 18, 11, 6, 40, tzinfo=timezone.utc), + msg="$dateAdd should accept a large int64 amount", + ), + ExpressionTestCase( + "amount_int32_max", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "millisecond", "amount": INT32_MAX}}, + expected=datetime(2000, 1, 26, 8, 31, 23, 647000, tzinfo=timezone.utc), + msg="$dateAdd should accept INT32_MAX milliseconds", + ), + ExpressionTestCase( + "amount_int32_min", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "millisecond", "amount": INT32_MIN}}, + expected=datetime(1999, 12, 7, 15, 28, 36, 352000, tzinfo=timezone.utc), + msg="$dateAdd should accept INT32_MIN milliseconds", + ), +] + +# Property [Amount Non-Integral]: a non-integral numeric amount is rejected. +DATEADD_AMOUNT_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "amount_non_integral_double_1_5", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1.5}}, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateAdd should reject a non-integral double amount", + ), + ExpressionTestCase( + "amount_non_integral_double_5_9", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 5.9}}, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateAdd should reject a non-integral double amount close to an integer", + ), + ExpressionTestCase( + "amount_non_integral_double_negative", + doc={"date": datetime(2000, 1, 10, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": -5.9}}, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateAdd should reject a non-integral negative double amount", + ), + ExpressionTestCase( + "amount_non_integral_decimal128", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": {"startDate": "$date", "unit": "day", "amount": DECIMAL128_ONE_AND_HALF} + }, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateAdd should reject a non-integral decimal128 amount", + ), + ExpressionTestCase( + "amount_decimal128_non_integral_34th_digit", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "day", + "amount": Decimal128("3.000000000000000000000000000000001"), + } + }, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateAdd should reject a decimal128 amount that is non-integral at the 34th digit", + ), + ExpressionTestCase( + "amount_double_near_min", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": DOUBLE_NEAR_MIN}}, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateAdd should reject a near-minimum double amount as non-integral", + ), + ExpressionTestCase( + "amount_double_min_subnormal", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": {"startDate": "$date", "unit": "day", "amount": DOUBLE_MIN_SUBNORMAL} + }, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateAdd should reject a minimum subnormal double amount as non-integral", + ), +] + +DATEADD_BASIC_TESTS: list[ExpressionTestCase] = ( + DATEADD_UNIT_TESTS + + DATEADD_NEGATIVE_TESTS + + DATEADD_ZERO_TESTS + + DATEADD_AMOUNT_TYPE_TESTS + + DATEADD_AMOUNT_BOUNDARY_TESTS + + DATEADD_AMOUNT_ERROR_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEADD_BASIC_TESTS)) +def test_dateAdd_basic(collection, test_case: ExpressionTestCase): + """Test $dateAdd basic operations.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_dates.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_dates.py new file mode 100644 index 000000000..2e90bee76 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_dates.py @@ -0,0 +1,514 @@ +"""$dateAdd date edge cases: leap years, month clamping, boundaries, and non-date starts.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( + oid_from_args, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import DATEADD_INVALID_LARGE_VALUE_ERROR +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_EPOCH, + DATE_MS_EPOCH, + DATE_Y2K, + DATE_YEAR_1900, + DATE_YEAR_9999, + OID_EPOCH, + OID_MAX_SIGNED32, + OID_MIN_SIGNED32, + TS_EPOCH, + TS_MAX_SIGNED32, + TS_MAX_UNSIGNED32, +) + +# Property [Leap Year]: adding across February resolves leap-year day counts correctly. +DATEADD_LEAP_YEAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "leap_year_feb29_add_year", + doc={"date": datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=datetime(2001, 2, 28, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Feb 29 to Feb 28 when adding a year into a non-leap year", + ), + ExpressionTestCase( + "leap_year_add_day_to_feb28", + doc={"date": datetime(2000, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should land on Feb 29 in a leap year", + ), + ExpressionTestCase( + "non_leap_year_feb28_add_day", + doc={"date": datetime(1999, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1999, 3, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should roll to Mar 1 in a non-leap year", + ), + ExpressionTestCase( + "leap_year_feb29_add_month", + doc={"date": datetime(2020, 2, 29, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2020, 3, 29, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should add a month from Feb 29 to Mar 29", + ), + ExpressionTestCase( + "leap_year_feb27_add_2days", + doc={"date": datetime(2020, 2, 27, 14, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 2}}, + expected=datetime(2020, 2, 29, 14, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should land on Feb 29 in a leap year", + ), + ExpressionTestCase( + "non_leap_year_feb27_add_2days", + doc={"date": datetime(2021, 2, 27, 14, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 2}}, + expected=datetime(2021, 3, 1, 14, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should roll to Mar 1 in a non-leap year", + ), + ExpressionTestCase( + "leap_year_365_days", + doc={"date": datetime(2020, 1, 1, 15, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 365}}, + expected=datetime(2020, 12, 31, 15, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should not wrap the year when adding 365 days in a leap year", + ), + ExpressionTestCase( + "non_leap_year_365_days", + doc={"date": datetime(2019, 1, 1, 15, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 365}}, + expected=datetime(2020, 1, 1, 15, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should wrap to the next year when adding 365 days in a non-leap year", + ), +] + +# Property [Century Leap Year]: the divisible-by-100/400 leap rule is honored. +DATEADD_CENTURY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "century_non_leap_1900_feb28_add_day", + doc={"date": datetime(1900, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1900, 3, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should roll to Mar 1 in 1900, a non-leap century year", + ), + ExpressionTestCase( + "century_leap_2000_feb28_add_day", + doc={"date": datetime(2000, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should land on Feb 29 in 2000, a leap century year", + ), +] + +# Property [Month Clamping]: adding months or quarters clamps to the last valid day of the +# target month. +DATEADD_MONTH_CLAMP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "jan31_add_month_leap", + doc={"date": datetime(2020, 1, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2020, 2, 29, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Jan 31 plus 1 month to Feb 29 in a leap year", + ), + ExpressionTestCase( + "jan31_add_month_non_leap", + doc={"date": datetime(2021, 1, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2021, 2, 28, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Jan 31 plus 1 month to Feb 28 in a non-leap year", + ), + ExpressionTestCase( + "jan29_add_month_non_leap", + doc={"date": datetime(2021, 1, 29, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2021, 2, 28, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Jan 29 plus 1 month to Feb 28 in a non-leap year", + ), + ExpressionTestCase( + "oct31_add_month", + doc={"date": datetime(2020, 10, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2020, 11, 30, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Oct 31 plus 1 month to Nov 30", + ), + ExpressionTestCase( + "mar31_add_month", + doc={"date": datetime(2000, 3, 31, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2000, 4, 30, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Mar 31 plus 1 month to Apr 30", + ), + ExpressionTestCase( + "may31_add_month", + doc={"date": datetime(2000, 5, 31, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2000, 6, 30, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp May 31 plus 1 month to Jun 30", + ), + ExpressionTestCase( + "aug31_add_month", + doc={"date": datetime(2020, 8, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2020, 9, 30, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Aug 31 plus 1 month to Sep 30", + ), + ExpressionTestCase( + "dec31_add_year_no_adjustment", + doc={"date": datetime(2020, 12, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=datetime(2021, 12, 31, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should not adjust Dec 31 when adding a year", + ), + ExpressionTestCase( + "mar31_subtract_month_leap", + doc={"date": datetime(2020, 3, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": -1}}, + expected=datetime(2020, 2, 29, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Mar 31 minus 1 month to Feb 29 in a leap year", + ), + ExpressionTestCase( + "mar31_subtract_month_non_leap", + doc={"date": datetime(2021, 3, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": -1}}, + expected=datetime(2021, 2, 28, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Mar 31 minus 1 month to Feb 28 in a non-leap year", + ), + ExpressionTestCase( + "jan31_subtract_month_no_adjustment", + doc={"date": datetime(2021, 1, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": -1}}, + expected=datetime(2020, 12, 31, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should not adjust Jan 31 minus 1 month, landing on Dec 31", + ), + ExpressionTestCase( + "jan31_add_quarter", + doc={"date": datetime(2021, 1, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "quarter", "amount": 1}}, + expected=datetime(2021, 4, 30, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Jan 31 plus 1 quarter to Apr 30", + ), + ExpressionTestCase( + "quarter_subtract", + doc={"date": datetime(2021, 4, 30, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "quarter", "amount": -1}}, + expected=datetime(2021, 1, 30, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should subtract 1 quarter", + ), + ExpressionTestCase( + "large_positive_month", + doc={"date": datetime(2020, 12, 31, 12, 10, 5, 10000, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 49}}, + expected=datetime(2025, 1, 31, 12, 10, 5, 10000, tzinfo=timezone.utc), + msg="$dateAdd should clamp a large positive month amount to end-of-month", + ), + ExpressionTestCase( + "large_negative_month", + doc={"date": datetime(2020, 12, 31, 12, 10, 5, 10000, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": -49}}, + expected=datetime(2016, 11, 30, 12, 10, 5, 10000, tzinfo=timezone.utc), + msg="$dateAdd should clamp a large negative month amount to end-of-month", + ), +] + +# Property [Boundary Crossing]: adding across day, month, and year boundaries carries correctly. +DATEADD_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dec31_add_day", + doc={"date": datetime(2000, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(2001, 1, 1, 23, 59, 59, tzinfo=timezone.utc), + msg="$dateAdd should cross the year boundary from Dec 31 plus 1 day", + ), + ExpressionTestCase( + "jan1_subtract_day", + doc={"date": DATE_Y2K}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": -1}}, + expected=datetime(1999, 12, 31, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should cross the year boundary from Jan 1 minus 1 day", + ), + ExpressionTestCase( + "dec31_add_hour", + doc={"date": datetime(2000, 12, 31, 23, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "hour", "amount": 2}}, + expected=datetime(2001, 1, 1, 1, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should cross the year boundary from Dec 31 plus 2 hours", + ), +] + +# Property [Historical And Future]: distant past and future start dates are handled. +DATEADD_HISTORICAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "historical_date", + doc={"date": datetime(1900, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 10}}, + expected=datetime(1910, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should handle a 1900 historical date", + ), + ExpressionTestCase( + "pre_epoch_1960", + doc={"date": datetime(1960, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 100}}, + expected=datetime(1960, 4, 10, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should handle a pre-epoch 1960 date", + ), + ExpressionTestCase( + "far_future", + doc={"date": datetime(2100, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 100}}, + expected=datetime(2200, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should handle a far-future 2100 date", + ), + ExpressionTestCase( + "large_year_amount", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 1000}}, + expected=datetime(3000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should handle adding 1000 years", + ), + ExpressionTestCase( + "distant_past", + doc={"date": DATE_YEAR_1900}, + expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=datetime(1901, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should handle 1900 plus 1 year", + ), + ExpressionTestCase( + "distant_future_month", + doc={"date": datetime(2100, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 6}}, + expected=datetime(2100, 7, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should handle 2100 plus 6 months", + ), +] + +# Property [Epoch Crossing]: adding forward and backward across the Unix epoch is correct. +DATEADD_EPOCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "epoch_add_day", + doc={"date": DATE_EPOCH}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1970, 1, 2, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should add a day to the epoch", + ), + ExpressionTestCase( + "pre_epoch_add_day", + doc={"date": datetime(1969, 12, 31, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=DATE_EPOCH, + msg="$dateAdd should cross the epoch forward from 1969", + ), + ExpressionTestCase( + "cross_epoch_back", + doc={"date": DATE_EPOCH}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": -1}}, + expected=datetime(1969, 12, 31, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should cross the epoch backward to 1969", + ), +] + +# Property [Date Limits]: adding near the maximum representable date is handled. +DATEADD_DATE_LIMIT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "near_max_date", + doc={"date": datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "millisecond", "amount": 1}}, + expected=datetime(9999, 12, 31, 23, 59, 59, 1000, tzinfo=timezone.utc), + msg="$dateAdd should add 1 millisecond near the maximum date", + ), + ExpressionTestCase( + "epoch_plus_large_ms", + doc={"date": DATE_EPOCH}, + expression={ + "$dateAdd": {"startDate": "$date", "unit": "millisecond", "amount": 253_402_300_799_999} + }, + expected=DATE_YEAR_9999, + msg="$dateAdd should reach the near-maximum date from the epoch with a large ms amount", + ), + ExpressionTestCase( + "at_python_max_year", + doc={"date": datetime(9999, 12, 31, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "hour", "amount": 23}}, + expected=datetime(9999, 12, 31, 23, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should add 23 hours at year 9999", + ), +] + +# Property [Unit Equivalence]: a smaller unit times its multiple equals the larger unit. +DATEADD_UNIT_EQUIVALENCE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "millisecond_1000_equals_second_1", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "millisecond", "amount": 1000}}, + expected=datetime(2000, 1, 1, 12, 0, 1, tzinfo=timezone.utc), + msg="$dateAdd of 1000 milliseconds should equal adding 1 second", + ), + ExpressionTestCase( + "millisecond_precision_1ms", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "millisecond", "amount": 1}}, + expected=datetime(2000, 1, 1, 12, 0, 0, 1000, tzinfo=timezone.utc), + msg="$dateAdd should increment by exactly 1 millisecond", + ), + ExpressionTestCase( + "day_7_equals_week", + doc={"date": datetime(2000, 6, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 7}}, + expected=datetime(2000, 6, 8, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd of 7 days should equal adding 1 week", + ), + ExpressionTestCase( + "month_12_equals_year", + doc={"date": datetime(2000, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 12}}, + expected=datetime(2001, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd of 12 months should equal adding 1 year", + ), + ExpressionTestCase( + "month_3_equals_quarter", + doc={"date": datetime(2000, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 3}}, + expected=datetime(2000, 9, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd of 3 months should equal adding 1 quarter", + ), +] + +# Property [Timestamp Start Date]: a Timestamp or DatetimeMS start date is accepted and returns +# a Date. +DATEADD_TIMESTAMP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "timestamp_startDate_day", + doc={"date": ts_from_args(2020, 12, 31, 12, 10, 5)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(2021, 1, 1, 12, 10, 5, tzinfo=timezone.utc), + msg="$dateAdd should accept a Timestamp start date and return a Date", + ), + ExpressionTestCase( + "timestamp_startDate_second", + doc={"date": ts_from_args(2000, 1, 1, 12, 0, 0)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2000, 1, 1, 12, 0, 1, tzinfo=timezone.utc), + msg="$dateAdd should add a second to a Timestamp start date", + ), + ExpressionTestCase( + "timestamp_epoch", + doc={"date": TS_EPOCH}, + expression={"$dateAdd": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(1970, 1, 1, 0, 0, 1, tzinfo=timezone.utc), + msg="$dateAdd should accept an epoch Timestamp start date", + ), + ExpressionTestCase( + "date_ms_epoch_add_day", + doc={"date": DATE_MS_EPOCH}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1970, 1, 2, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should add a day to an epoch DatetimeMS start date", + ), + ExpressionTestCase( + "ts_max_s32_add_second", + doc={"date": TS_MAX_SIGNED32}, + expression={"$dateAdd": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2038, 1, 19, 3, 14, 8, tzinfo=timezone.utc), + msg="$dateAdd should add a second to a max signed 32-bit Timestamp", + ), + ExpressionTestCase( + "ts_max_u32_add_second", + doc={"date": TS_MAX_UNSIGNED32}, + expression={"$dateAdd": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2106, 2, 7, 6, 28, 16, tzinfo=timezone.utc), + msg="$dateAdd should add a second to a max unsigned 32-bit Timestamp", + ), +] + +# Property [ObjectId Start Date]: an ObjectId start date uses its embedded timestamp and +# returns a Date. +DATEADD_OBJECTID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "objectid_startDate_day", + doc={"date": oid_from_args(2022, 7, 15, 22, 32, 25)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=datetime(2023, 7, 15, 22, 32, 25, tzinfo=timezone.utc), + msg="$dateAdd should accept an ObjectId start date and return a Date", + ), + ExpressionTestCase( + "objectid_startDate_second", + doc={"date": oid_from_args(2022, 7, 15, 22, 32, 25)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2022, 7, 15, 22, 32, 26, tzinfo=timezone.utc), + msg="$dateAdd should add a second to an ObjectId start date", + ), + ExpressionTestCase( + "objectid_startDate_millisecond", + doc={"date": oid_from_args(2022, 7, 15, 22, 32, 25)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "millisecond", "amount": 500}}, + expected=datetime(2022, 7, 15, 22, 32, 25, 500000, tzinfo=timezone.utc), + msg="$dateAdd should add sub-second precision to an ObjectId start date", + ), + ExpressionTestCase( + "objectid_epoch", + doc={"date": OID_EPOCH}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1970, 1, 2, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should accept an epoch ObjectId start date", + ), + ExpressionTestCase( + "objectid_max_signed32", + doc={"date": OID_MAX_SIGNED32}, + expression={"$dateAdd": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2038, 1, 19, 3, 14, 8, tzinfo=timezone.utc), + msg="$dateAdd should add a second to a max signed 32-bit ObjectId", + ), + ExpressionTestCase( + "objectid_high_bit_pre_epoch", + doc={"date": OID_MIN_SIGNED32}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1901, 12, 14, 20, 45, 52, tzinfo=timezone.utc), + msg="$dateAdd should handle an ObjectId with the high timestamp bit set", + ), +] + +DATEADD_DATE_SUCCESS_TESTS = ( + DATEADD_LEAP_YEAR_TESTS + + DATEADD_CENTURY_TESTS + + DATEADD_MONTH_CLAMP_TESTS + + DATEADD_BOUNDARY_TESTS + + DATEADD_HISTORICAL_TESTS + + DATEADD_EPOCH_TESTS + + DATEADD_DATE_LIMIT_TESTS + + DATEADD_UNIT_EQUIVALENCE_TESTS + + DATEADD_TIMESTAMP_TESTS + + DATEADD_OBJECTID_TESTS +) + +# Property [Overflow]: an amount that pushes the result beyond the representable date range +# is rejected. +DATEADD_OVERFLOW_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "large_negative_month_overflow", + doc={"date": datetime(2020, 12, 31, 12, 10, 5, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": -30_000_000_000}}, + error_code=DATEADD_INVALID_LARGE_VALUE_ERROR, + msg="$dateAdd should reject a month amount that overflows the representable date range", + ), +] + +DATEADD_DATE_TESTS: list[ExpressionTestCase] = ( + DATEADD_DATE_SUCCESS_TESTS + DATEADD_OVERFLOW_ERROR_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEADD_DATE_TESTS)) +def test_dateAdd_dates(collection, test_case: ExpressionTestCase): + """Test $dateAdd date edge cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_expressions.py new file mode 100644 index 000000000..308b83513 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_expressions.py @@ -0,0 +1,145 @@ +"""$dateAdd expression inputs, array-resolving field paths, and Date return type.""" + +from datetime import datetime, timezone + +import pytest +from bson import ObjectId, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import DATEADD_INVALID_STARTDATE_ERROR +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Literal Input]: $dateAdd evaluates literal operands. +DATEADD_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_year_add", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "year", + "amount": 1, + } + }, + expected=datetime(2001, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should add 1 year from literal operands", + ), + ExpressionTestCase( + "literal_day_add", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": 10, + } + }, + expected=datetime(2000, 1, 11, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should add 10 days from literal operands", + ), + ExpressionTestCase( + "literal_null_startDate", + expression={"$dateAdd": {"startDate": None, "unit": "day", "amount": 1}}, + expected=None, + msg="$dateAdd should return null for a literal null startDate", + ), +] + +# Property [Field Reference Operands]: the amount and unit operands resolve from field references. +DATEADD_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "field_ref_amount", + doc={"amt": 5}, + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": "$amt", + } + }, + expected=datetime(2000, 1, 6, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should resolve the amount from a field reference", + ), + ExpressionTestCase( + "field_ref_unit", + doc={"unit_field": "day"}, + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "$unit_field", + "amount": 5, + } + }, + expected=datetime(2000, 1, 6, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should resolve the unit from a field reference", + ), +] + +# Property [Array-Resolving Path]: a startDate field path that resolves to an array is rejected +# by the operator's startDate type contract. +DATEADD_ARRAY_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "composite_array_path", + doc={ + "a": [ + {"b": datetime(2021, 6, 15, tzinfo=timezone.utc)}, + {"b": datetime(2021, 7, 1, tzinfo=timezone.utc)}, + ] + }, + expression={"$dateAdd": {"startDate": "$a.b", "unit": "day", "amount": 1}}, + error_code=DATEADD_INVALID_STARTDATE_ERROR, + msg="$dateAdd should reject a composite array field path as startDate", + ), + ExpressionTestCase( + "single_element_array_path", + doc={"a": [{"b": datetime(2021, 6, 15, tzinfo=timezone.utc)}]}, + expression={"$dateAdd": {"startDate": "$a.b", "unit": "day", "amount": 1}}, + error_code=DATEADD_INVALID_STARTDATE_ERROR, + msg="$dateAdd should reject a single-element array field path as startDate", + ), +] + +# Property [Return Type]: $dateAdd returns a Date regardless of the start date's date-like type. +DATEADD_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type_from_date", + doc={"date": datetime(2021, 1, 1, tzinfo=timezone.utc)}, + expression={"$type": {"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}}, + expected="date", + msg="$dateAdd should return a Date from a Date start date", + ), + ExpressionTestCase( + "return_type_from_timestamp", + doc={"date": Timestamp(1609459200, 1)}, + expression={"$type": {"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}}, + expected="date", + msg="$dateAdd should return a Date from a Timestamp start date", + ), + ExpressionTestCase( + "return_type_from_objectid", + doc={"date": ObjectId("600000000000000000000000")}, + expression={"$type": {"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}}, + expected="date", + msg="$dateAdd should return a Date from an ObjectId start date", + ), +] + +DATEADD_EXPRESSION_TESTS: list[ExpressionTestCase] = ( + DATEADD_LITERAL_TESTS + + DATEADD_FIELD_REF_TESTS + + DATEADD_ARRAY_PATH_TESTS + + DATEADD_RETURN_TYPE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEADD_EXPRESSION_TESTS)) +def test_dateAdd_expressions(collection, test_case: ExpressionTestCase): + """Test $dateAdd expression inputs and return type.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_timezone.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_timezone.py new file mode 100644 index 000000000..bad17664d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_timezone.py @@ -0,0 +1,369 @@ +"""$dateAdd timezone: Olson IDs, UTC offsets, DST, null, and invalid values and types.""" + +from datetime import datetime, timezone + +import pytest +from bson import MaxKey, MinKey + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + INVALID_TIMEZONE_ERROR, + INVALID_TIMEZONE_TYPE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Olson Timezone]: a valid Olson timezone id is accepted. +DATEADD_TIMEZONE_OLSON_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"timezone_{tid}", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": {"startDate": "$date", "unit": "hour", "amount": 5, "timezone": tz} + }, + expected=datetime(2000, 1, 1, 17, 0, 0, tzinfo=timezone.utc), + msg=f"$dateAdd should accept the {tz} timezone", + ) + for tid, tz in [ + ("utc", "UTC"), + ("gmt", "GMT"), + ("america_ny", "America/New_York"), + ("europe_london", "Europe/London"), + ("asia_tokyo", "Asia/Tokyo"), + ("asia_kolkata", "Asia/Kolkata"), + ("pacific_apia", "Pacific/Apia"), + ] +] + +# Property [UTC Offset]: syntactically valid UTC offset strings are accepted, including +# numerically out-of-range but well-formed offsets. +DATEADD_TIMEZONE_OFFSET_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"timezone_offset_{tid}", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": {"startDate": "$date", "unit": "hour", "amount": 5, "timezone": tz} + }, + expected=datetime(2000, 1, 1, 17, 0, 0, tzinfo=timezone.utc), + msg=f"$dateAdd should accept the {tz} UTC offset", + ) + for tid, tz in [ + ("colon_positive", "+05:30"), + ("no_colon", "-0530"), + ("hour_only", "+03"), + ("zero", "+00:00"), + ("max_east", "+14:00"), + ("max_west", "-11:00"), + ("half_hour_west", "-02:30"), + ("45min", "+05:45"), + ("over60_minutes_positive", "+05:70"), + ("over60_minutes_negative", "-05:70"), + ("over24_hours_positive", "+25:00"), + ("over24_hours_negative", "-25:00"), + ("max_valid_positive", "+99:99"), + ("max_valid_negative", "-99:99"), + ("out_of_range_east", "+15:00"), + ("out_of_range_west", "-13:00"), + ] +] + +# Property [DST Transition]: day and larger units track wall-clock time across DST, while +# 24-hour and sub-day units add absolute time and do not adjust. +DATEADD_TIMEZONE_DST_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dst_spring_forward_day", + doc={"date": datetime(2021, 3, 13, 15, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "day", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 3, 14, 14, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should DST-adjust a day across spring-forward", + ), + ExpressionTestCase( + "dst_spring_forward_hour_24", + doc={"date": datetime(2021, 3, 13, 15, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "hour", + "amount": 24, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 3, 14, 15, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should not DST-adjust 24 hours across spring-forward", + ), + ExpressionTestCase( + "dst_spring_forward_week", + doc={"date": datetime(2021, 3, 7, 10, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "week", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 3, 14, 9, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should DST-adjust a week across spring-forward", + ), + ExpressionTestCase( + "dst_spring_forward_month", + doc={"date": datetime(2021, 2, 14, 10, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "month", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 3, 14, 9, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should DST-adjust a month across spring-forward", + ), + ExpressionTestCase( + "dst_spring_forward_quarter", + doc={"date": datetime(2021, 1, 14, 10, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "quarter", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 4, 14, 9, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should DST-adjust a quarter across spring-forward", + ), + ExpressionTestCase( + "dst_fall_back_day", + doc={"date": datetime(2021, 11, 6, 10, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "day", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 11, 7, 11, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should DST-adjust a day across fall-back", + ), + ExpressionTestCase( + "dst_fall_back_hour_24", + doc={"date": datetime(2021, 11, 6, 10, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "hour", + "amount": 24, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 11, 7, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should not DST-adjust 24 hours across fall-back", + ), + ExpressionTestCase( + "dst_fall_back_week", + doc={"date": datetime(2021, 10, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "week", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 11, 7, 11, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should DST-adjust a week across fall-back", + ), + ExpressionTestCase( + "dst_spring_minute_no_adjust", + doc={"date": datetime(2021, 3, 14, 6, 59, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "minute", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 3, 14, 7, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should not DST-adjust a minute", + ), + ExpressionTestCase( + "dst_spring_second_no_adjust", + doc={"date": datetime(2021, 3, 14, 6, 59, 59, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "second", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 3, 14, 7, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should not DST-adjust a second", + ), + ExpressionTestCase( + "dst_spring_millisecond_no_adjust", + doc={"date": datetime(2021, 3, 14, 6, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "millisecond", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 3, 14, 7, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should not DST-adjust a millisecond", + ), + ExpressionTestCase( + "dst_europe_paris_hour_no_adjust", + doc={"date": datetime(2020, 10, 24, 18, 10, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "hour", + "amount": 24, + "timezone": "Europe/Paris", + } + }, + expected=datetime(2020, 10, 25, 18, 10, 0, tzinfo=timezone.utc), + msg="$dateAdd should not DST-adjust 24 hours in Europe/Paris", + ), + ExpressionTestCase( + "dst_europe_paris_day_adjust", + doc={"date": datetime(2020, 10, 24, 18, 10, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": "$date", + "unit": "day", + "amount": 1, + "timezone": "Europe/Paris", + } + }, + expected=datetime(2020, 10, 25, 19, 10, 0, tzinfo=timezone.utc), + msg="$dateAdd should DST-adjust a day across Europe/Paris fall-back", + ), +] + +# Property [Timezone Field Reference]: the timezone operand resolves from a field, and a +# missing timezone field reference returns null. +DATEADD_TIMEZONE_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "timezone_field_ref", + doc={"tz": "Europe/Paris"}, + expression={ + "$dateAdd": { + "startDate": datetime(2020, 12, 31, 12, 10, 5, tzinfo=timezone.utc), + "unit": "month", + "amount": 2, + "timezone": "$tz", + } + }, + expected=datetime(2021, 2, 28, 12, 10, 5, tzinfo=timezone.utc), + msg="$dateAdd should resolve the timezone from a field reference", + ), + ExpressionTestCase( + "timezone_missing_field_ref", + doc={}, + expression={ + "$dateAdd": { + "startDate": datetime(2020, 12, 31, 12, 10, 5, tzinfo=timezone.utc), + "unit": "month", + "amount": 2, + "timezone": "$tz", + } + }, + expected=None, + msg="$dateAdd should return null for a missing timezone field reference", + ), +] + +# Property [Null Timezone]: a null timezone returns null. +DATEADD_TIMEZONE_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "timezone_null", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": {"startDate": "$date", "unit": "hour", "amount": 5, "timezone": None} + }, + expected=None, + msg="$dateAdd should return null when the timezone is null", + ), +] + +DATEADD_TIMEZONE_SUCCESS_TESTS = ( + DATEADD_TIMEZONE_OLSON_TESTS + + DATEADD_TIMEZONE_OFFSET_TESTS + + DATEADD_TIMEZONE_DST_TESTS + + DATEADD_TIMEZONE_FIELD_REF_TESTS + + DATEADD_TIMEZONE_NULL_TESTS +) + +# Property [Invalid Timezone]: an unrecognized or malformed timezone string is rejected. +DATEADD_TIMEZONE_INVALID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"timezone_{tid}", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": {"startDate": "$date", "unit": "hour", "amount": 5, "timezone": tz} + }, + error_code=INVALID_TIMEZONE_ERROR, + msg=f"$dateAdd should reject {desc}", + ) + for tid, tz, desc in [ + ("offset_3digit_hours_invalid", "+100:00", "a 3-digit hour offset"), + ("invalid", "Invalid/Timezone", "an unrecognized Olson timezone"), + ("empty_string", "", "an empty string timezone"), + ("olson_wrong_case_lowercase", "america/new_york", "an all-lowercase Olson name"), + ("olson_wrong_case_uppercase", "AMERICA/NEW_YORK", "an all-uppercase Olson name"), + ("olson_wrong_case_mixed", "america/New_York", "a mixed-case Olson name"), + ] +] + +# Property [Timezone Type]: a non-string timezone is rejected as an invalid type. +DATEADD_TIMEZONE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"timezone_{tid}", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": {"startDate": "$date", "unit": "hour", "amount": 5, "timezone": tz} + }, + error_code=INVALID_TIMEZONE_TYPE_ERROR, + msg=f"$dateAdd should reject a {tid} timezone", + ) + for tid, tz in [ + ("number", 5), + ("boolean", True), + ("array", ["UTC"]), + ("object", {"tz": "UTC"}), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +DATEADD_TIMEZONE_ERROR_TESTS = DATEADD_TIMEZONE_INVALID_TESTS + DATEADD_TIMEZONE_TYPE_ERROR_TESTS + +DATEADD_TIMEZONE_TESTS = DATEADD_TIMEZONE_SUCCESS_TESTS + DATEADD_TIMEZONE_ERROR_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(DATEADD_TIMEZONE_TESTS)) +def test_dateAdd_timezone(collection, test_case: ExpressionTestCase): + """Test $dateAdd timezone handling.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_types.py new file mode 100644 index 000000000..a354a9c74 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_types.py @@ -0,0 +1,380 @@ +"""$dateAdd type validation: null/missing, invalid field types, unit strings, argument shape.""" + +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.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + DATEADD_INVALID_AMOUNT_ERROR, + DATEADD_INVALID_STARTDATE_ERROR, + DATEADD_MISSING_FIELD_ERROR, + DATEADD_UNKNOWN_FIELD_ERROR, + FAILED_TO_PARSE_ERROR, + INVALID_DATE_UNIT_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + MISSING, +) + +# Property [Null Handling]: a null literal for startDate, amount, or unit returns null. +DATEADD_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_startDate", + doc={"date": None}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=None, + msg="$dateAdd should return null for a null startDate", + ), + ExpressionTestCase( + "null_amount", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": None, + } + }, + expected=None, + msg="$dateAdd should return null for a null amount", + ), + ExpressionTestCase( + "null_unit", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": None, + "amount": 1, + } + }, + expected=None, + msg="$dateAdd should return null for a null unit", + ), + ExpressionTestCase( + "null_startDate_zero_amount", + doc={"date": None}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 0}}, + expected=None, + msg="$dateAdd should return null for a null startDate even with a zero amount", + ), + ExpressionTestCase( + "all_null", + expression={"$dateAdd": {"startDate": None, "unit": None, "amount": None}}, + expected=None, + msg="$dateAdd should return null when all inputs are null", + ), +] + +# Property [Missing Field Reference]: a missing startDate, amount, unit, or timezone field +# reference returns null. +DATEADD_MISSING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_startDate", + expression={"$dateAdd": {"startDate": MISSING, "unit": "day", "amount": 1}}, + expected=None, + msg="$dateAdd should return null for a missing startDate field reference", + ), + ExpressionTestCase( + "missing_amount", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": MISSING, + } + }, + expected=None, + msg="$dateAdd should return null for a missing amount field reference", + ), + ExpressionTestCase( + "missing_unit", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": MISSING, + "amount": 1, + } + }, + expected=None, + msg="$dateAdd should return null for a missing unit field reference", + ), + ExpressionTestCase( + "missing_startDate_zero_amount", + expression={"$dateAdd": {"startDate": MISSING, "unit": "day", "amount": 0}}, + expected=None, + msg="$dateAdd should return null for a missing startDate field reference and zero amount", + ), + ExpressionTestCase( + "missing_timezone", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": 1, + "timezone": MISSING, + } + }, + expected=None, + msg="$dateAdd should return null for a missing timezone field reference", + ), + ExpressionTestCase( + "missing_startDate_with_tz", + expression={ + "$dateAdd": {"startDate": MISSING, "unit": "day", "amount": 1, "timezone": "UTC"} + }, + expected=None, + msg="$dateAdd should return null for a missing startDate even with a timezone", + ), +] + +DATEADD_TYPE_SUCCESS_TESTS = DATEADD_NULL_TESTS + DATEADD_MISSING_TESTS + +# Property [StartDate Type]: a non-date, non-Timestamp, non-ObjectId startDate is rejected. +DATEADD_STARTDATE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"startDate_{tid}", + doc={"date": val}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + error_code=DATEADD_INVALID_STARTDATE_ERROR, + msg=f"$dateAdd should reject a {tid} startDate", + ) + for tid, val in [ + ("string", "2000-01-01"), + ("int", 123_456_789), + ("int64", Int64(123_456_789)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", [2000, 1, 1]), + ("empty_array", []), + ("single_date_array", [datetime(2021, 6, 15, tzinfo=timezone.utc)]), + ("object", {"year": 2000}), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Amount Type]: a non-numeric amount is rejected. +DATEADD_AMOUNT_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"amount_{tid}", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": val, + } + }, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg=f"$dateAdd should reject a {tid} amount", + ) + for tid, val in [ + ("string", "5"), + ("boolean", True), + ("array", [5]), + ("object", {"value": 5}), + ("datetime", datetime(2000, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Amount Non-Finite]: a NaN or infinite numeric amount is rejected. +DATEADD_AMOUNT_NONFINITE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"amount_{tid}", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": val, + } + }, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg=f"$dateAdd should reject a {tid} amount", + ) + for tid, val in [ + ("nan", FLOAT_NAN), + ("decimal128_nan", DECIMAL128_NAN), + ("infinity", FLOAT_INFINITY), + ("neg_infinity", FLOAT_NEGATIVE_INFINITY), + ("decimal128_infinity", DECIMAL128_INFINITY), + ("decimal128_neg_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + +# Property [Unit Type]: a non-string unit is rejected as an invalid date unit. +DATEADD_UNIT_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"unit_{tid}", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": val, + "amount": 5, + } + }, + error_code=INVALID_DATE_UNIT_ERROR, + msg=f"$dateAdd should reject a {tid} unit", + ) + for tid, val in [ + ("number", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", ["day"]), + ("object", {"type": "day"}), + ("datetime", datetime(2000, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex("day")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Unit String]: an unrecognized unit string, including wrong case and plurals, is +# rejected at parse time. +DATEADD_UNIT_STRING_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"unit_{tid}", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": val, + "amount": 5, + } + }, + error_code=FAILED_TO_PARSE_ERROR, + msg=f"$dateAdd should reject the {desc}", + ) + for tid, val, desc in [ + ("invalid_string", "invalid", "unrecognized unit string"), + ("empty_string", "", "empty string unit"), + ("mixed_case_Day", "Day", "mixed-case unit Day"), + ("mixed_case_Hour", "Hour", "mixed-case unit Hour"), + ("mixed_case_Month", "Month", "mixed-case unit Month"), + ("mixed_case_Quarter", "Quarter", "mixed-case unit Quarter"), + ("mixed_case_Week", "Week", "mixed-case unit Week"), + ("mixed_case_Second", "Second", "mixed-case unit Second"), + ("mixed_case_Minute", "Minute", "mixed-case unit Minute"), + ("mixed_case_Millisecond", "Millisecond", "mixed-case unit Millisecond"), + ("mixed_case_Year", "Year", "mixed-case unit Year"), + ("uppercase_DAY", "DAY", "uppercase unit DAY"), + ("uppercase_MILLISECOND", "MILLISECOND", "uppercase unit MILLISECOND"), + ("uppercase_YEAR", "YEAR", "uppercase unit YEAR"), + ("uppercase_HOUR", "HOUR", "uppercase unit HOUR"), + ("uppercase_MONTH", "MONTH", "uppercase unit MONTH"), + ("uppercase_QUARTER", "QUARTER", "uppercase unit QUARTER"), + ("uppercase_WEEK", "WEEK", "uppercase unit WEEK"), + ("uppercase_SECOND", "SECOND", "uppercase unit SECOND"), + ("uppercase_MINUTE", "MINUTE", "uppercase unit MINUTE"), + ("plural_years", "years", "plural unit years"), + ("plural_days", "days", "plural unit days"), + ("plural_months", "months", "plural unit months"), + ("plural_hours", "hours", "plural unit hours"), + ("plural_minutes", "minutes", "plural unit minutes"), + ("plural_seconds", "seconds", "plural unit seconds"), + ("plural_milliseconds", "milliseconds", "plural unit milliseconds"), + ("plural_weeks", "weeks", "plural unit weeks"), + ("plural_quarters", "quarters", "plural unit quarters"), + ("epoch_invalid", "epoch", "invalid unit epoch"), + ] +] + +DATEADD_TYPE_ERROR_TESTS = ( + DATEADD_STARTDATE_TYPE_ERROR_TESTS + + DATEADD_AMOUNT_TYPE_ERROR_TESTS + + DATEADD_AMOUNT_NONFINITE_ERROR_TESTS + + DATEADD_UNIT_TYPE_ERROR_TESTS + + DATEADD_UNIT_STRING_ERROR_TESTS +) + +# Property [Argument Shape]: a missing required field or an unknown field is rejected. +DATEADD_ARGUMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arg_missing_startDate", + expression={"$dateAdd": {"unit": "day", "amount": 1}}, + error_code=DATEADD_MISSING_FIELD_ERROR, + msg="$dateAdd should error when startDate is missing", + ), + ExpressionTestCase( + "arg_missing_unit", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "amount": 1, + } + }, + error_code=DATEADD_MISSING_FIELD_ERROR, + msg="$dateAdd should error when unit is missing", + ), + ExpressionTestCase( + "arg_missing_amount", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + } + }, + error_code=DATEADD_MISSING_FIELD_ERROR, + msg="$dateAdd should error when amount is missing", + ), + ExpressionTestCase( + "arg_empty_object", + expression={"$dateAdd": {}}, + error_code=DATEADD_MISSING_FIELD_ERROR, + msg="$dateAdd should error for an empty argument object", + ), + ExpressionTestCase( + "arg_unknown_field", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": 1, + "foo": 1, + } + }, + error_code=DATEADD_UNKNOWN_FIELD_ERROR, + msg="$dateAdd should error for an unknown field", + ), +] + +DATEADD_TYPE_TESTS: list[ExpressionTestCase] = ( + DATEADD_TYPE_SUCCESS_TESTS + DATEADD_TYPE_ERROR_TESTS + DATEADD_ARGUMENT_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEADD_TYPE_TESTS)) +def test_dateAdd_types(collection, test_case: ExpressionTestCase): + """Test $dateAdd type validation and argument handling.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/test_dateAdd_dateSubtract_equivalence.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/test_dateAdd_dateSubtract_equivalence.py new file mode 100644 index 000000000..d8188a6e9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/test_dateAdd_dateSubtract_equivalence.py @@ -0,0 +1,157 @@ +"""$dateAdd / $dateSubtract equivalence under amount negation, plus add/subtract roundtrips.""" + +from datetime import datetime, timezone + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Negation Equivalence]: $dateAdd(date, unit, N) equals $dateSubtract(date, unit, -N), +# verified server-side with $eq. +DATEADD_EQUIVALENCE_TESTS: list[ExpressionTestCase] = [ + *[ + ExpressionTestCase( + f"negation_{unit}_{amount}", + expression={ + "$eq": [ + { + "$dateAdd": { + "startDate": datetime(2021, 6, 15, 12, 30, 45, tzinfo=timezone.utc), + "unit": unit, + "amount": amount, + } + }, + { + "$dateSubtract": { + "startDate": datetime(2021, 6, 15, 12, 30, 45, tzinfo=timezone.utc), + "unit": unit, + "amount": -amount, + } + }, + ] + }, + expected=True, + msg=f"$dateAdd should equal $dateSubtract with a negated {unit} amount", + ) + for unit, amount in [ + ("year", 1), + ("quarter", 2), + ("month", 3), + ("week", 4), + ("day", 10), + ("hour", 24), + ("minute", 120), + ("second", 3600), + ("millisecond", 5000), + ] + ], + ExpressionTestCase( + "negation_with_timezone", + expression={ + "$eq": [ + { + "$dateAdd": { + "startDate": datetime(2021, 3, 14, 10, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": 1, + "timezone": "America/New_York", + } + }, + { + "$dateSubtract": { + "startDate": datetime(2021, 3, 14, 10, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": -1, + "timezone": "America/New_York", + } + }, + ] + }, + expected=True, + msg="$dateAdd should equal $dateSubtract with a negated amount and a timezone", + ), + ExpressionTestCase( + "negation_int64_amount", + expression={ + "$eq": [ + { + "$dateAdd": { + "startDate": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "unit": "second", + "amount": Int64(86400), + } + }, + { + "$dateSubtract": { + "startDate": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "unit": "second", + "amount": Int64(-86400), + } + }, + ] + }, + expected=True, + msg="$dateAdd should equal $dateSubtract with negated Int64 amounts", + ), +] + +# Property [Roundtrip]: adding then subtracting the same amount returns the original date, +# reflecting end-of-month clamping. +DATEADD_ROUNDTRIP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "roundtrip_day_30", + expression={ + "$dateSubtract": { + "startDate": { + "$dateAdd": { + "startDate": datetime(2021, 6, 15, 12, 30, 45, tzinfo=timezone.utc), + "unit": "day", + "amount": 30, + } + }, + "unit": "day", + "amount": 30, + } + }, + expected=datetime(2021, 6, 15, 12, 30, 45, tzinfo=timezone.utc), + msg="add then subtract of the same amount should return the original date", + ), + ExpressionTestCase( + "roundtrip_month_clamping", + expression={ + "$dateSubtract": { + "startDate": { + "$dateAdd": { + "startDate": datetime(2021, 1, 31, 12, 0, 0, tzinfo=timezone.utc), + "unit": "month", + "amount": 1, + } + }, + "unit": "month", + "amount": 1, + } + }, + # Jan 31 + 1 month clamps to Feb 28, and Feb 28 - 1 month lands on Jan 28, not Jan 31. + expected=datetime(2021, 1, 28, 12, 0, 0, tzinfo=timezone.utc), + msg="a month roundtrip should reflect end-of-month clamping", + ), +] + +DATEADD_DATESUBTRACT_TESTS = DATEADD_EQUIVALENCE_TESTS + DATEADD_ROUNDTRIP_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(DATEADD_DATESUBTRACT_TESTS)) +def test_dateAdd_dateSubtract(collection, test_case: ExpressionTestCase): + """Test $dateAdd and $dateSubtract equivalence and roundtrips.""" + result = execute_expression(collection, test_case.expression) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_date_operators.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_date_operators.py new file mode 100644 index 000000000..194b5f9d0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_date_operators.py @@ -0,0 +1,138 @@ +"""Cross-operator combinations of $dateAdd/$dateSubtract with $cond, $let, $abs, and nesting.""" + +from datetime import datetime, timezone + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Cross-Operator Composition]: date operators compose with $cond, $let, $abs, +# $dateFromString, and each other. +DATE_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dateAdd_inside_cond", + doc={"date": datetime(2020, 12, 31, 12, 0, 0, tzinfo=timezone.utc), "active": True}, + expression={ + "$cond": { + "if": "$active", + "then": {"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + "else": "$date", + } + }, + expected=datetime(2021, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should compose inside $cond", + ), + ExpressionTestCase( + "dateAdd_with_let", + expression={ + "$let": { + "vars": {"d": datetime(2020, 12, 31, 12, 0, 0, tzinfo=timezone.utc)}, + "in": {"$dateAdd": {"startDate": "$$d", "unit": "day", "amount": 1}}, + } + }, + expected=datetime(2021, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should accept a $let variable as startDate", + ), + ExpressionTestCase( + "dateAdd_nested_literal", + expression={ + "$dateAdd": { + "startDate": {"$literal": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + "unit": "day", + "amount": 5, + } + }, + expected=datetime(2000, 1, 6, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should accept a nested $literal startDate", + ), + ExpressionTestCase( + "dateAdd_nested_abs", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": {"$abs": -5}, + } + }, + expected=datetime(2000, 1, 6, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should accept a nested $abs amount", + ), + ExpressionTestCase( + "dateAdd_nested_dateAdd", + doc={"date": datetime(2020, 12, 31, 12, 10, 5, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": { + "startDate": {"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + "unit": "day", + "amount": 1, + } + }, + expected=datetime(2021, 1, 2, 12, 10, 5, tzinfo=timezone.utc), + msg="nested $dateAdd should accumulate additions", + ), + ExpressionTestCase( + "dateSubtract_from_expression", + expression={ + "$dateSubtract": { + "startDate": {"$dateFromString": {"dateString": "2021-06-15"}}, + "unit": "day", + "amount": 1, + } + }, + expected=datetime(2021, 6, 14, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should accept an expression operator as startDate", + ), + ExpressionTestCase( + "dateSubtract_nested_literal", + expression={ + "$dateSubtract": { + "startDate": {"$literal": datetime(2000, 1, 6, 12, 0, 0, tzinfo=timezone.utc)}, + "unit": "day", + "amount": 5, + } + }, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should accept a nested $literal startDate", + ), + ExpressionTestCase( + "dateSubtract_nested_abs", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 6, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": {"$abs": -5}, + } + }, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should accept a nested $abs amount", + ), + ExpressionTestCase( + "dateDiff_from_expression", + expression={ + "$dateDiff": { + "startDate": {"$dateFromString": {"dateString": "2021-01-01"}}, + "endDate": {"$dateFromString": {"dateString": "2021-01-02"}}, + "unit": "day", + } + }, + expected=Int64(1), + msg="$dateDiff should accept expression operators as date inputs", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(DATE_COMBINATION_TESTS)) +def test_date_operator_combination(collection, test_case: ExpressionTestCase): + """Test date expression operators composed with other operators.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) From f08556245bd210f7b14773544be9dc247cc0224c Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Thu, 9 Jul 2026 10:27:22 -0700 Subject: [PATCH 2/5] Add $dateDiff expression tests This change adds tests for the $dateDiff expression. It was originally authored by @mitchell-elholm. Closes #305 Co-authored-by: Mitchell Elholm Signed-off-by: Daniel Frankcom --- .../expressions/date/dateDiff/__init__.py | 0 .../date/dateDiff/test_dateDiff_basic.py | 436 +++++++++++++++ .../date/dateDiff/test_dateDiff_dates.py | 522 ++++++++++++++++++ .../dateDiff/test_dateDiff_expressions.py | 185 +++++++ .../date/dateDiff/test_dateDiff_timezone.py | 420 ++++++++++++++ .../date/dateDiff/test_dateDiff_types.py | 450 +++++++++++++++ 6 files changed, 2013 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_basic.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_dates.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_timezone.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_types.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_basic.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_basic.py new file mode 100644 index 000000000..5b6ebbd05 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_basic.py @@ -0,0 +1,436 @@ +"""$dateDiff argument handling, valid operations, null, and invalid date and unit inputs.""" + +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.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + DATEDIFF_MISSING_ENDDATE_ERROR, + DATEDIFF_MISSING_STARTDATE_ERROR, + DATEDIFF_MISSING_UNIT_ERROR, + DATEDIFF_NON_OBJECT_ERROR, + DATEDIFF_UNKNOWN_FIELD_ERROR, + FAILED_TO_PARSE_ERROR, + INVALID_DATE_UNIT_ERROR, + INVALID_DATE_VALUE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NEGATIVE_INFINITY, + MISSING, +) + +# Property [Valid Operation]: with valid start date, end date, and unit, the difference is +# returned as a long, and the optional timezone and startOfWeek fields are accepted. +DATEDIFF_VALID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "all_required", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(152), + msg="$dateDiff should return the day difference for the required fields", + ), + ExpressionTestCase( + "with_timezone", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": "day", + "timezone": "UTC", + } + }, + expected=Int64(152), + msg="$dateDiff should accept an optional timezone", + ), + ExpressionTestCase( + "with_startOfWeek", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 1, 31, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": "monday", + } + }, + expected=Int64(4), + msg="$dateDiff should accept an optional startOfWeek for the week unit", + ), + ExpressionTestCase( + "all_five_fields", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 1, 31, tzinfo=timezone.utc), + "unit": "week", + "timezone": "UTC", + "startOfWeek": "monday", + } + }, + expected=Int64(4), + msg="$dateDiff should accept all five fields together", + ), +] + +# Property [Null Handling]: a null literal for startDate, endDate, or unit returns null. +DATEDIFF_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_startDate", + expression={ + "$dateDiff": { + "startDate": None, + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=None, + msg="$dateDiff should return null for a null startDate", + ), + ExpressionTestCase( + "null_endDate", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": None, + "unit": "day", + } + }, + expected=None, + msg="$dateDiff should return null for a null endDate", + ), + ExpressionTestCase( + "null_unit", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": None, + } + }, + expected=None, + msg="$dateDiff should return null for a null unit", + ), +] + +# Property [Missing Field Reference]: a missing startDate, endDate, unit, timezone, or +# startOfWeek field reference returns null. +DATEDIFF_MISSING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_startDate_ref", + expression={ + "$dateDiff": { + "startDate": MISSING, + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=None, + msg="$dateDiff should return null for a missing startDate field reference", + ), + ExpressionTestCase( + "missing_endDate_ref", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": MISSING, + "unit": "day", + } + }, + expected=None, + msg="$dateDiff should return null for a missing endDate field reference", + ), + ExpressionTestCase( + "missing_unit_ref", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": MISSING, + } + }, + expected=None, + msg="$dateDiff should return null for a missing unit field reference", + ), + ExpressionTestCase( + "missing_timezone_ref", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": "day", + "timezone": MISSING, + } + }, + expected=None, + msg="$dateDiff should return null for a missing timezone field reference", + ), + ExpressionTestCase( + "missing_startOfWeek_ref", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": MISSING, + } + }, + expected=None, + msg="$dateDiff should return null for a missing startOfWeek field reference", + ), +] + +DATEDIFF_BASIC_SUCCESS_TESTS = DATEDIFF_VALID_TESTS + DATEDIFF_NULL_TESTS + DATEDIFF_MISSING_TESTS + +# Property [StartDate Type]: a non-date, non-Timestamp, non-ObjectId startDate is rejected. +DATEDIFF_STARTDATE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"startDate_{tid}", + expression={ + "$dateDiff": { + "startDate": val, + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + error_code=INVALID_DATE_VALUE_ERROR, + msg=f"$dateDiff should reject a {tid} startDate", + ) + for tid, val in [ + ("string", "2024-01-01"), + ("int", 123), + ("int64", Int64(123)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", [2024, 1, 1]), + ("empty_array", []), + ("single_date_array", [datetime(2024, 1, 1, tzinfo=timezone.utc)]), + ("object", {"year": 2024}), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128_infinity", DECIMAL128_INFINITY), + ("decimal128_neg_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + +# Property [EndDate Type]: a non-date, non-Timestamp, non-ObjectId endDate is rejected. +DATEDIFF_ENDDATE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"endDate_{tid}", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": val, + "unit": "day", + } + }, + error_code=INVALID_DATE_VALUE_ERROR, + msg=f"$dateDiff should reject a {tid} endDate", + ) + for tid, val in [ + ("string", "2024-06-01"), + ("int", 123), + ("int64", Int64(123)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", [2024, 6, 1]), + ("empty_array", []), + ("single_date_array", [datetime(2024, 6, 1, tzinfo=timezone.utc)]), + ("object", {"year": 2024}), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128_infinity", DECIMAL128_INFINITY), + ("decimal128_neg_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + +# Property [Unit Type]: a non-string unit is rejected as an invalid date unit. +DATEDIFF_UNIT_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"unit_{tid}", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": val, + } + }, + error_code=INVALID_DATE_UNIT_ERROR, + msg=f"$dateDiff should reject a {tid} unit", + ) + for tid, val in [ + ("number", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", ["day"]), + ("object", {"t": "day"}), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex("day")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Unit String]: an unrecognized unit string, including wrong case and plurals, is +# rejected at parse time. +DATEDIFF_UNIT_STRING_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"unit_{tid}", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": val, + } + }, + error_code=FAILED_TO_PARSE_ERROR, + msg=f"$dateDiff should reject the {desc}", + ) + for tid, val, desc in [ + ("invalid_string", "invalid", "unrecognized unit string"), + ("empty_string", "", "empty string unit"), + ("epoch_invalid", "epoch", "invalid unit epoch"), + ("mixed_case_Day", "Day", "mixed-case unit Day"), + ("mixed_case_Hour", "Hour", "mixed-case unit Hour"), + ("mixed_case_Month", "Month", "mixed-case unit Month"), + ("mixed_case_Quarter", "Quarter", "mixed-case unit Quarter"), + ("mixed_case_Week", "Week", "mixed-case unit Week"), + ("mixed_case_Second", "Second", "mixed-case unit Second"), + ("mixed_case_Minute", "Minute", "mixed-case unit Minute"), + ("mixed_case_Millisecond", "Millisecond", "mixed-case unit Millisecond"), + ("mixed_case_Year", "Year", "mixed-case unit Year"), + ("uppercase_DAY", "DAY", "uppercase unit DAY"), + ("uppercase_YEAR", "YEAR", "uppercase unit YEAR"), + ("uppercase_HOUR", "HOUR", "uppercase unit HOUR"), + ("uppercase_MONTH", "MONTH", "uppercase unit MONTH"), + ("uppercase_MILLISECOND", "MILLISECOND", "uppercase unit MILLISECOND"), + ("uppercase_QUARTER", "QUARTER", "uppercase unit QUARTER"), + ("uppercase_WEEK", "WEEK", "uppercase unit WEEK"), + ("uppercase_SECOND", "SECOND", "uppercase unit SECOND"), + ("uppercase_MINUTE", "MINUTE", "uppercase unit MINUTE"), + ("plural_years", "years", "plural unit years"), + ("plural_months", "months", "plural unit months"), + ("plural_days", "days", "plural unit days"), + ("plural_hours", "hours", "plural unit hours"), + ("plural_minutes", "minutes", "plural unit minutes"), + ("plural_seconds", "seconds", "plural unit seconds"), + ("plural_milliseconds", "milliseconds", "plural unit milliseconds"), + ("plural_weeks", "weeks", "plural unit weeks"), + ("plural_quarters", "quarters", "plural unit quarters"), + ] +] + +# Property [Argument Shape]: a missing required field, an unknown field, or a non-object +# argument is rejected. +DATEDIFF_ARGUMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arg_missing_startDate", + expression={ + "$dateDiff": {"endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), "unit": "day"} + }, + error_code=DATEDIFF_MISSING_STARTDATE_ERROR, + msg="$dateDiff should error when startDate is missing", + ), + ExpressionTestCase( + "arg_missing_endDate", + expression={ + "$dateDiff": {"startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), "unit": "day"} + }, + error_code=DATEDIFF_MISSING_ENDDATE_ERROR, + msg="$dateDiff should error when endDate is missing", + ), + ExpressionTestCase( + "arg_missing_unit", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + } + }, + error_code=DATEDIFF_MISSING_UNIT_ERROR, + msg="$dateDiff should error when unit is missing", + ), + ExpressionTestCase( + "arg_empty_object", + expression={"$dateDiff": {}}, + error_code=DATEDIFF_MISSING_STARTDATE_ERROR, + msg="$dateDiff should report a missing startDate for an empty argument object", + ), + ExpressionTestCase( + "arg_unknown_field", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": "day", + "foo": 1, + } + }, + error_code=DATEDIFF_UNKNOWN_FIELD_ERROR, + msg="$dateDiff should error for an unknown field", + ), + ExpressionTestCase( + "arg_non_object_string", + expression={"$dateDiff": "string"}, + error_code=DATEDIFF_NON_OBJECT_ERROR, + msg="$dateDiff should reject a string argument as a non-object", + ), + ExpressionTestCase( + "arg_non_object_array", + expression={"$dateDiff": [1, 2]}, + error_code=DATEDIFF_NON_OBJECT_ERROR, + msg="$dateDiff should reject an array argument as a non-object", + ), + ExpressionTestCase( + "arg_non_object_number", + expression={"$dateDiff": 123}, + error_code=DATEDIFF_NON_OBJECT_ERROR, + msg="$dateDiff should reject a number argument as a non-object", + ), +] + +DATEDIFF_BASIC_ERROR_TESTS = ( + DATEDIFF_STARTDATE_TYPE_ERROR_TESTS + + DATEDIFF_ENDDATE_TYPE_ERROR_TESTS + + DATEDIFF_UNIT_TYPE_ERROR_TESTS + + DATEDIFF_UNIT_STRING_ERROR_TESTS + + DATEDIFF_ARGUMENT_TESTS +) + +DATEDIFF_BASIC_TESTS = DATEDIFF_BASIC_SUCCESS_TESTS + DATEDIFF_BASIC_ERROR_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_BASIC_TESTS)) +def test_dateDiff_basic(collection, test_case: ExpressionTestCase): + """Test $dateDiff argument handling and basic operations.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_dates.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_dates.py new file mode 100644 index 000000000..b0a2cd441 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_dates.py @@ -0,0 +1,522 @@ +"""$dateDiff sign handling, unit boundary counting, quarter counting, and week counting.""" + +from datetime import datetime, timezone + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DATE_YEAR_1, INT64_ZERO + +# Property [Sign Handling]: the difference is positive when endDate follows startDate, +# negative when it precedes, and zero when they are equal. +DATEDIFF_SIGN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "positive_diff", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(152), + msg="$dateDiff should return a positive difference when endDate follows startDate", + ), + ExpressionTestCase( + "negative_diff", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(-152), + msg="$dateDiff should return a negative difference when endDate precedes startDate", + ), + ExpressionTestCase( + "zero_diff", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=INT64_ZERO, + msg="$dateDiff should return zero when startDate equals endDate", + ), + ExpressionTestCase( + "negative_year", + expression={ + "$dateDiff": { + "startDate": datetime(2022, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "year", + } + }, + expected=Int64(-1), + msg="$dateDiff should return a negative year difference", + ), + ExpressionTestCase( + "negative_month", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 6, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "month", + } + }, + expected=Int64(-5), + msg="$dateDiff should return a negative month difference", + ), +] + +# Property [Boundary Counting]: the difference counts unit boundaries crossed, not elapsed +# whole units, so sub-unit remainders do not change the count. +DATEDIFF_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "year_exact", + expression={ + "$dateDiff": { + "startDate": datetime(2010, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2011, 1, 1, tzinfo=timezone.utc), + "unit": "year", + } + }, + expected=Int64(1), + msg="$dateDiff should count one year boundary for an exact year", + ), + ExpressionTestCase( + "year_18m_still_1", + expression={ + "$dateDiff": { + "startDate": datetime(2010, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2011, 6, 30, tzinfo=timezone.utc), + "unit": "year", + } + }, + expected=Int64(1), + msg="$dateDiff should count one year boundary for eighteen months", + ), + ExpressionTestCase( + "month_12", + expression={ + "$dateDiff": { + "startDate": datetime(2010, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2011, 1, 1, tzinfo=timezone.utc), + "unit": "month", + } + }, + expected=Int64(12), + msg="$dateDiff should count twelve month boundaries for one year", + ), + ExpressionTestCase( + "month_60d_is_1", + expression={ + "$dateDiff": { + "startDate": datetime(2010, 3, 1, tzinfo=timezone.utc), + "endDate": datetime(2010, 4, 30, tzinfo=timezone.utc), + "unit": "month", + } + }, + expected=Int64(1), + msg="$dateDiff should count one month boundary within sixty days", + ), + ExpressionTestCase( + "day_365", + expression={ + "$dateDiff": { + "startDate": datetime(2010, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2011, 1, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(365), + msg="$dateDiff should count 365 days across a non-leap year", + ), + ExpressionTestCase( + "day_60", + expression={ + "$dateDiff": { + "startDate": datetime(2010, 3, 1, tzinfo=timezone.utc), + "endDate": datetime(2010, 4, 30, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(60), + msg="$dateDiff should count sixty days from March to April", + ), + ExpressionTestCase( + "day_546", + expression={ + "$dateDiff": { + "startDate": datetime(2010, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2011, 7, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(546), + msg="$dateDiff should count 546 days across eighteen months", + ), + ExpressionTestCase( + "day_no_cross", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 1, 23, 59, 59, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=INT64_ZERO, + msg="$dateDiff should count zero days within a single calendar day", + ), + ExpressionTestCase( + "day_cross", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, 23, 59, 59, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 2, 0, 0, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(1), + msg="$dateDiff should count one day when crossing midnight by seconds", + ), + ExpressionTestCase( + "hour_no_cross", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 1, 0, 59, 59, tzinfo=timezone.utc), + "unit": "hour", + } + }, + expected=INT64_ZERO, + msg="$dateDiff should count zero hours within a single hour", + ), + ExpressionTestCase( + "hour_cross", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, 0, 59, 59, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 1, 1, 0, 1, tzinfo=timezone.utc), + "unit": "hour", + } + }, + expected=Int64(1), + msg="$dateDiff should count one hour when crossing the hour boundary by seconds", + ), + ExpressionTestCase( + "minute_no_cross", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 1, 0, 0, 59, tzinfo=timezone.utc), + "unit": "minute", + } + }, + expected=INT64_ZERO, + msg="$dateDiff should count zero minutes within a single minute", + ), + ExpressionTestCase( + "minute_cross", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, 0, 0, 59, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 1, 0, 1, 1, tzinfo=timezone.utc), + "unit": "minute", + } + }, + expected=Int64(1), + msg="$dateDiff should count one minute when crossing the minute boundary by seconds", + ), + ExpressionTestCase( + "second_no_cross", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, 0, 0, 0, 0, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 1, 0, 0, 0, 999000, tzinfo=timezone.utc), + "unit": "second", + } + }, + expected=INT64_ZERO, + msg="$dateDiff should count zero seconds within a single second", + ), + ExpressionTestCase( + "second_cross", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, 0, 0, 0, 999000, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 1, 0, 0, 1, 1000, tzinfo=timezone.utc), + "unit": "second", + } + }, + expected=Int64(1), + msg="$dateDiff should count one second when crossing the second boundary by milliseconds", + ), + ExpressionTestCase( + "millisecond_1", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, 0, 0, 0, 0, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 1, 0, 0, 0, 1000, tzinfo=timezone.utc), + "unit": "millisecond", + } + }, + expected=Int64(1), + msg="$dateDiff should count one millisecond", + ), + ExpressionTestCase( + "large_year_range", + expression={ + "$dateDiff": { + "startDate": DATE_YEAR_1, + "endDate": datetime(9999, 12, 31, tzinfo=timezone.utc), + "unit": "year", + } + }, + expected=Int64(9998), + msg="$dateDiff should count years across the full representable range", + ), + ExpressionTestCase( + "large_day_range", + expression={ + "$dateDiff": { + "startDate": DATE_YEAR_1, + "endDate": datetime(9999, 12, 31, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(3_652_058), + msg="$dateDiff should count days across the full representable range", + ), +] + +# Property [Quarter Counting]: the difference counts quarter boundaries crossed. +DATEDIFF_QUARTER_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "quarter_still_q1", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 3, 31, tzinfo=timezone.utc), + "unit": "quarter", + } + }, + expected=INT64_ZERO, + msg="$dateDiff should count zero quarters within the first quarter", + ), + ExpressionTestCase( + "quarter_cross_q2", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 4, 1, tzinfo=timezone.utc), + "unit": "quarter", + } + }, + expected=Int64(1), + msg="$dateDiff should count one quarter when crossing into the second quarter", + ), + ExpressionTestCase( + "quarter_3", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 12, 31, tzinfo=timezone.utc), + "unit": "quarter", + } + }, + expected=Int64(3), + msg="$dateDiff should count three quarters within a year", + ), + ExpressionTestCase( + "quarter_4", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2022, 1, 1, tzinfo=timezone.utc), + "unit": "quarter", + } + }, + expected=Int64(4), + msg="$dateDiff should count four quarters across a year", + ), + ExpressionTestCase( + "quarter_negative", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 4, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "quarter", + } + }, + expected=Int64(-1), + msg="$dateDiff should return a negative quarter difference", + ), +] + +# Property [Week Counting]: the difference counts week boundaries crossed relative to +# startOfWeek, defaulting to Sunday. +DATEDIFF_WEEK_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "jan_default_sun", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 31, tzinfo=timezone.utc), + "unit": "week", + } + }, + expected=Int64(5), + msg="$dateDiff should count January weeks from the default Sunday start", + ), + ExpressionTestCase( + "jan_monday", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 31, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": "monday", + } + }, + expected=Int64(4), + msg="$dateDiff should count January weeks from a Monday start", + ), + ExpressionTestCase( + "jan_friday", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 31, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": "fri", + } + }, + expected=Int64(4), + msg="$dateDiff should count January weeks from a Friday start", + ), + ExpressionTestCase( + "feb_default_sun", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 2, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 2, 28, tzinfo=timezone.utc), + "unit": "week", + } + }, + expected=Int64(4), + msg="$dateDiff should count February weeks from the default Sunday start", + ), + ExpressionTestCase( + "feb_monday", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 2, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 2, 28, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": "monday", + } + }, + expected=Int64(3), + msg="$dateDiff should count February weeks from a Monday start", + ), + ExpressionTestCase( + "feb_friday", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 2, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 2, 28, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": "fri", + } + }, + expected=Int64(4), + msg="$dateDiff should count February weeks from a Friday start", + ), + ExpressionTestCase( + "mar_default_sun", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 3, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 3, 31, tzinfo=timezone.utc), + "unit": "week", + } + }, + expected=Int64(4), + msg="$dateDiff should count March weeks from the default Sunday start", + ), + ExpressionTestCase( + "mar_monday", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 3, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 3, 31, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": "monday", + } + }, + expected=Int64(4), + msg="$dateDiff should count March weeks from a Monday start", + ), + ExpressionTestCase( + "mar_friday", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 3, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 3, 31, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": "fri", + } + }, + expected=Int64(4), + msg="$dateDiff should count March weeks from a Friday start", + ), + ExpressionTestCase( + "cross_year_week", + expression={ + "$dateDiff": { + "startDate": datetime(2020, 12, 28, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 4, tzinfo=timezone.utc), + "unit": "week", + } + }, + expected=Int64(1), + msg="$dateDiff should count one week across the year boundary", + ), + ExpressionTestCase( + "cross_month_week", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 25, tzinfo=timezone.utc), + "endDate": datetime(2021, 2, 8, tzinfo=timezone.utc), + "unit": "week", + } + }, + expected=Int64(2), + msg="$dateDiff should count two weeks across the month boundary", + ), +] + +DATEDIFF_DATE_TESTS = ( + DATEDIFF_SIGN_TESTS + DATEDIFF_BOUNDARY_TESTS + DATEDIFF_QUARTER_TESTS + DATEDIFF_WEEK_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_DATE_TESTS)) +def test_dateDiff_dates(collection, test_case: ExpressionTestCase): + """Test $dateDiff sign handling and unit boundary counting.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_expressions.py new file mode 100644 index 000000000..f2a9ec535 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_expressions.py @@ -0,0 +1,185 @@ +"""$dateDiff field-reference and expression operands, array-resolving paths, and return type.""" + +from datetime import datetime, timezone + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import INVALID_DATE_VALUE_ERROR +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import INT64_ZERO + +# Property [Field Reference Operands]: the startDate, endDate, unit, and timezone operands +# resolve from field references, including a nested path, and a missing timezone reference +# returns null. +DATEDIFF_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "field_refs", + doc={ + "s": datetime(2024, 1, 1, tzinfo=timezone.utc), + "e": datetime(2024, 1, 6, tzinfo=timezone.utc), + }, + expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "day"}}, + expected=Int64(5), + msg="$dateDiff should resolve startDate and endDate from field references", + ), + ExpressionTestCase( + "field_ref_unit", + doc={"u": "day"}, + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 1, 6, tzinfo=timezone.utc), + "unit": "$u", + } + }, + expected=Int64(5), + msg="$dateDiff should resolve the unit from a field reference", + ), + ExpressionTestCase( + "nested_field_path", + doc={ + "a": { + "s": datetime(2021, 1, 1, tzinfo=timezone.utc), + "e": datetime(2021, 1, 2, tzinfo=timezone.utc), + } + }, + expression={"$dateDiff": {"startDate": "$a.s", "endDate": "$a.e", "unit": "day"}}, + expected=Int64(1), + msg="$dateDiff should resolve date operands from a nested field path", + ), + ExpressionTestCase( + "timezone_field_ref", + doc={ + "s": datetime(2021, 12, 31, 23, 0, 0, tzinfo=timezone.utc), + "e": datetime(2022, 1, 1, 1, 0, 0, tzinfo=timezone.utc), + "tz": "+02:00", + }, + expression={ + "$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "year", "timezone": "$tz"} + }, + expected=INT64_ZERO, + msg="$dateDiff should resolve the timezone from a field reference", + ), + ExpressionTestCase( + "missing_timezone_field_ref", + doc={ + "s": datetime(2021, 1, 1, tzinfo=timezone.utc), + "e": datetime(2021, 1, 2, tzinfo=timezone.utc), + }, + expression={ + "$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "day", "timezone": "$tz"} + }, + expected=None, + msg="$dateDiff should return null for a missing timezone field reference", + ), +] + +# Property [Array-Resolving Path]: a field path that resolves to an array is delivered to the +# operator, which rejects it under its date type contract. +DATEDIFF_ARRAY_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "composite_array_path", + doc={ + "a": [ + {"b": datetime(2021, 1, 1, tzinfo=timezone.utc)}, + {"b": datetime(2021, 6, 1, tzinfo=timezone.utc)}, + ] + }, + expression={ + "$dateDiff": { + "startDate": "$a.b", + "endDate": datetime(2021, 1, 2, tzinfo=timezone.utc), + "unit": "day", + } + }, + error_code=INVALID_DATE_VALUE_ERROR, + msg="$dateDiff should reject a composite array field path as startDate", + ), + ExpressionTestCase( + "single_element_array_path", + doc={"a": [{"b": datetime(2021, 6, 15, tzinfo=timezone.utc)}]}, + expression={ + "$dateDiff": { + "startDate": "$a.b", + "endDate": datetime(2021, 6, 16, tzinfo=timezone.utc), + "unit": "day", + } + }, + error_code=INVALID_DATE_VALUE_ERROR, + msg="$dateDiff should reject a single-element array field path as startDate", + ), + ExpressionTestCase( + "array_index_path", + doc={"a": [{"b": datetime(2021, 1, 1, tzinfo=timezone.utc)}]}, + expression={ + "$dateDiff": { + "startDate": "$a.0.b", + "endDate": datetime(2021, 1, 2, tzinfo=timezone.utc), + "unit": "day", + } + }, + error_code=INVALID_DATE_VALUE_ERROR, + msg="$dateDiff should reject an array-index field path resolving to an array as startDate", + ), +] + +DATEDIFF_EXPRESSION_TESTS = DATEDIFF_FIELD_REF_TESTS + DATEDIFF_ARRAY_PATH_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_EXPRESSION_TESTS)) +def test_dateDiff_expressions(collection, test_case: ExpressionTestCase): + """Test $dateDiff field-reference and expression operands.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) + + +# Property [Return Type]: $dateDiff returns a long regardless of the unit. +DATEDIFF_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type_day", + expression={ + "$type": { + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 2, tzinfo=timezone.utc), + "unit": "day", + } + } + }, + expected="long", + msg="$dateDiff should return a long for a day difference", + ), + ExpressionTestCase( + "return_type_millisecond", + expression={ + "$type": { + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 2, tzinfo=timezone.utc), + "unit": "millisecond", + } + } + }, + expected="long", + msg="$dateDiff should return a long for a millisecond difference", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_RETURN_TYPE_TESTS)) +def test_dateDiff_return_type(collection, test_case: ExpressionTestCase): + """Test $dateDiff return type.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_timezone.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_timezone.py new file mode 100644 index 000000000..03516daee --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_timezone.py @@ -0,0 +1,420 @@ +"""$dateDiff timezone acceptance, DST behavior, startOfWeek values, and their type errors.""" + +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.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + INVALID_STARTOFWEEK_ERROR, + INVALID_STARTOFWEEK_TYPE_ERROR, + INVALID_TIMEZONE_ERROR, + INVALID_TIMEZONE_TYPE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import INT64_ZERO + +# Property [Valid Timezone]: a valid Olson id or syntactically valid UTC offset is accepted, +# including numerically out-of-range but well-formed offsets. +DATEDIFF_TIMEZONE_VALID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"tz_{tid}", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": "day", + "timezone": tz, + } + }, + expected=Int64(152), + msg=f"$dateDiff should accept the {tz} timezone", + ) + for tid, tz in [ + ("utc", "UTC"), + ("gmt", "GMT"), + ("olson_ny", "America/New_York"), + ("asia_kolkata", "Asia/Kolkata"), + ("pacific_apia", "Pacific/Apia"), + ("offset_colon", "+05:30"), + ("offset_neg", "-05:00"), + ("offset_no_colon", "+0530"), + ("offset_short", "+03"), + ("offset_zero", "+00:00"), + ("offset_45min", "+05:45"), + ("offset_max_east", "+14:00"), + ("offset_max_west", "-11:00"), + ("offset_half_hour_west", "-02:30"), + ("offset_over60_minutes_positive", "+05:70"), + ("offset_over60_minutes_negative", "-05:70"), + ("offset_over24_hours_positive", "+25:00"), + ("offset_over24_hours_negative", "-25:00"), + ("offset_max_valid_positive", "+99:99"), + ("offset_max_valid_negative", "-99:99"), + ] +] + +# Property [Timezone Boundary Effect]: the timezone shifts the local wall clock, changing +# which unit boundaries are crossed. +DATEDIFF_TIMEZONE_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_plus14_year", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 12, 31, 11, 0, 0, tzinfo=timezone.utc), + "endDate": datetime(2022, 1, 1, 11, 0, 0, tzinfo=timezone.utc), + "unit": "year", + "timezone": "+14:00", + } + }, + expected=INT64_ZERO, + msg="$dateDiff should not cross the year boundary in a +14:00 timezone", + ), + ExpressionTestCase( + "tz_minus11_day", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, 10, 0, 0, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 2, 10, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "timezone": "-11:00", + } + }, + expected=Int64(1), + msg="$dateDiff should cross the day boundary in a -11:00 timezone", + ), + ExpressionTestCase( + "tz_hour_only_day", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 2, 0, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "timezone": "+03", + } + }, + expected=Int64(1), + msg="$dateDiff should cross the day boundary in an hour-only offset timezone", + ), + ExpressionTestCase( + "tz_year_boundary", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 12, 31, 23, 0, 0, tzinfo=timezone.utc), + "endDate": datetime(2022, 1, 1, 1, 0, 0, tzinfo=timezone.utc), + "unit": "year", + "timezone": "+02:00", + } + }, + expected=INT64_ZERO, + msg="$dateDiff should not cross the year boundary when the timezone shifts both dates " + "into the same year", + ), + ExpressionTestCase( + "tz_day_boundary", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, 23, 30, 0, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 2, 0, 30, 0, tzinfo=timezone.utc), + "unit": "day", + "timezone": "+05:30", + } + }, + expected=INT64_ZERO, + msg="$dateDiff should not cross the day boundary when the timezone shifts both dates " + "into the same day", + ), +] + +# Property [DST Behavior]: day and larger units track wall-clock time across DST, while +# sub-day units count absolute elapsed time. +DATEDIFF_TIMEZONE_DST_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dst_spring_hour", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 3, 14, 6, 0, 0, tzinfo=timezone.utc), + "endDate": datetime(2021, 3, 14, 8, 0, 0, tzinfo=timezone.utc), + "unit": "hour", + "timezone": "America/New_York", + } + }, + expected=Int64(2), + msg="$dateDiff should count absolute hours across spring-forward", + ), + ExpressionTestCase( + "dst_fall_hour", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 11, 7, 5, 0, 0, tzinfo=timezone.utc), + "endDate": datetime(2021, 11, 7, 7, 0, 0, tzinfo=timezone.utc), + "unit": "hour", + "timezone": "America/New_York", + } + }, + expected=Int64(2), + msg="$dateDiff should count absolute hours across fall-back", + ), + ExpressionTestCase( + "dst_day_unaffected", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 3, 13, 12, 0, 0, tzinfo=timezone.utc), + "endDate": datetime(2021, 3, 15, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "timezone": "America/New_York", + } + }, + expected=Int64(2), + msg="$dateDiff should count two days across a DST transition", + ), + ExpressionTestCase( + "dst_spring_minute_no_adjust", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 3, 14, 6, 59, 0, tzinfo=timezone.utc), + "endDate": datetime(2021, 3, 14, 7, 0, 0, tzinfo=timezone.utc), + "unit": "minute", + "timezone": "America/New_York", + } + }, + expected=Int64(1), + msg="$dateDiff should not DST-adjust a minute count", + ), + ExpressionTestCase( + "dst_spring_second_no_adjust", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 3, 14, 6, 59, 59, tzinfo=timezone.utc), + "endDate": datetime(2021, 3, 14, 7, 0, 0, tzinfo=timezone.utc), + "unit": "second", + "timezone": "America/New_York", + } + }, + expected=Int64(1), + msg="$dateDiff should not DST-adjust a second count", + ), +] + +# Property [StartOfWeek Value]: startOfWeek accepts full and abbreviated day names +# case-insensitively. +DATEDIFF_STARTOFWEEK_VALUE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"sow_{tid}", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 1, 31, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": sow, + } + }, + expected=Int64(4), + msg=f"$dateDiff should accept the {sow} startOfWeek value", + ) + for tid, sow in [ + ("mixed_case_Monday", "Monday"), + ("uppercase_MONDAY", "MONDAY"), + ("lowercase_monday", "monday"), + ("abbrev_mon", "mon"), + ("abbrev_MON", "MON"), + ("mixed_case_Friday", "Friday"), + ("uppercase_FRIDAY", "FRIDAY"), + ("abbrev_FRI", "FRI"), + ] +] + +# Property [Null Timezone]: a null timezone returns null. +DATEDIFF_TIMEZONE_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_null", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 2, tzinfo=timezone.utc), + "unit": "day", + "timezone": None, + } + }, + expected=None, + msg="$dateDiff should return null when the timezone is null", + ), +] + +# Property [StartOfWeek Unit Gating]: startOfWeek is only consulted for the week unit, so with +# any other unit it is ignored and not even validated. +DATEDIFF_STARTOFWEEK_GATING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "sow_ignored_day_unit", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 1, 31, tzinfo=timezone.utc), + "unit": "day", + "startOfWeek": "monday", + } + }, + expected=Int64(30), + msg="$dateDiff should ignore startOfWeek for a non-week unit", + ), + ExpressionTestCase( + "sow_invalid_ignored_day_unit", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 1, 31, tzinfo=timezone.utc), + "unit": "day", + "startOfWeek": "notaday", + } + }, + expected=Int64(30), + msg="$dateDiff should not validate startOfWeek for a non-week unit", + ), +] + +DATEDIFF_TIMEZONE_SUCCESS_TESTS = ( + DATEDIFF_TIMEZONE_VALID_TESTS + + DATEDIFF_TIMEZONE_BOUNDARY_TESTS + + DATEDIFF_TIMEZONE_DST_TESTS + + DATEDIFF_STARTOFWEEK_VALUE_TESTS + + DATEDIFF_TIMEZONE_NULL_TESTS + + DATEDIFF_STARTOFWEEK_GATING_TESTS +) + +# Property [Invalid Timezone]: an unrecognized or malformed timezone string is rejected. +DATEDIFF_TIMEZONE_INVALID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"tz_{tid}", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": "day", + "timezone": tz, + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg=f"$dateDiff should reject {desc}", + ) + for tid, tz, desc in [ + ("offset_3digit_hours", "+100:00", "a 3-digit hour offset"), + ("invalid_string", "NotATimezone", "an unrecognized Olson timezone"), + ("empty_string", "", "an empty string timezone"), + ("olson_wrong_case_lowercase", "america/new_york", "an all-lowercase Olson name"), + ("olson_wrong_case_uppercase", "AMERICA/NEW_YORK", "an all-uppercase Olson name"), + ] +] + +# Property [Timezone Type]: a non-string timezone is rejected as an invalid type. +DATEDIFF_TIMEZONE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"tz_{tid}", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 2, tzinfo=timezone.utc), + "unit": "day", + "timezone": val, + } + }, + error_code=INVALID_TIMEZONE_TYPE_ERROR, + msg=f"$dateDiff should reject a {tid} timezone", + ) + for tid, val in [ + ("number", 5), + ("int64", Int64(5)), + ("double", 5.0), + ("decimal128", Decimal128("5")), + ("boolean", True), + ("array", ["UTC"]), + ("object", {"tz": "UTC"}), + ("datetime", datetime(2021, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex("UTC")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Invalid StartOfWeek]: an unrecognized startOfWeek string is rejected. +DATEDIFF_STARTOFWEEK_INVALID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"sow_{tid}", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 1, 31, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": sow, + } + }, + error_code=INVALID_STARTOFWEEK_ERROR, + msg=f"$dateDiff should reject {desc}", + ) + for tid, sow, desc in [ + ("invalid_string", "notaday", "an unrecognized startOfWeek string"), + ("empty_string", "", "an empty startOfWeek string"), + ] +] + +# Property [StartOfWeek Type]: a non-string startOfWeek is rejected as an invalid type. +DATEDIFF_STARTOFWEEK_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"sow_{tid}", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 1, 31, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": val, + } + }, + error_code=INVALID_STARTOFWEEK_TYPE_ERROR, + msg=f"$dateDiff should reject a {tid} startOfWeek", + ) + for tid, val in [ + ("number", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", ["monday"]), + ("object", {"day": "monday"}), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex("monday")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +DATEDIFF_TIMEZONE_ERROR_TESTS = ( + DATEDIFF_TIMEZONE_INVALID_TESTS + + DATEDIFF_TIMEZONE_TYPE_ERROR_TESTS + + DATEDIFF_STARTOFWEEK_INVALID_TESTS + + DATEDIFF_STARTOFWEEK_TYPE_ERROR_TESTS +) + +DATEDIFF_TIMEZONE_TESTS = DATEDIFF_TIMEZONE_SUCCESS_TESTS + DATEDIFF_TIMEZONE_ERROR_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_TIMEZONE_TESTS)) +def test_dateDiff_timezone(collection, test_case: ExpressionTestCase): + """Test $dateDiff timezone and startOfWeek handling.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_types.py new file mode 100644 index 000000000..ad6f75448 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_types.py @@ -0,0 +1,450 @@ +"""$dateDiff leap years, ObjectId/Timestamp/DatetimeMS date sources, and extreme boundaries.""" + +from datetime import datetime, timezone + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( + oid_from_args, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_EPOCH, + DATE_MS_BEFORE_EPOCH, + DATE_MS_EPOCH, + DATE_MS_MAX, + DATE_MS_MIN, + DATE_YEAR_1900, + INT64_ZERO, + OID_MAX_SIGNED32, + OID_MIN_SIGNED32, + TS_MAX_SIGNED32, + TS_MAX_UNSIGNED32, +) + +# Property [Leap Year]: day counting honors leap years, including the century leap rule. +DATEDIFF_LEAP_YEAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "leap_feb28_mar1", + expression={ + "$dateDiff": { + "startDate": datetime(2020, 2, 28, tzinfo=timezone.utc), + "endDate": datetime(2020, 3, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(2), + msg="$dateDiff should count two days across Feb 29 in a leap year", + ), + ExpressionTestCase( + "non_leap_feb28_mar1", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 2, 28, tzinfo=timezone.utc), + "endDate": datetime(2021, 3, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(1), + msg="$dateDiff should count one day from Feb 28 in a non-leap year", + ), + ExpressionTestCase( + "full_leap_year", + expression={ + "$dateDiff": { + "startDate": datetime(2020, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(366), + msg="$dateDiff should count 366 days across a full leap year", + ), + ExpressionTestCase( + "full_non_leap_year", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2022, 1, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(365), + msg="$dateDiff should count 365 days across a full non-leap year", + ), + ExpressionTestCase( + "century_leap_2000", + expression={ + "$dateDiff": { + "startDate": datetime(2000, 2, 28, tzinfo=timezone.utc), + "endDate": datetime(2000, 3, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(2), + msg="$dateDiff should treat 2000 as a leap century year", + ), + ExpressionTestCase( + "century_non_leap_1900", + expression={ + "$dateDiff": { + "startDate": datetime(1900, 2, 28, tzinfo=timezone.utc), + "endDate": datetime(1900, 3, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(1), + msg="$dateDiff should treat 1900 as a non-leap century year", + ), +] + +# Property [ObjectId Source]: an ObjectId date operand uses its embedded timestamp for either +# position and for the timezone option. +DATEDIFF_OBJECTID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "oid_day", + doc={"s": oid_from_args(2024, 1, 1, 0, 0, 0), "e": oid_from_args(2024, 1, 6, 0, 0, 0)}, + expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "day"}}, + expected=Int64(5), + msg="$dateDiff should count days between two ObjectId dates", + ), + ExpressionTestCase( + "oid_hour", + doc={"s": oid_from_args(2024, 1, 1, 0, 0, 0)}, + expression={ + "$dateDiff": { + "startDate": "$s", + "endDate": datetime(2024, 1, 1, 5, 0, 0, tzinfo=timezone.utc), + "unit": "hour", + } + }, + expected=Int64(5), + msg="$dateDiff should count hours from an ObjectId startDate", + ), + ExpressionTestCase( + "oid_month", + doc={"s": oid_from_args(2024, 1, 1, 0, 0, 0), "e": oid_from_args(2024, 6, 1, 0, 0, 0)}, + expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "month"}}, + expected=Int64(5), + msg="$dateDiff should count months between two ObjectId dates", + ), + ExpressionTestCase( + "oid_year", + doc={ + "s": oid_from_args(2024, 1, 1, 0, 0, 0), + "e": oid_from_args(2024, 12, 31, 23, 59, 59), + }, + expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "year"}}, + expected=INT64_ZERO, + msg="$dateDiff should count zero years within the same ObjectId year", + ), + ExpressionTestCase( + "oid_with_tz", + doc={"s": oid_from_args(2024, 1, 1, 0, 0, 0), "e": oid_from_args(2024, 1, 6, 0, 0, 0)}, + expression={ + "$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "day", "timezone": "UTC"} + }, + expected=Int64(5), + msg="$dateDiff should count days between ObjectId dates with a timezone", + ), + ExpressionTestCase( + "oid_negative", + doc={"s": oid_from_args(2024, 6, 1, 0, 0, 0), "e": oid_from_args(2024, 1, 1, 0, 0, 0)}, + expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "month"}}, + expected=Int64(-5), + msg="$dateDiff should return a negative month difference between ObjectId dates", + ), + ExpressionTestCase( + "oid_endDate", + doc={"e": oid_from_args(2024, 1, 6, 0, 0, 0)}, + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": "$e", + "unit": "day", + } + }, + expected=Int64(5), + msg="$dateDiff should accept an ObjectId endDate", + ), + ExpressionTestCase( + "oid_epoch", + doc={"s": oid_from_args(1970, 1, 1, 0, 0, 0)}, + expression={ + "$dateDiff": { + "startDate": "$s", + "endDate": datetime(1970, 1, 2, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(1), + msg="$dateDiff should accept an epoch ObjectId startDate", + ), + ExpressionTestCase( + "oid_far_future", + doc={"s": oid_from_args(2035, 1, 1, 0, 0, 0)}, + expression={ + "$dateDiff": { + "startDate": "$s", + "endDate": datetime(2035, 6, 1, tzinfo=timezone.utc), + "unit": "month", + } + }, + expected=Int64(5), + msg="$dateDiff should accept a far-future ObjectId startDate", + ), +] + +# Property [Timestamp Source]: a Timestamp date operand uses its seconds for either position +# and for the timezone option. +DATEDIFF_TIMESTAMP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "ts_day", + doc={"s": ts_from_args(2024, 1, 1, 0, 0, 0), "e": ts_from_args(2024, 1, 6, 0, 0, 0)}, + expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "day"}}, + expected=Int64(5), + msg="$dateDiff should count days between two Timestamp dates", + ), + ExpressionTestCase( + "ts_hour", + doc={"s": ts_from_args(2024, 1, 1, 0, 0, 0)}, + expression={ + "$dateDiff": { + "startDate": "$s", + "endDate": datetime(2024, 1, 1, 5, 0, 0, tzinfo=timezone.utc), + "unit": "hour", + } + }, + expected=Int64(5), + msg="$dateDiff should count hours from a Timestamp startDate", + ), + ExpressionTestCase( + "ts_month", + doc={"s": ts_from_args(2024, 1, 1, 0, 0, 0), "e": ts_from_args(2024, 6, 1, 0, 0, 0)}, + expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "month"}}, + expected=Int64(5), + msg="$dateDiff should count months between two Timestamp dates", + ), + ExpressionTestCase( + "ts_year", + doc={ + "s": ts_from_args(2024, 1, 1, 0, 0, 0), + "e": ts_from_args(2024, 12, 31, 23, 59, 59), + }, + expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "year"}}, + expected=INT64_ZERO, + msg="$dateDiff should count zero years within the same Timestamp year", + ), + ExpressionTestCase( + "ts_with_tz", + doc={"s": ts_from_args(2024, 1, 1, 0, 0, 0), "e": ts_from_args(2024, 1, 6, 0, 0, 0)}, + expression={ + "$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "day", "timezone": "UTC"} + }, + expected=Int64(5), + msg="$dateDiff should count days between Timestamp dates with a timezone", + ), + ExpressionTestCase( + "ts_negative", + doc={"s": ts_from_args(2024, 6, 1, 0, 0, 0), "e": ts_from_args(2024, 1, 1, 0, 0, 0)}, + expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "month"}}, + expected=Int64(-5), + msg="$dateDiff should return a negative month difference between Timestamp dates", + ), + ExpressionTestCase( + "ts_epoch", + doc={"s": ts_from_args(1970, 1, 1, 0, 0, 0)}, + expression={ + "$dateDiff": { + "startDate": "$s", + "endDate": datetime(1970, 1, 2, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(1), + msg="$dateDiff should accept an epoch Timestamp startDate", + ), + ExpressionTestCase( + "ts_far_future", + doc={"s": ts_from_args(2100, 1, 1, 0, 0, 0)}, + expression={ + "$dateDiff": { + "startDate": "$s", + "endDate": datetime(2100, 6, 1, tzinfo=timezone.utc), + "unit": "month", + } + }, + expected=Int64(5), + msg="$dateDiff should accept a far-future Timestamp startDate", + ), +] + +# Property [Mixed Source]: ObjectId and Timestamp date operands can be combined. +DATEDIFF_MIXED_SOURCE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "oid_ts_mixed", + doc={"s": oid_from_args(2024, 1, 1, 0, 0, 0), "e": ts_from_args(2024, 1, 6, 0, 0, 0)}, + expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "day"}}, + expected=Int64(5), + msg="$dateDiff should count days from an ObjectId startDate to a Timestamp endDate", + ), + ExpressionTestCase( + "ts_oid_mixed", + doc={"s": ts_from_args(2024, 1, 1, 0, 0, 0), "e": oid_from_args(2024, 6, 1, 0, 0, 0)}, + expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "month"}}, + expected=Int64(5), + msg="$dateDiff should count months from a Timestamp startDate to an ObjectId endDate", + ), +] + +# Property [Epoch Crossing]: dates around the Unix epoch and far from it are counted correctly. +DATEDIFF_EPOCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "epoch_date", + doc={"s": DATE_EPOCH}, + expression={ + "$dateDiff": { + "startDate": "$s", + "endDate": datetime(1970, 1, 2, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(1), + msg="$dateDiff should count one day from the epoch", + ), + ExpressionTestCase( + "pre_epoch", + expression={ + "$dateDiff": { + "startDate": datetime(1969, 12, 31, tzinfo=timezone.utc), + "endDate": DATE_EPOCH, + "unit": "day", + } + }, + expected=Int64(1), + msg="$dateDiff should count one day across the epoch from 1969", + ), + ExpressionTestCase( + "distant_past", + doc={"s": DATE_YEAR_1900}, + expression={ + "$dateDiff": { + "startDate": "$s", + "endDate": datetime(1900, 12, 31, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(364), + msg="$dateDiff should count days within a distant past year", + ), + ExpressionTestCase( + "distant_future", + expression={ + "$dateDiff": { + "startDate": datetime(2100, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2100, 12, 31, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(364), + msg="$dateDiff should count days within a distant future year", + ), + ExpressionTestCase( + "cross_epoch", + expression={ + "$dateDiff": { + "startDate": datetime(1969, 6, 1, tzinfo=timezone.utc), + "endDate": datetime(1970, 6, 1, tzinfo=timezone.utc), + "unit": "year", + } + }, + expected=Int64(1), + msg="$dateDiff should count one year crossing the epoch", + ), +] + +# Property [Extreme Boundary]: DatetimeMS, Timestamp, and ObjectId values at the edges of the +# 32-bit and 64-bit ranges are counted correctly. +DATEDIFF_EXTREME_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_ms_epoch_diff", + doc={"s": DATE_MS_EPOCH}, + expression={ + "$dateDiff": { + "startDate": "$s", + "endDate": datetime(1970, 1, 2, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(1), + msg="$dateDiff should count one day from an epoch DatetimeMS", + ), + ExpressionTestCase( + "date_ms_before_epoch_diff", + doc={"s": DATE_MS_BEFORE_EPOCH}, + expression={"$dateDiff": {"startDate": "$s", "endDate": DATE_EPOCH, "unit": "millisecond"}}, + expected=Int64(1), + msg="$dateDiff should count one millisecond from just before the epoch", + ), + ExpressionTestCase( + "ts_boundary_max_s32", + doc={"s": TS_MAX_SIGNED32, "e": TS_MAX_UNSIGNED32}, + expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "year"}}, + expected=Int64(68), + msg="$dateDiff should count years between the signed and unsigned 32-bit Timestamp limits", + ), + ExpressionTestCase( + "oid_max_signed32", + doc={"s": OID_MAX_SIGNED32}, + expression={ + "$dateDiff": { + "startDate": "$s", + "endDate": datetime(2038, 1, 20, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(1), + msg="$dateDiff should count from a max signed 32-bit ObjectId", + ), + ExpressionTestCase( + "oid_high_bit_pre_epoch", + doc={"s": OID_MIN_SIGNED32}, + expression={"$dateDiff": {"startDate": "$s", "endDate": DATE_EPOCH, "unit": "year"}}, + expected=Int64(69), + msg="$dateDiff should handle an ObjectId with the high timestamp bit set as pre-epoch", + ), + ExpressionTestCase( + "ms_overflow_int64", + doc={"s": DATE_MS_MIN, "e": DATE_MS_MAX}, + expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "year"}}, + expected=Int64(584_554_049), + msg="$dateDiff should count years between the minimum and maximum representable dates", + ), +] + +DATEDIFF_TYPE_TESTS = ( + DATEDIFF_LEAP_YEAR_TESTS + + DATEDIFF_OBJECTID_TESTS + + DATEDIFF_TIMESTAMP_TESTS + + DATEDIFF_MIXED_SOURCE_TESTS + + DATEDIFF_EPOCH_TESTS + + DATEDIFF_EXTREME_BOUNDARY_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_TYPE_TESTS)) +def test_dateDiff_types(collection, test_case: ExpressionTestCase): + """Test $dateDiff date-source types and boundaries.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) From 3ea01eb350a8acaa4c1459594de0e461d0825939 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Thu, 9 Jul 2026 10:59:34 -0700 Subject: [PATCH 3/5] Add $dateSubtract expression tests This change adds tests for the $dateSubtract expression. It was originally authored by @mitchell-elholm. Closes #308 Co-authored-by: Mitchell Elholm Signed-off-by: Daniel Frankcom --- .../expressions/date/dateSubtract/__init__.py | 0 .../dateSubtract/test_dateSubtract_basic.py | 324 ++++++++++++ .../dateSubtract/test_dateSubtract_dates.py | 473 ++++++++++++++++++ .../test_dateSubtract_expressions.py | 146 ++++++ .../test_dateSubtract_timezone.py | 382 ++++++++++++++ .../dateSubtract/test_dateSubtract_types.py | 380 ++++++++++++++ 6 files changed, 1705 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_basic.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_dates.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_timezone.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_types.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_basic.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_basic.py new file mode 100644 index 000000000..7626c8765 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_basic.py @@ -0,0 +1,324 @@ +"""$dateSubtract basic operations, amount types, and boundaries.""" + +from datetime import datetime, timezone + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import DATEADD_INVALID_AMOUNT_ERROR +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEAR_MIN, + DOUBLE_NEGATIVE_ZERO, + INT32_MAX, + INT32_MIN, +) + +# Property [Unit Arithmetic]: subtracting a positive amount moves the start date back by that unit. +DATESUBTRACT_UNIT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "year_subtract", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=datetime(1999, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract 1 year", + ), + ExpressionTestCase( + "month_subtract", + doc={"date": datetime(2000, 3, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 2}}, + expected=datetime(2000, 1, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract 2 months", + ), + ExpressionTestCase( + "day_subtract", + doc={"date": datetime(2000, 1, 11, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 10}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract 10 days", + ), + ExpressionTestCase( + "hour_subtract", + doc={"date": datetime(2000, 1, 1, 17, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "hour", "amount": 5}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract 5 hours", + ), + ExpressionTestCase( + "minute_subtract", + doc={"date": datetime(2000, 1, 1, 12, 30, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "minute", "amount": 30}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract 30 minutes", + ), + ExpressionTestCase( + "second_subtract", + doc={"date": datetime(2000, 1, 1, 12, 0, 45, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "second", "amount": 45}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract 45 seconds", + ), + ExpressionTestCase( + "millisecond_subtract", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, 500000, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "millisecond", "amount": 500}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract 500 milliseconds", + ), + ExpressionTestCase( + "week_subtract", + doc={"date": datetime(2000, 1, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "week", "amount": 2}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract 2 weeks", + ), + ExpressionTestCase( + "quarter_subtract", + doc={"date": datetime(2000, 4, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "quarter", "amount": 1}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract 1 quarter", + ), +] + +# Property [Negative Amount]: a negative amount advances the start date forward by the given unit. +DATESUBTRACT_NEGATIVE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "year_add", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": -1}}, + expected=datetime(2001, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should add 1 year with a negative amount", + ), + ExpressionTestCase( + "month_add", + doc={"date": datetime(2000, 1, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": -2}}, + expected=datetime(2000, 3, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should add 2 months with a negative amount", + ), + ExpressionTestCase( + "day_add", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": -10}}, + expected=datetime(2000, 1, 11, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should add 10 days with a negative amount", + ), +] + +# Property [Zero Amount]: a zero amount for any unit returns the start date unchanged. +DATESUBTRACT_ZERO_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"zero_amount_{unit}", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": unit, "amount": 0}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg=f"$dateSubtract should return the start date unchanged for a zero {unit} amount", + ) + for unit in ( + "year", + "quarter", + "month", + "week", + "day", + "hour", + "minute", + "second", + "millisecond", + ) +] + +# Property [Amount Numeric Types]: integral int64, double, and decimal128 amounts, including +# negative zero, are accepted. +DATESUBTRACT_AMOUNT_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "amount_int64", + doc={"date": datetime(2000, 1, 6, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": Int64(5)}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should accept an int64 amount", + ), + ExpressionTestCase( + "amount_double_integral", + doc={"date": datetime(2000, 1, 6, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 5.0}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should accept an integral double amount", + ), + ExpressionTestCase( + "amount_decimal", + doc={"date": datetime(2000, 1, 6, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": {"startDate": "$date", "unit": "day", "amount": Decimal128("5")} + }, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should accept an integral decimal128 amount", + ), + ExpressionTestCase( + "amount_double_negative_zero", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": {"startDate": "$date", "unit": "day", "amount": DOUBLE_NEGATIVE_ZERO} + }, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should treat a negative-zero double as a zero amount", + ), + ExpressionTestCase( + "amount_decimal128_negative_zero", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "day", + "amount": DECIMAL128_NEGATIVE_ZERO, + } + }, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should treat a negative-zero decimal128 as a zero amount", + ), + ExpressionTestCase( + "amount_decimal128_max_precision_integral", + doc={"date": datetime(2000, 1, 4, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "day", + "amount": Decimal128("3.0000000000000000000000000000000000"), + } + }, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should accept a max-precision integral decimal128 amount", + ), +] + +# Property [Amount Boundary]: large valid int32/int64 amounts are accepted. +DATESUBTRACT_AMOUNT_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "amount_long_large", + doc={"date": datetime(2069, 9, 18, 11, 6, 40, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "second", + "amount": Int64(2_200_000_000), + } + }, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should accept a large int64 amount", + ), + ExpressionTestCase( + "amount_int32_max", + doc={"date": datetime(2000, 1, 26, 8, 31, 23, 647000, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": {"startDate": "$date", "unit": "millisecond", "amount": INT32_MAX} + }, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should accept INT32_MAX milliseconds", + ), + ExpressionTestCase( + "amount_int32_min", + doc={"date": datetime(1999, 12, 7, 15, 28, 36, 352000, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": {"startDate": "$date", "unit": "millisecond", "amount": INT32_MIN} + }, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should accept INT32_MIN milliseconds", + ), +] + +# Property [Amount Non-Integral]: a non-integral numeric amount is rejected. +DATESUBTRACT_AMOUNT_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "amount_non_integral_double_1_5", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1.5}}, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateSubtract should reject a non-integral double amount", + ), + ExpressionTestCase( + "amount_non_integral_double_5_9", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 5.9}}, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateSubtract should reject a non-integral double amount close to an integer", + ), + ExpressionTestCase( + "amount_non_integral_double_negative", + doc={"date": datetime(2000, 1, 10, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": -5.9}}, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateSubtract should reject a non-integral negative double amount", + ), + ExpressionTestCase( + "amount_non_integral_decimal128", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "day", + "amount": DECIMAL128_ONE_AND_HALF, + } + }, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateSubtract should reject a non-integral decimal128 amount", + ), + ExpressionTestCase( + "amount_decimal128_non_integral_34th_digit", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "day", + "amount": Decimal128("3.000000000000000000000000000000001"), + } + }, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateSubtract should reject a decimal128 amount non-integral at the 34th digit", + ), + ExpressionTestCase( + "amount_double_near_min", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": {"startDate": "$date", "unit": "day", "amount": DOUBLE_NEAR_MIN} + }, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateSubtract should reject a near-minimum double amount as non-integral", + ), + ExpressionTestCase( + "amount_double_min_subnormal", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": {"startDate": "$date", "unit": "day", "amount": DOUBLE_MIN_SUBNORMAL} + }, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateSubtract should reject a minimum subnormal double amount as non-integral", + ), +] + +DATESUBTRACT_BASIC_TESTS: list[ExpressionTestCase] = ( + DATESUBTRACT_UNIT_TESTS + + DATESUBTRACT_NEGATIVE_TESTS + + DATESUBTRACT_ZERO_TESTS + + DATESUBTRACT_AMOUNT_TYPE_TESTS + + DATESUBTRACT_AMOUNT_BOUNDARY_TESTS + + DATESUBTRACT_AMOUNT_ERROR_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATESUBTRACT_BASIC_TESTS)) +def test_dateSubtract_basic(collection, test_case: ExpressionTestCase): + """Test $dateSubtract basic operations.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_dates.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_dates.py new file mode 100644 index 000000000..a0df57026 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_dates.py @@ -0,0 +1,473 @@ +"""$dateSubtract date edge cases: leap years, month clamping, boundaries, and non-date starts.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( + oid_from_args, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import DATEADD_INVALID_LARGE_VALUE_ERROR +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_EPOCH, + DATE_MS_EPOCH, + DATE_Y2K, + DATE_YEAR_1, + DATE_YEAR_1900, + DATE_YEAR_9999, + OID_EPOCH, + OID_MAX_SIGNED32, + OID_MIN_SIGNED32, + TS_EPOCH, + TS_MAX_SIGNED32, + TS_MAX_UNSIGNED32, +) + +# Property [Leap Year]: subtracting across February resolves leap-year day counts correctly. +DATESUBTRACT_LEAP_YEAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "leap_year_feb28_sub_year", + doc={"date": datetime(2001, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=datetime(2000, 2, 28, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should land on Feb 28 when subtracting a year into a leap year", + ), + ExpressionTestCase( + "leap_year_feb29_sub_year", + doc={"date": datetime(2020, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=datetime(2019, 2, 28, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should clamp Feb 29 to Feb 28 subtracting a year into a non-leap year", + ), + ExpressionTestCase( + "leap_year_sub_day_to_feb29", + doc={"date": datetime(2000, 3, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should land on Feb 29 in a leap year", + ), + ExpressionTestCase( + "non_leap_year_mar1_sub_day", + doc={"date": datetime(1999, 3, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1999, 2, 28, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should land on Feb 28 in a non-leap year", + ), + ExpressionTestCase( + "leap_year_mar29_sub_month", + doc={"date": datetime(2020, 3, 29, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2020, 2, 29, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract a month from Mar 29 to Feb 29 in a leap year", + ), + ExpressionTestCase( + "leap_year_365_days", + doc={"date": datetime(2020, 12, 31, 15, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 365}}, + expected=datetime(2020, 1, 1, 15, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should not wrap the year when subtracting 365 days in a leap year", + ), + ExpressionTestCase( + "non_leap_year_365_days", + doc={"date": datetime(2020, 1, 1, 15, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 365}}, + expected=datetime(2019, 1, 1, 15, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should wrap to the prior year subtracting 365 days into a non-leap year", + ), +] + +# Property [Century Leap Year]: the divisible-by-100/400 leap rule is honored. +DATESUBTRACT_CENTURY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "century_non_leap_1900_mar1_sub_day", + doc={"date": datetime(1900, 3, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1900, 2, 28, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should land on Feb 28 in 1900, a non-leap century year", + ), + ExpressionTestCase( + "century_leap_2000_mar1_sub_day", + doc={"date": datetime(2000, 3, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should land on Feb 29 in 2000, a leap century year", + ), +] + +# Property [Month Clamping]: subtracting months or quarters clamps to the last valid day of the +# target month. +DATESUBTRACT_MONTH_CLAMP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "mar31_sub_month_leap", + doc={"date": datetime(2000, 3, 31, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should clamp Mar 31 minus 1 month to Feb 29 in a leap year", + ), + ExpressionTestCase( + "mar31_sub_month_non_leap", + doc={"date": datetime(2021, 3, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2021, 2, 28, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should clamp Mar 31 minus 1 month to Feb 28 in a non-leap year", + ), + ExpressionTestCase( + "may31_sub_month", + doc={"date": datetime(2000, 5, 31, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2000, 4, 30, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should clamp May 31 minus 1 month to Apr 30", + ), + ExpressionTestCase( + "jul31_sub_month", + doc={"date": datetime(2000, 7, 31, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2000, 6, 30, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should clamp Jul 31 minus 1 month to Jun 30", + ), + ExpressionTestCase( + "jan31_sub_month_no_adjustment", + doc={"date": datetime(2021, 1, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2020, 12, 31, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should not adjust Jan 31 minus 1 month, landing on Dec 31", + ), + ExpressionTestCase( + "dec31_sub_year_no_adjustment", + doc={"date": datetime(2021, 12, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=datetime(2020, 12, 31, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should not adjust Dec 31 when subtracting a year", + ), + ExpressionTestCase( + "apr30_sub_quarter", + doc={"date": datetime(2021, 4, 30, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "quarter", "amount": 1}}, + expected=datetime(2021, 1, 30, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract 1 quarter from Apr 30 to Jan 30", + ), + ExpressionTestCase( + "large_positive_month", + doc={"date": datetime(2020, 12, 31, 12, 10, 5, 10000, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 49}}, + expected=datetime(2016, 11, 30, 12, 10, 5, 10000, tzinfo=timezone.utc), + msg="$dateSubtract should clamp a large positive month amount to end-of-month", + ), + ExpressionTestCase( + "large_negative_month", + doc={"date": datetime(2020, 12, 31, 12, 10, 5, 10000, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": -50}}, + expected=datetime(2025, 2, 28, 12, 10, 5, 10000, tzinfo=timezone.utc), + msg="$dateSubtract should clamp a large negative month amount to end-of-month", + ), +] + +# Property [Boundary Crossing]: subtracting across day, month, and year boundaries carries +# correctly. +DATESUBTRACT_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dec31_sub_day", + doc={"date": datetime(2001, 1, 1, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(2000, 12, 31, 23, 59, 59, tzinfo=timezone.utc), + msg="$dateSubtract should cross the year boundary from Jan 1 minus 1 day", + ), + ExpressionTestCase( + "jan1_add_day", + doc={"date": DATE_Y2K}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": -1}}, + expected=datetime(2000, 1, 2, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should cross the day boundary from Jan 1 with a negative amount", + ), + ExpressionTestCase( + "dec31_sub_hour", + doc={"date": datetime(2001, 1, 1, 1, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "hour", "amount": 2}}, + expected=datetime(2000, 12, 31, 23, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should cross the year boundary from Jan 1 minus 2 hours", + ), +] + +# Property [Historical And Future]: distant past and future start dates are handled. +DATESUBTRACT_HISTORICAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "historical_date", + doc={"date": datetime(1910, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 10}}, + expected=datetime(1900, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should handle a 1910 historical date", + ), + ExpressionTestCase( + "pre_epoch_1960", + doc={"date": datetime(1960, 4, 10, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 100}}, + expected=datetime(1960, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should handle a pre-epoch 1960 date", + ), + ExpressionTestCase( + "far_future", + doc={"date": datetime(2200, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 100}}, + expected=datetime(2100, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should handle a far-future 2200 date", + ), + ExpressionTestCase( + "large_year_amount", + doc={"date": datetime(3000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 1000}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should handle subtracting 1000 years", + ), + ExpressionTestCase( + "distant_past", + doc={"date": datetime(1901, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=DATE_YEAR_1900, + msg="$dateSubtract should handle 1901 minus 1 year", + ), + ExpressionTestCase( + "distant_future_month", + doc={"date": datetime(2100, 7, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 6}}, + expected=datetime(2100, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should handle 2100 minus 6 months", + ), +] + +# Property [Epoch Crossing]: subtracting forward and backward across the Unix epoch is correct. +DATESUBTRACT_EPOCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "epoch_sub_day", + doc={"date": datetime(1970, 1, 2, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=DATE_EPOCH, + msg="$dateSubtract should subtract a day to reach the epoch", + ), + ExpressionTestCase( + "cross_epoch_back", + doc={"date": DATE_EPOCH}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1969, 12, 31, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should cross the epoch backward to 1969", + ), + ExpressionTestCase( + "pre_epoch_to_epoch", + doc={"date": datetime(1969, 12, 31, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": -1}}, + expected=DATE_EPOCH, + msg="$dateSubtract should cross the epoch forward from 1969 with a negative amount", + ), +] + +# Property [Date Limits]: subtracting near the minimum representable date is handled. +DATESUBTRACT_DATE_LIMIT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "near_min_date", + doc={"date": datetime(1, 1, 1, 0, 0, 1, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "millisecond", "amount": 1}}, + expected=datetime(1, 1, 1, 0, 0, 0, 999000, tzinfo=timezone.utc), + msg="$dateSubtract should subtract 1 millisecond near the minimum date", + ), + ExpressionTestCase( + "year_9999_sub_large_ms", + doc={"date": DATE_YEAR_9999}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "millisecond", + "amount": 253_402_300_799_999, + } + }, + expected=DATE_EPOCH, + msg="$dateSubtract should reach the epoch from the max date with a large ms amount", + ), + ExpressionTestCase( + "at_python_min_year", + doc={"date": datetime(1, 1, 1, 23, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "hour", "amount": 23}}, + expected=DATE_YEAR_1, + msg="$dateSubtract should subtract 23 hours at year 1", + ), +] + +# Property [Unit Equivalence]: a smaller unit times its multiple equals the larger unit. +DATESUBTRACT_UNIT_EQUIVALENCE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "millisecond_1000_equals_second_1", + doc={"date": datetime(2000, 1, 1, 12, 0, 1, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "millisecond", "amount": 1000}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract of 1000 milliseconds should equal subtracting 1 second", + ), + ExpressionTestCase( + "millisecond_precision_1ms", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, 1000, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "millisecond", "amount": 1}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should decrement by exactly 1 millisecond", + ), + ExpressionTestCase( + "day_7_equals_week", + doc={"date": datetime(2000, 6, 8, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 7}}, + expected=datetime(2000, 6, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract of 7 days should equal subtracting 1 week", + ), + ExpressionTestCase( + "month_12_equals_year", + doc={"date": datetime(2001, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 12}}, + expected=datetime(2000, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract of 12 months should equal subtracting 1 year", + ), + ExpressionTestCase( + "month_3_equals_quarter", + doc={"date": datetime(2000, 9, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 3}}, + expected=datetime(2000, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract of 3 months should equal subtracting 1 quarter", + ), +] + +# Property [Timestamp Start Date]: a Timestamp or DatetimeMS start date is accepted and returns +# a Date. +DATESUBTRACT_TIMESTAMP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "timestamp_startDate_day", + doc={"date": ts_from_args(2021, 1, 1, 12, 10, 5)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(2020, 12, 31, 12, 10, 5, tzinfo=timezone.utc), + msg="$dateSubtract should accept a Timestamp start date and return a Date", + ), + ExpressionTestCase( + "timestamp_startDate_second", + doc={"date": ts_from_args(2000, 1, 1, 12, 0, 1)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract a second from a Timestamp start date", + ), + ExpressionTestCase( + "timestamp_epoch", + doc={"date": TS_EPOCH}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "second", "amount": -1}}, + expected=datetime(1970, 1, 1, 0, 0, 1, tzinfo=timezone.utc), + msg="$dateSubtract should accept an epoch Timestamp start date", + ), + ExpressionTestCase( + "date_ms_epoch_sub_day", + doc={"date": DATE_MS_EPOCH}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1969, 12, 31, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract a day from an epoch DatetimeMS start date", + ), + ExpressionTestCase( + "ts_max_s32_sub_second", + doc={"date": TS_MAX_SIGNED32}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2038, 1, 19, 3, 14, 6, tzinfo=timezone.utc), + msg="$dateSubtract should subtract a second from a max signed 32-bit Timestamp", + ), + ExpressionTestCase( + "ts_max_u32_sub_second", + doc={"date": TS_MAX_UNSIGNED32}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2106, 2, 7, 6, 28, 14, tzinfo=timezone.utc), + msg="$dateSubtract should subtract a second from a max unsigned 32-bit Timestamp", + ), +] + +# Property [ObjectId Start Date]: an ObjectId start date uses its embedded timestamp and +# returns a Date. +DATESUBTRACT_OBJECTID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "objectid_startDate_year", + doc={"date": oid_from_args(2023, 7, 15, 22, 32, 25)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=datetime(2022, 7, 15, 22, 32, 25, tzinfo=timezone.utc), + msg="$dateSubtract should accept an ObjectId start date and return a Date", + ), + ExpressionTestCase( + "objectid_startDate_second", + doc={"date": oid_from_args(2022, 7, 15, 22, 32, 26)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2022, 7, 15, 22, 32, 25, tzinfo=timezone.utc), + msg="$dateSubtract should subtract a second from an ObjectId start date", + ), + ExpressionTestCase( + "objectid_startDate_millisecond", + doc={"date": oid_from_args(2022, 7, 15, 22, 32, 26)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "millisecond", "amount": 500}}, + expected=datetime(2022, 7, 15, 22, 32, 25, 500000, tzinfo=timezone.utc), + msg="$dateSubtract should subtract sub-second precision from an ObjectId start date", + ), + ExpressionTestCase( + "objectid_epoch", + doc={"date": OID_EPOCH}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": -1}}, + expected=datetime(1970, 1, 2, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should accept an epoch ObjectId start date", + ), + ExpressionTestCase( + "objectid_max_signed32", + doc={"date": OID_MAX_SIGNED32}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2038, 1, 19, 3, 14, 6, tzinfo=timezone.utc), + msg="$dateSubtract should subtract a second from a max signed 32-bit ObjectId", + ), + ExpressionTestCase( + "objectid_high_bit_pre_epoch", + doc={"date": OID_MIN_SIGNED32}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1901, 12, 12, 20, 45, 52, tzinfo=timezone.utc), + msg="$dateSubtract should handle an ObjectId with the high timestamp bit set", + ), +] + +DATESUBTRACT_DATE_SUCCESS_TESTS = ( + DATESUBTRACT_LEAP_YEAR_TESTS + + DATESUBTRACT_CENTURY_TESTS + + DATESUBTRACT_MONTH_CLAMP_TESTS + + DATESUBTRACT_BOUNDARY_TESTS + + DATESUBTRACT_HISTORICAL_TESTS + + DATESUBTRACT_EPOCH_TESTS + + DATESUBTRACT_DATE_LIMIT_TESTS + + DATESUBTRACT_UNIT_EQUIVALENCE_TESTS + + DATESUBTRACT_TIMESTAMP_TESTS + + DATESUBTRACT_OBJECTID_TESTS +) + +# Property [Overflow]: an amount that pushes the result beyond the representable date range +# is rejected. +DATESUBTRACT_OVERFLOW_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "large_positive_month_overflow", + doc={"date": datetime(2020, 12, 31, 12, 10, 5, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 30_000_000_000} + }, + error_code=DATEADD_INVALID_LARGE_VALUE_ERROR, + msg="$dateSubtract should reject a month amount that underflows the date range", + ), +] + +DATESUBTRACT_DATE_TESTS: list[ExpressionTestCase] = ( + DATESUBTRACT_DATE_SUCCESS_TESTS + DATESUBTRACT_OVERFLOW_ERROR_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATESUBTRACT_DATE_TESTS)) +def test_dateSubtract_dates(collection, test_case: ExpressionTestCase): + """Test $dateSubtract date edge cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_expressions.py new file mode 100644 index 000000000..798b8794c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_expressions.py @@ -0,0 +1,146 @@ +"""$dateSubtract expression inputs, array-resolving field paths, and Date return type.""" + +from datetime import datetime, timezone + +import pytest +from bson import ObjectId, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import DATEADD_INVALID_STARTDATE_ERROR +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Literal Input]: $dateSubtract evaluates literal operands. +DATESUBTRACT_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_year_subtract", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "year", + "amount": 1, + } + }, + expected=datetime(1999, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract 1 year from literal operands", + ), + ExpressionTestCase( + "literal_day_subtract", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 11, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": 10, + } + }, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract 10 days from literal operands", + ), + ExpressionTestCase( + "literal_null_startDate", + expression={"$dateSubtract": {"startDate": None, "unit": "day", "amount": 1}}, + expected=None, + msg="$dateSubtract should return null for a literal null startDate", + ), +] + +# Property [Field Reference Operands]: the amount and unit operands resolve from field references. +DATESUBTRACT_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "field_ref_amount", + doc={"amt": 5}, + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 6, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": "$amt", + } + }, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should resolve the amount from a field reference", + ), + ExpressionTestCase( + "field_ref_unit", + doc={"unit_field": "day"}, + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 6, 12, 0, 0, tzinfo=timezone.utc), + "unit": "$unit_field", + "amount": 5, + } + }, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should resolve the unit from a field reference", + ), +] + +# Property [Array-Resolving Path]: a startDate field path that resolves to an array is rejected +# by the operator's startDate type contract. +DATESUBTRACT_ARRAY_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "composite_array_path", + doc={ + "a": [ + {"b": datetime(2021, 6, 15, tzinfo=timezone.utc)}, + {"b": datetime(2021, 7, 1, tzinfo=timezone.utc)}, + ] + }, + expression={"$dateSubtract": {"startDate": "$a.b", "unit": "day", "amount": 1}}, + error_code=DATEADD_INVALID_STARTDATE_ERROR, + msg="$dateSubtract should reject a composite array field path as startDate", + ), + ExpressionTestCase( + "single_element_array_path", + doc={"a": [{"b": datetime(2021, 6, 15, tzinfo=timezone.utc)}]}, + expression={"$dateSubtract": {"startDate": "$a.b", "unit": "day", "amount": 1}}, + error_code=DATEADD_INVALID_STARTDATE_ERROR, + msg="$dateSubtract should reject a single-element array field path as startDate", + ), +] + +# Property [Return Type]: $dateSubtract returns a Date regardless of the start date's date-like +# type. +DATESUBTRACT_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type_from_date", + doc={"date": datetime(2021, 1, 1, tzinfo=timezone.utc)}, + expression={"$type": {"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}}, + expected="date", + msg="$dateSubtract should return a Date from a Date start date", + ), + ExpressionTestCase( + "return_type_from_timestamp", + doc={"date": Timestamp(1609459200, 1)}, + expression={"$type": {"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}}, + expected="date", + msg="$dateSubtract should return a Date from a Timestamp start date", + ), + ExpressionTestCase( + "return_type_from_objectid", + doc={"date": ObjectId("600000000000000000000000")}, + expression={"$type": {"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}}, + expected="date", + msg="$dateSubtract should return a Date from an ObjectId start date", + ), +] + +DATESUBTRACT_EXPRESSION_TESTS: list[ExpressionTestCase] = ( + DATESUBTRACT_LITERAL_TESTS + + DATESUBTRACT_FIELD_REF_TESTS + + DATESUBTRACT_ARRAY_PATH_TESTS + + DATESUBTRACT_RETURN_TYPE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATESUBTRACT_EXPRESSION_TESTS)) +def test_dateSubtract_expressions(collection, test_case: ExpressionTestCase): + """Test $dateSubtract expression inputs and return type.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_timezone.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_timezone.py new file mode 100644 index 000000000..5da11bd64 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_timezone.py @@ -0,0 +1,382 @@ +"""$dateSubtract timezone: Olson IDs, UTC offsets, DST, null, and invalid values and types.""" + +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.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + INVALID_TIMEZONE_ERROR, + INVALID_TIMEZONE_TYPE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Olson Timezone]: a valid Olson timezone id is accepted. +DATESUBTRACT_TIMEZONE_OLSON_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"timezone_{tid}", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": {"startDate": "$date", "unit": "hour", "amount": 5, "timezone": tz} + }, + expected=datetime(2000, 1, 1, 7, 0, 0, tzinfo=timezone.utc), + msg=f"$dateSubtract should accept the {tz} timezone", + ) + for tid, tz in [ + ("utc", "UTC"), + ("gmt", "GMT"), + ("america_ny", "America/New_York"), + ("europe_london", "Europe/London"), + ("asia_tokyo", "Asia/Tokyo"), + ("asia_kolkata", "Asia/Kolkata"), + ("pacific_apia", "Pacific/Apia"), + ] +] + +# Property [UTC Offset]: syntactically valid UTC offset strings are accepted, including +# numerically out-of-range but well-formed offsets. +DATESUBTRACT_TIMEZONE_OFFSET_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"timezone_offset_{tid}", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": {"startDate": "$date", "unit": "hour", "amount": 5, "timezone": tz} + }, + expected=datetime(2000, 1, 1, 7, 0, 0, tzinfo=timezone.utc), + msg=f"$dateSubtract should accept the {tz} UTC offset", + ) + for tid, tz in [ + ("colon_positive", "+05:30"), + ("no_colon", "-0530"), + ("hour_only", "+03"), + ("zero", "+00:00"), + ("max_east", "+14:00"), + ("max_west", "-11:00"), + ("half_hour_west", "-02:30"), + ("45min", "+05:45"), + ("over60_minutes_positive", "+05:70"), + ("over60_minutes_negative", "-05:70"), + ("over24_hours_positive", "+25:00"), + ("over24_hours_negative", "-25:00"), + ("max_valid_positive", "+99:99"), + ("max_valid_negative", "-99:99"), + ("out_of_range_east", "+15:00"), + ("out_of_range_west", "-13:00"), + ] +] + +# Property [DST Transition]: day and larger units track wall-clock time across DST, while +# 24-hour and sub-day units subtract absolute time and do not adjust. +DATESUBTRACT_TIMEZONE_DST_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dst_spring_forward_day", + doc={"date": datetime(2021, 3, 14, 14, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "day", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 3, 13, 15, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should DST-adjust a day across spring-forward", + ), + ExpressionTestCase( + "dst_spring_forward_hour_24", + doc={"date": datetime(2021, 3, 14, 15, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "hour", + "amount": 24, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 3, 13, 15, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should not DST-adjust 24 hours across spring-forward", + ), + ExpressionTestCase( + "dst_spring_forward_week", + doc={"date": datetime(2021, 3, 14, 9, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "week", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 3, 7, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should DST-adjust a week across spring-forward", + ), + ExpressionTestCase( + "dst_spring_forward_month", + doc={"date": datetime(2021, 3, 14, 9, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "month", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 2, 14, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should DST-adjust a month across spring-forward", + ), + ExpressionTestCase( + "dst_spring_forward_quarter", + doc={"date": datetime(2021, 4, 14, 9, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "quarter", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 1, 14, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should DST-adjust a quarter across spring-forward", + ), + ExpressionTestCase( + "dst_fall_back_day", + doc={"date": datetime(2021, 11, 7, 11, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "day", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 11, 6, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should DST-adjust a day across fall-back", + ), + ExpressionTestCase( + "dst_fall_back_hour_24", + doc={"date": datetime(2021, 11, 7, 10, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "hour", + "amount": 24, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 11, 6, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should not DST-adjust 24 hours across fall-back", + ), + ExpressionTestCase( + "dst_fall_back_week", + doc={"date": datetime(2021, 11, 7, 11, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "week", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 10, 31, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should DST-adjust a week across fall-back", + ), + ExpressionTestCase( + "dst_spring_minute_no_adjust", + doc={"date": datetime(2021, 3, 14, 7, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "minute", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 3, 14, 6, 59, 0, tzinfo=timezone.utc), + msg="$dateSubtract should not DST-adjust a minute", + ), + ExpressionTestCase( + "dst_spring_second_no_adjust", + doc={"date": datetime(2021, 3, 14, 7, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "second", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 3, 14, 6, 59, 59, tzinfo=timezone.utc), + msg="$dateSubtract should not DST-adjust a second", + ), + ExpressionTestCase( + "dst_spring_millisecond_no_adjust", + doc={"date": datetime(2021, 3, 14, 7, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "millisecond", + "amount": 1, + "timezone": "America/New_York", + } + }, + expected=datetime(2021, 3, 14, 6, 59, 59, 999000, tzinfo=timezone.utc), + msg="$dateSubtract should not DST-adjust a millisecond", + ), + ExpressionTestCase( + "dst_europe_paris_hour_no_adjust", + doc={"date": datetime(2020, 10, 25, 18, 10, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "hour", + "amount": 24, + "timezone": "Europe/Paris", + } + }, + expected=datetime(2020, 10, 24, 18, 10, 0, tzinfo=timezone.utc), + msg="$dateSubtract should not DST-adjust 24 hours in Europe/Paris", + ), + ExpressionTestCase( + "dst_europe_paris_day_adjust", + doc={"date": datetime(2020, 10, 25, 19, 10, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "day", + "amount": 1, + "timezone": "Europe/Paris", + } + }, + expected=datetime(2020, 10, 24, 18, 10, 0, tzinfo=timezone.utc), + msg="$dateSubtract should DST-adjust a day across Europe/Paris fall-back", + ), +] + +# Property [Timezone Field Reference]: the timezone operand resolves from a field, and a +# missing timezone field reference returns null. +DATESUBTRACT_TIMEZONE_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "timezone_field_ref", + doc={"tz": "Europe/Paris"}, + expression={ + "$dateSubtract": { + "startDate": datetime(2021, 2, 28, 12, 10, 5, tzinfo=timezone.utc), + "unit": "month", + "amount": 2, + "timezone": "$tz", + } + }, + expected=datetime(2020, 12, 28, 12, 10, 5, tzinfo=timezone.utc), + msg="$dateSubtract should resolve the timezone from a field reference", + ), + ExpressionTestCase( + "timezone_missing_field_ref", + doc={}, + expression={ + "$dateSubtract": { + "startDate": datetime(2021, 2, 28, 12, 10, 5, tzinfo=timezone.utc), + "unit": "month", + "amount": 2, + "timezone": "$tz", + } + }, + expected=None, + msg="$dateSubtract should return null for a missing timezone field reference", + ), +] + +# Property [Null Timezone]: a null timezone returns null. +DATESUBTRACT_TIMEZONE_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "timezone_null", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": {"startDate": "$date", "unit": "hour", "amount": 5, "timezone": None} + }, + expected=None, + msg="$dateSubtract should return null when the timezone is null", + ), +] + +DATESUBTRACT_TIMEZONE_SUCCESS_TESTS = ( + DATESUBTRACT_TIMEZONE_OLSON_TESTS + + DATESUBTRACT_TIMEZONE_OFFSET_TESTS + + DATESUBTRACT_TIMEZONE_DST_TESTS + + DATESUBTRACT_TIMEZONE_FIELD_REF_TESTS + + DATESUBTRACT_TIMEZONE_NULL_TESTS +) + +# Property [Invalid Timezone]: an unrecognized or malformed timezone string is rejected. +DATESUBTRACT_TIMEZONE_INVALID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"timezone_{tid}", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": {"startDate": "$date", "unit": "hour", "amount": 5, "timezone": tz} + }, + error_code=INVALID_TIMEZONE_ERROR, + msg=f"$dateSubtract should reject {desc}", + ) + for tid, tz, desc in [ + ("offset_3digit_hours_invalid", "+100:00", "a 3-digit hour offset"), + ("invalid", "Invalid/Timezone", "an unrecognized Olson timezone"), + ("empty_string", "", "an empty string timezone"), + ("olson_wrong_case_lowercase", "america/new_york", "an all-lowercase Olson name"), + ("olson_wrong_case_uppercase", "AMERICA/NEW_YORK", "an all-uppercase Olson name"), + ("olson_wrong_case_mixed", "america/New_York", "a mixed-case Olson name"), + ] +] + +# Property [Timezone Type]: a non-string timezone is rejected as an invalid type. +DATESUBTRACT_TIMEZONE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"timezone_{tid}", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": {"startDate": "$date", "unit": "hour", "amount": 5, "timezone": tz} + }, + error_code=INVALID_TIMEZONE_TYPE_ERROR, + msg=f"$dateSubtract should reject a {tid} timezone", + ) + for tid, tz in [ + ("int", 5), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", ["UTC"]), + ("object", {"tz": "UTC"}), + ("datetime", datetime(2000, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +DATESUBTRACT_TIMEZONE_ERROR_TESTS = ( + DATESUBTRACT_TIMEZONE_INVALID_TESTS + DATESUBTRACT_TIMEZONE_TYPE_ERROR_TESTS +) + +DATESUBTRACT_TIMEZONE_TESTS = ( + DATESUBTRACT_TIMEZONE_SUCCESS_TESTS + DATESUBTRACT_TIMEZONE_ERROR_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATESUBTRACT_TIMEZONE_TESTS)) +def test_dateSubtract_timezone(collection, test_case: ExpressionTestCase): + """Test $dateSubtract timezone handling.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_types.py new file mode 100644 index 000000000..0067d29af --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_types.py @@ -0,0 +1,380 @@ +"""$dateSubtract type validation: null/missing, invalid types, unit strings, argument shape.""" + +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.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + DATEADD_INVALID_AMOUNT_ERROR, + DATEADD_INVALID_STARTDATE_ERROR, + DATEADD_MISSING_FIELD_ERROR, + DATEADD_UNKNOWN_FIELD_ERROR, + FAILED_TO_PARSE_ERROR, + INVALID_DATE_UNIT_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + MISSING, +) + +# Property [Null Handling]: a null literal for startDate, amount, or unit returns null. +DATESUBTRACT_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_startDate", + doc={"date": None}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=None, + msg="$dateSubtract should return null for a null startDate", + ), + ExpressionTestCase( + "null_amount", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": None, + } + }, + expected=None, + msg="$dateSubtract should return null for a null amount", + ), + ExpressionTestCase( + "null_unit", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": None, + "amount": 1, + } + }, + expected=None, + msg="$dateSubtract should return null for a null unit", + ), + ExpressionTestCase( + "null_startDate_zero_amount", + doc={"date": None}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 0}}, + expected=None, + msg="$dateSubtract should return null for a null startDate even with a zero amount", + ), + ExpressionTestCase( + "all_null", + expression={"$dateSubtract": {"startDate": None, "unit": None, "amount": None}}, + expected=None, + msg="$dateSubtract should return null when all inputs are null", + ), +] + +# Property [Missing Field Reference]: a missing startDate, amount, unit, or timezone field +# reference returns null. +DATESUBTRACT_MISSING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_startDate", + expression={"$dateSubtract": {"startDate": MISSING, "unit": "day", "amount": 1}}, + expected=None, + msg="$dateSubtract should return null for a missing startDate field reference", + ), + ExpressionTestCase( + "missing_amount", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": MISSING, + } + }, + expected=None, + msg="$dateSubtract should return null for a missing amount field reference", + ), + ExpressionTestCase( + "missing_unit", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": MISSING, + "amount": 1, + } + }, + expected=None, + msg="$dateSubtract should return null for a missing unit field reference", + ), + ExpressionTestCase( + "missing_startDate_zero_amount", + expression={"$dateSubtract": {"startDate": MISSING, "unit": "day", "amount": 0}}, + expected=None, + msg="$dateSubtract should return null for a missing startDate reference and zero amount", + ), + ExpressionTestCase( + "missing_timezone", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": 1, + "timezone": MISSING, + } + }, + expected=None, + msg="$dateSubtract should return null for a missing timezone field reference", + ), + ExpressionTestCase( + "missing_startDate_with_tz", + expression={ + "$dateSubtract": {"startDate": MISSING, "unit": "day", "amount": 1, "timezone": "UTC"} + }, + expected=None, + msg="$dateSubtract should return null for a missing startDate even with a timezone", + ), +] + +DATESUBTRACT_TYPE_SUCCESS_TESTS = DATESUBTRACT_NULL_TESTS + DATESUBTRACT_MISSING_TESTS + +# Property [StartDate Type]: a non-date, non-Timestamp, non-ObjectId startDate is rejected. +DATESUBTRACT_STARTDATE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"startDate_{tid}", + doc={"date": val}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + error_code=DATEADD_INVALID_STARTDATE_ERROR, + msg=f"$dateSubtract should reject a {tid} startDate", + ) + for tid, val in [ + ("string", "2000-01-01"), + ("int", 123_456_789), + ("int64", Int64(123_456_789)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", [2000, 1, 1]), + ("empty_array", []), + ("single_date_array", [datetime(2021, 6, 15, tzinfo=timezone.utc)]), + ("object", {"year": 2000}), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Amount Type]: a non-numeric amount is rejected. +DATESUBTRACT_AMOUNT_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"amount_{tid}", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": val, + } + }, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg=f"$dateSubtract should reject a {tid} amount", + ) + for tid, val in [ + ("string", "5"), + ("boolean", True), + ("array", [5]), + ("object", {"value": 5}), + ("datetime", datetime(2000, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Amount Non-Finite]: a NaN or infinite numeric amount is rejected. +DATESUBTRACT_AMOUNT_NONFINITE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"amount_{tid}", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": val, + } + }, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg=f"$dateSubtract should reject a {tid} amount", + ) + for tid, val in [ + ("nan", FLOAT_NAN), + ("decimal128_nan", DECIMAL128_NAN), + ("infinity", FLOAT_INFINITY), + ("neg_infinity", FLOAT_NEGATIVE_INFINITY), + ("decimal128_infinity", DECIMAL128_INFINITY), + ("decimal128_neg_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + +# Property [Unit Type]: a non-string unit is rejected as an invalid date unit. +DATESUBTRACT_UNIT_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"unit_{tid}", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": val, + "amount": 5, + } + }, + error_code=INVALID_DATE_UNIT_ERROR, + msg=f"$dateSubtract should reject a {tid} unit", + ) + for tid, val in [ + ("number", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", ["day"]), + ("object", {"type": "day"}), + ("datetime", datetime(2000, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex("day")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Unit String]: an unrecognized unit string, including wrong case and plurals, is +# rejected at parse time. +DATESUBTRACT_UNIT_STRING_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"unit_{tid}", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": val, + "amount": 5, + } + }, + error_code=FAILED_TO_PARSE_ERROR, + msg=f"$dateSubtract should reject the {desc}", + ) + for tid, val, desc in [ + ("invalid_string", "invalid", "unrecognized unit string"), + ("empty_string", "", "empty string unit"), + ("mixed_case_Day", "Day", "mixed-case unit Day"), + ("mixed_case_Hour", "Hour", "mixed-case unit Hour"), + ("mixed_case_Month", "Month", "mixed-case unit Month"), + ("mixed_case_Quarter", "Quarter", "mixed-case unit Quarter"), + ("mixed_case_Week", "Week", "mixed-case unit Week"), + ("mixed_case_Second", "Second", "mixed-case unit Second"), + ("mixed_case_Minute", "Minute", "mixed-case unit Minute"), + ("mixed_case_Millisecond", "Millisecond", "mixed-case unit Millisecond"), + ("mixed_case_Year", "Year", "mixed-case unit Year"), + ("uppercase_DAY", "DAY", "uppercase unit DAY"), + ("uppercase_MILLISECOND", "MILLISECOND", "uppercase unit MILLISECOND"), + ("uppercase_YEAR", "YEAR", "uppercase unit YEAR"), + ("uppercase_HOUR", "HOUR", "uppercase unit HOUR"), + ("uppercase_MONTH", "MONTH", "uppercase unit MONTH"), + ("uppercase_QUARTER", "QUARTER", "uppercase unit QUARTER"), + ("uppercase_WEEK", "WEEK", "uppercase unit WEEK"), + ("uppercase_SECOND", "SECOND", "uppercase unit SECOND"), + ("uppercase_MINUTE", "MINUTE", "uppercase unit MINUTE"), + ("plural_years", "years", "plural unit years"), + ("plural_days", "days", "plural unit days"), + ("plural_months", "months", "plural unit months"), + ("plural_hours", "hours", "plural unit hours"), + ("plural_minutes", "minutes", "plural unit minutes"), + ("plural_seconds", "seconds", "plural unit seconds"), + ("plural_milliseconds", "milliseconds", "plural unit milliseconds"), + ("plural_weeks", "weeks", "plural unit weeks"), + ("plural_quarters", "quarters", "plural unit quarters"), + ("epoch_invalid", "epoch", "invalid unit epoch"), + ] +] + +DATESUBTRACT_TYPE_ERROR_TESTS = ( + DATESUBTRACT_STARTDATE_TYPE_ERROR_TESTS + + DATESUBTRACT_AMOUNT_TYPE_ERROR_TESTS + + DATESUBTRACT_AMOUNT_NONFINITE_ERROR_TESTS + + DATESUBTRACT_UNIT_TYPE_ERROR_TESTS + + DATESUBTRACT_UNIT_STRING_ERROR_TESTS +) + +# Property [Argument Shape]: a missing required field or an unknown field is rejected. +DATESUBTRACT_ARGUMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arg_missing_startDate", + expression={"$dateSubtract": {"unit": "day", "amount": 1}}, + error_code=DATEADD_MISSING_FIELD_ERROR, + msg="$dateSubtract should error when startDate is missing", + ), + ExpressionTestCase( + "arg_missing_unit", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "amount": 1, + } + }, + error_code=DATEADD_MISSING_FIELD_ERROR, + msg="$dateSubtract should error when unit is missing", + ), + ExpressionTestCase( + "arg_missing_amount", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + } + }, + error_code=DATEADD_MISSING_FIELD_ERROR, + msg="$dateSubtract should error when amount is missing", + ), + ExpressionTestCase( + "arg_empty_object", + expression={"$dateSubtract": {}}, + error_code=DATEADD_MISSING_FIELD_ERROR, + msg="$dateSubtract should error for an empty argument object", + ), + ExpressionTestCase( + "arg_unknown_field", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": 1, + "foo": 1, + } + }, + error_code=DATEADD_UNKNOWN_FIELD_ERROR, + msg="$dateSubtract should error for an unknown field", + ), +] + +DATESUBTRACT_TYPE_TESTS: list[ExpressionTestCase] = ( + DATESUBTRACT_TYPE_SUCCESS_TESTS + DATESUBTRACT_TYPE_ERROR_TESTS + DATESUBTRACT_ARGUMENT_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATESUBTRACT_TYPE_TESTS)) +def test_dateSubtract_types(collection, test_case: ExpressionTestCase): + """Test $dateSubtract type validation and argument handling.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) From 14e0394ba856a3339877b907468dfc99738bb634 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Thu, 9 Jul 2026 11:34:12 -0700 Subject: [PATCH 4/5] Add $dateTrunc expression tests This change adds tests for the $dateTrunc expression. It was originally authored by @mitchell-elholm. Closes #311 Co-authored-by: Mitchell Elholm Signed-off-by: Daniel Frankcom --- .../expressions/date/dateTrunc/__init__.py | 0 .../date/dateTrunc/test_dateTrunc_basic.py | 885 ++++++++++++++++++ .../date/dateTrunc/test_dateTrunc_dates.py | 272 ++++++ .../dateTrunc/test_dateTrunc_expressions.py | 171 ++++ .../date/dateTrunc/test_dateTrunc_types.py | 470 ++++++++++ 5 files changed, 1798 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_basic.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_dates.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_types.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_basic.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_basic.py new file mode 100644 index 000000000..d26756a70 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_basic.py @@ -0,0 +1,885 @@ +"""$dateTrunc argument handling, null/missing, and type/value validation of every parameter.""" + +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.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + DATETRUNC_BINSIZE_OVERFLOW_HOUR_ERROR, + DATETRUNC_BINSIZE_OVERFLOW_YEAR_ERROR, + DATETRUNC_INVALID_BINSIZE_ERROR, + DATETRUNC_INVALID_BINSIZE_VALUE_ERROR, + DATETRUNC_MISSING_DATE_ERROR, + DATETRUNC_MISSING_UNIT_ERROR, + DATETRUNC_NON_OBJECT_ERROR, + DATETRUNC_UNKNOWN_FIELD_ERROR, + FAILED_TO_PARSE_ERROR, + INVALID_DATE_UNIT_ERROR, + INVALID_DATE_VALUE_ERROR, + INVALID_STARTOFWEEK_ERROR, + INVALID_STARTOFWEEK_TYPE_ERROR, + INVALID_TIMEZONE_ERROR, + INVALID_TIMEZONE_TYPE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_HALF, + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + FLOAT_INFINITY, + FLOAT_NAN, + MISSING, +) + +# Property [Argument Handling]: $dateTrunc accepts the required date and unit plus the optional +# binSize, timezone, and startOfWeek parameters. +DATETRUNC_ARG_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arg_required_only", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour"}}, + expected=datetime(2021, 3, 20, 11, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate with the required fields only", + ), + ExpressionTestCase( + "arg_with_binSize", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "binSize": 2}}, + expected=datetime(2021, 3, 20, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a binSize", + ), + ExpressionTestCase( + "arg_with_timezone", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "UTC"}}, + expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a timezone", + ), + ExpressionTestCase( + "arg_with_startOfWeek", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "monday"}}, + expected=datetime(2021, 3, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a startOfWeek", + ), + ExpressionTestCase( + "arg_all_fields", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={ + "$dateTrunc": { + "date": "$date", + "unit": "week", + "binSize": 1, + "timezone": "UTC", + "startOfWeek": "monday", + } + }, + expected=datetime(2021, 3, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept all fields together", + ), +] + +# Property [BinSize Numeric Types]: integral int64, decimal128, and double binSize values are +# accepted. +DATETRUNC_BINSIZE_ACCEPT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "binSize_int64", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "binSize": Int64(2)}}, + expected=datetime(2021, 3, 20, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept an int64 binSize", + ), + ExpressionTestCase( + "binSize_decimal128", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "binSize": Decimal128("2")}}, + expected=datetime(2021, 3, 20, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a decimal128 binSize", + ), + ExpressionTestCase( + "binSize_double_integral", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "binSize": 2.0}}, + expected=datetime(2021, 3, 20, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept an integral double binSize", + ), +] + +# Property [Timezone Extremes]: out-of-range two-digit offsets and additional named zones are +# accepted. +DATETRUNC_TIMEZONE_ACCEPT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_over60_minutes_positive", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "+05:70"}}, + expected=datetime(2021, 6, 15, 11, 50, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a +05:70 offset with over-60 minutes", + ), + ExpressionTestCase( + "tz_over60_minutes_negative", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "-05:70"}}, + expected=datetime(2021, 6, 15, 11, 10, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a -05:70 offset with over-60 minutes", + ), + ExpressionTestCase( + "tz_over24_hours_positive", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "+25:00"}}, + expected=datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a +25:00 offset with over-24 hours", + ), + ExpressionTestCase( + "tz_over24_hours_negative", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "-25:00"}}, + expected=datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a -25:00 offset with over-24 hours", + ), + ExpressionTestCase( + "tz_max_valid_positive", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "+99:99"}}, + expected=datetime(2021, 6, 15, 11, 21, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept the maximum two-digit +99:99 offset", + ), + ExpressionTestCase( + "tz_max_valid_negative", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "-99:99"}}, + expected=datetime(2021, 6, 15, 11, 39, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept the maximum two-digit -99:99 offset", + ), + ExpressionTestCase( + "tz_pacific_apia", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "Pacific/Apia"}}, + expected=datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept the Pacific/Apia named zone", + ), + ExpressionTestCase( + "tz_offset_45min", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "+05:45"}}, + expected=datetime(2021, 6, 15, 11, 15, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a +05:45 offset with 45 minutes", + ), + ExpressionTestCase( + "tz_offset_half_hour_west", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "-02:30"}}, + expected=datetime(2021, 6, 15, 11, 30, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a -02:30 half-hour offset", + ), + ExpressionTestCase( + "tz_offset_max_east", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "+14:00"}}, + expected=datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept the +14:00 maximum east offset", + ), + ExpressionTestCase( + "tz_offset_max_west", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "-11:00"}}, + expected=datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept the -11:00 maximum west offset", + ), +] + +# Property [StartOfWeek Case]: startOfWeek accepts full day names and three-letter abbreviations +# in any case. +DATETRUNC_STARTOFWEEK_ACCEPT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "sow_mixed_case_Monday", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "Monday"}}, + expected=datetime(2021, 6, 14, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a mixed-case Monday", + ), + ExpressionTestCase( + "sow_uppercase_MONDAY", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "MONDAY"}}, + expected=datetime(2021, 6, 14, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept an uppercase MONDAY", + ), + ExpressionTestCase( + "sow_abbrev_MON", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "MON"}}, + expected=datetime(2021, 6, 14, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept an uppercase abbreviation MON", + ), + ExpressionTestCase( + "sow_mixed_case_Friday", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "Friday"}}, + expected=datetime(2021, 6, 11, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a mixed-case Friday", + ), + ExpressionTestCase( + "sow_uppercase_FRIDAY", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "FRIDAY"}}, + expected=datetime(2021, 6, 11, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept an uppercase FRIDAY", + ), + ExpressionTestCase( + "sow_abbrev_FRI", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "FRI"}}, + expected=datetime(2021, 6, 11, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept an uppercase abbreviation FRI", + ), + ExpressionTestCase( + "sow_uppercase_SUNDAY", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "SUNDAY"}}, + expected=datetime(2021, 6, 13, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept an uppercase SUNDAY", + ), + ExpressionTestCase( + "sow_abbrev_SUN", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "SUN"}}, + expected=datetime(2021, 6, 13, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept an uppercase abbreviation SUN", + ), +] + +# Property [StartOfWeek Relevance]: for a non-week unit a null startOfWeek is ignored when the date +# is a constant literal, but short-circuits the result to null when the date is a field reference. +DATETRUNC_STARTOFWEEK_RELEVANCE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "sow_relevance_literal_day", + expression={ + "$dateTrunc": { + "date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc), + "unit": "day", + "startOfWeek": None, + } + }, + expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should ignore a null startOfWeek for a non-week unit when the date is a " + "constant", + ), + ExpressionTestCase( + "sow_relevance_field_ref_day", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "startOfWeek": None}}, + expected=None, + msg="$dateTrunc should return null for a null startOfWeek on a non-week unit when the date " + "is a field reference", + ), +] + +# Property [Null Handling]: a null date, unit, binSize, timezone, or week startOfWeek returns null. +DATETRUNC_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_date", + doc={"date": None}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=None, + msg="$dateTrunc should return null for a null date", + ), + ExpressionTestCase( + "null_unit", + expression={ + "$dateTrunc": {"date": datetime(2021, 1, 1, tzinfo=timezone.utc), "unit": None} + }, + expected=None, + msg="$dateTrunc should return null for a null unit", + ), + ExpressionTestCase( + "null_binSize", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": None, + } + }, + expected=None, + msg="$dateTrunc should return null for a null binSize", + ), + ExpressionTestCase( + "null_timezone", + expression={ + "$dateTrunc": { + "date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "timezone": None, + } + }, + expected=None, + msg="$dateTrunc should return null for a null timezone", + ), + ExpressionTestCase( + "null_startOfWeek_week", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": None, + } + }, + expected=None, + msg="$dateTrunc should return null for a null startOfWeek with the week unit", + ), +] + +# Property [Missing Field Reference]: a missing field reference for date or any optional parameter +# returns null. +DATETRUNC_MISSING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_date_ref", + expression={"$dateTrunc": {"date": MISSING, "unit": "day"}}, + expected=None, + msg="$dateTrunc should return null for a missing date field reference", + ), + ExpressionTestCase( + "missing_binSize_ref", + expression={ + "$dateTrunc": { + "date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc), + "unit": "hour", + "binSize": MISSING, + } + }, + expected=None, + msg="$dateTrunc should return null for a missing binSize field reference", + ), + ExpressionTestCase( + "missing_timezone_ref", + expression={ + "$dateTrunc": { + "date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc), + "unit": "day", + "timezone": MISSING, + } + }, + expected=None, + msg="$dateTrunc should return null for a missing timezone field reference", + ), + ExpressionTestCase( + "missing_startOfWeek_ref", + expression={ + "$dateTrunc": { + "date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": MISSING, + } + }, + expected=None, + msg="$dateTrunc should return null for a missing startOfWeek field reference", + ), +] + +DATETRUNC_SUCCESS_TESTS: list[ExpressionTestCase] = ( + DATETRUNC_ARG_TESTS + + DATETRUNC_BINSIZE_ACCEPT_TESTS + + DATETRUNC_TIMEZONE_ACCEPT_TESTS + + DATETRUNC_STARTOFWEEK_ACCEPT_TESTS + + DATETRUNC_STARTOFWEEK_RELEVANCE_TESTS + + DATETRUNC_NULL_TESTS + + DATETRUNC_MISSING_TESTS +) + +# Property [Argument Shape]: a missing required field, an empty object, an unknown field, or a +# non-object argument is rejected. +DATETRUNC_ARG_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arg_missing_date", + expression={"$dateTrunc": {"unit": "day"}}, + error_code=DATETRUNC_MISSING_DATE_ERROR, + msg="$dateTrunc should error when date is missing", + ), + ExpressionTestCase( + "arg_missing_unit", + expression={"$dateTrunc": {"date": datetime(2021, 1, 1, tzinfo=timezone.utc)}}, + error_code=DATETRUNC_MISSING_UNIT_ERROR, + msg="$dateTrunc should error when unit is missing", + ), + ExpressionTestCase( + "arg_empty_object", + expression={"$dateTrunc": {}}, + error_code=DATETRUNC_MISSING_DATE_ERROR, + msg="$dateTrunc should error for an empty argument object", + ), + ExpressionTestCase( + "arg_unknown_field", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "foo": 1, + } + }, + error_code=DATETRUNC_UNKNOWN_FIELD_ERROR, + msg="$dateTrunc should error for an unknown field", + ), + ExpressionTestCase( + "arg_non_object_string", + expression={"$dateTrunc": "string"}, + error_code=DATETRUNC_NON_OBJECT_ERROR, + msg="$dateTrunc should reject a string argument", + ), + ExpressionTestCase( + "arg_non_object_array", + expression={"$dateTrunc": [1, 2]}, + error_code=DATETRUNC_NON_OBJECT_ERROR, + msg="$dateTrunc should reject an array argument", + ), + ExpressionTestCase( + "arg_non_object_number", + expression={"$dateTrunc": 123}, + error_code=DATETRUNC_NON_OBJECT_ERROR, + msg="$dateTrunc should reject a numeric argument", + ), +] + +# Property [Date Type]: a non-date, non-ObjectId, non-Timestamp date value is rejected. +DATETRUNC_DATE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + *[ + ExpressionTestCase( + f"date_{tid}", + doc={"date": val}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + error_code=INVALID_DATE_VALUE_ERROR, + msg=f"$dateTrunc should reject a {tid} date", + ) + for tid, val in [ + ("string", "2021"), + ("int", 123), + ("int64", Int64(123)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", [1, 2]), + ("empty_array", []), + ("object", {"a": 1}), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + ExpressionTestCase( + "date_boolean_false", + doc={"date": False}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + error_code=INVALID_DATE_VALUE_ERROR, + msg="$dateTrunc should reject a false boolean date", + ), + ExpressionTestCase( + "date_single_date_array", + doc={"date": [datetime(2021, 6, 15, tzinfo=timezone.utc)]}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + error_code=INVALID_DATE_VALUE_ERROR, + msg="$dateTrunc should reject a single-element array containing a date", + ), + ExpressionTestCase( + "date_decimal128_infinity", + doc={"date": DECIMAL128_INFINITY}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + error_code=INVALID_DATE_VALUE_ERROR, + msg="$dateTrunc should reject a Decimal128 Infinity date", + ), + ExpressionTestCase( + "date_decimal128_neg_infinity", + doc={"date": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + error_code=INVALID_DATE_VALUE_ERROR, + msg="$dateTrunc should reject a Decimal128 -Infinity date", + ), +] + +# Property [Unit Type]: a non-string unit is rejected as an invalid date unit. +DATETRUNC_UNIT_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"unit_{tid}", + expression={"$dateTrunc": {"date": datetime(2021, 1, 1, tzinfo=timezone.utc), "unit": val}}, + error_code=INVALID_DATE_UNIT_ERROR, + msg=f"$dateTrunc should reject a {tid} unit", + ) + for tid, val in [ + ("int", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", ["day"]), + ("empty_array", []), + ("object", {"t": "day"}), + ("datetime", datetime(2021, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex("day")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Unit String]: an unrecognized unit string, including wrong case and plurals, is +# rejected at parse time. +DATETRUNC_UNIT_STRING_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"unit_{tid}", + expression={"$dateTrunc": {"date": datetime(2021, 1, 1, tzinfo=timezone.utc), "unit": val}}, + error_code=FAILED_TO_PARSE_ERROR, + msg=f"$dateTrunc should reject the {desc}", + ) + for tid, val, desc in [ + ("invalid_string", "invalid", "unrecognized unit string"), + ("empty_string", "", "empty string unit"), + ("mixed_case_Year", "Year", "mixed-case unit Year"), + ("mixed_case_Day", "Day", "mixed-case unit Day"), + ("mixed_case_Hour", "Hour", "mixed-case unit Hour"), + ("mixed_case_Month", "Month", "mixed-case unit Month"), + ("mixed_case_Quarter", "Quarter", "mixed-case unit Quarter"), + ("mixed_case_Week", "Week", "mixed-case unit Week"), + ("mixed_case_Second", "Second", "mixed-case unit Second"), + ("mixed_case_Minute", "Minute", "mixed-case unit Minute"), + ("mixed_case_Millisecond", "Millisecond", "mixed-case unit Millisecond"), + ("uppercase_YEAR", "YEAR", "uppercase unit YEAR"), + ("uppercase_DAY", "DAY", "uppercase unit DAY"), + ("uppercase_HOUR", "HOUR", "uppercase unit HOUR"), + ("uppercase_MONTH", "MONTH", "uppercase unit MONTH"), + ("uppercase_MILLISECOND", "MILLISECOND", "uppercase unit MILLISECOND"), + ("uppercase_QUARTER", "QUARTER", "uppercase unit QUARTER"), + ("uppercase_WEEK", "WEEK", "uppercase unit WEEK"), + ("uppercase_SECOND", "SECOND", "uppercase unit SECOND"), + ("uppercase_MINUTE", "MINUTE", "uppercase unit MINUTE"), + ("plural_years", "years", "plural unit years"), + ("plural_months", "months", "plural unit months"), + ("plural_days", "days", "plural unit days"), + ("plural_hours", "hours", "plural unit hours"), + ("plural_minutes", "minutes", "plural unit minutes"), + ("plural_seconds", "seconds", "plural unit seconds"), + ("plural_milliseconds", "milliseconds", "plural unit milliseconds"), + ("plural_weeks", "weeks", "plural unit weeks"), + ("plural_quarters", "quarters", "plural unit quarters"), + ] +] + +# Property [BinSize Type]: a non-numeric binSize is rejected. +DATETRUNC_BINSIZE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"binSize_{tid}", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": val, + } + }, + error_code=DATETRUNC_INVALID_BINSIZE_ERROR, + msg=f"$dateTrunc should reject a {tid} binSize", + ) + for tid, val in [ + ("string", "2"), + ("boolean", True), + ("array", [1]), + ("empty_array", []), + ("object", {"a": 1}), + ("datetime", datetime(2021, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [BinSize Non-Integral]: a fractional or non-finite numeric binSize is rejected. +DATETRUNC_BINSIZE_NONINTEGRAL_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "binSize_fractional", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": 0.5, + } + }, + error_code=DATETRUNC_INVALID_BINSIZE_ERROR, + msg="$dateTrunc should reject a fractional binSize", + ), + ExpressionTestCase( + "binSize_nan", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": FLOAT_NAN, + } + }, + error_code=DATETRUNC_INVALID_BINSIZE_ERROR, + msg="$dateTrunc should reject a NaN binSize", + ), + ExpressionTestCase( + "binSize_infinity", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": FLOAT_INFINITY, + } + }, + error_code=DATETRUNC_INVALID_BINSIZE_ERROR, + msg="$dateTrunc should reject an infinite binSize", + ), + ExpressionTestCase( + "binSize_decimal128_fractional", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": DECIMAL128_HALF, + } + }, + error_code=DATETRUNC_INVALID_BINSIZE_ERROR, + msg="$dateTrunc should reject a fractional decimal128 binSize", + ), + ExpressionTestCase( + "binSize_decimal128_nan", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": DECIMAL128_NAN, + } + }, + error_code=DATETRUNC_INVALID_BINSIZE_ERROR, + msg="$dateTrunc should reject a NaN decimal128 binSize", + ), + ExpressionTestCase( + "binSize_decimal128_infinity", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": DECIMAL128_INFINITY, + } + }, + error_code=DATETRUNC_INVALID_BINSIZE_ERROR, + msg="$dateTrunc should reject an infinite decimal128 binSize", + ), +] + +# Property [BinSize Value]: a zero or negative binSize is rejected as an invalid value. +DATETRUNC_BINSIZE_VALUE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "binSize_zero", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": 0, + } + }, + error_code=DATETRUNC_INVALID_BINSIZE_VALUE_ERROR, + msg="$dateTrunc should reject a zero binSize", + ), + ExpressionTestCase( + "binSize_negative", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": -1, + } + }, + error_code=DATETRUNC_INVALID_BINSIZE_VALUE_ERROR, + msg="$dateTrunc should reject a negative binSize", + ), +] + +# Property [BinSize Overflow]: a binSize that overflows the date range for its unit is rejected. +DATETRUNC_BINSIZE_OVERFLOW_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "binSize_overflow_year", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "year", + "binSize": Int64(100_000_000_001), + } + }, + error_code=DATETRUNC_BINSIZE_OVERFLOW_YEAR_ERROR, + msg="$dateTrunc should reject a binSize that overflows the year unit", + ), + ExpressionTestCase( + "binSize_overflow_hour", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "hour", + "binSize": Int64(2_562_047_788_016_999), + } + }, + error_code=DATETRUNC_BINSIZE_OVERFLOW_HOUR_ERROR, + msg="$dateTrunc should reject a binSize that overflows the hour unit", + ), +] + +# Property [Timezone Type]: a non-string timezone is rejected. +DATETRUNC_TIMEZONE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"tz_{tid}", + expression={ + "$dateTrunc": { + "date": datetime(2021, 6, 15, tzinfo=timezone.utc), + "unit": "day", + "timezone": val, + } + }, + error_code=INVALID_TIMEZONE_TYPE_ERROR, + msg=f"$dateTrunc should reject a {tid} timezone", + ) + for tid, val in [ + ("int", 5), + ("int64", Int64(5)), + ("double", 5.0), + ("decimal128", Decimal128("5")), + ("boolean", True), + ("array", ["UTC"]), + ("empty_array", []), + ("object", {"tz": "UTC"}), + ("datetime", datetime(2021, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex("UTC")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Timezone String]: an unrecognized timezone string, including wrong-case Olson names and +# out-of-range offsets, is rejected. +DATETRUNC_TIMEZONE_STRING_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"tz_{tid}", + expression={ + "$dateTrunc": { + "date": datetime(2021, 6, 15, tzinfo=timezone.utc), + "unit": "day", + "timezone": val, + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg=f"$dateTrunc should reject the {desc}", + ) + for tid, val, desc in [ + ("invalid_string", "NotATimezone", "unrecognized timezone string"), + ("empty_string", "", "empty timezone string"), + ("olson_lowercase", "america/new_york", "all-lowercase Olson name"), + ("olson_uppercase", "AMERICA/NEW_YORK", "all-uppercase Olson name"), + ("offset_3digit_hours", "+100:00", "three-digit hour offset"), + ] +] + +# Property [StartOfWeek Type]: a non-string startOfWeek is rejected. +DATETRUNC_STARTOFWEEK_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"sow_{tid}", + expression={ + "$dateTrunc": { + "date": datetime(2021, 3, 20, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": val, + } + }, + error_code=INVALID_STARTOFWEEK_TYPE_ERROR, + msg=f"$dateTrunc should reject a {tid} startOfWeek", + ) + for tid, val in [ + ("int", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", ["monday"]), + ("empty_array", []), + ("object", {"day": "monday"}), + ("datetime", datetime(2021, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex("monday")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [StartOfWeek String]: an unrecognized startOfWeek string is rejected. +DATETRUNC_STARTOFWEEK_STRING_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "sow_invalid_string", + expression={ + "$dateTrunc": { + "date": datetime(2021, 3, 20, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": "notaday", + } + }, + error_code=INVALID_STARTOFWEEK_ERROR, + msg="$dateTrunc should reject an unrecognized startOfWeek string", + ), + ExpressionTestCase( + "sow_empty_string", + expression={ + "$dateTrunc": { + "date": datetime(2021, 3, 20, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": "", + } + }, + error_code=INVALID_STARTOFWEEK_ERROR, + msg="$dateTrunc should reject an empty startOfWeek string", + ), +] + +DATETRUNC_ERROR_TESTS: list[ExpressionTestCase] = ( + DATETRUNC_ARG_ERROR_TESTS + + DATETRUNC_DATE_TYPE_ERROR_TESTS + + DATETRUNC_UNIT_TYPE_ERROR_TESTS + + DATETRUNC_UNIT_STRING_ERROR_TESTS + + DATETRUNC_BINSIZE_TYPE_ERROR_TESTS + + DATETRUNC_BINSIZE_NONINTEGRAL_ERROR_TESTS + + DATETRUNC_BINSIZE_VALUE_ERROR_TESTS + + DATETRUNC_BINSIZE_OVERFLOW_TESTS + + DATETRUNC_TIMEZONE_TYPE_ERROR_TESTS + + DATETRUNC_TIMEZONE_STRING_ERROR_TESTS + + DATETRUNC_STARTOFWEEK_TYPE_ERROR_TESTS + + DATETRUNC_STARTOFWEEK_STRING_ERROR_TESTS +) + +DATETRUNC_BASIC_TESTS: list[ExpressionTestCase] = DATETRUNC_SUCCESS_TESTS + DATETRUNC_ERROR_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(DATETRUNC_BASIC_TESTS)) +def test_dateTrunc_basic(collection, test_case: ExpressionTestCase): + """Test $dateTrunc argument handling and validation.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_dates.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_dates.py new file mode 100644 index 000000000..59668096e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_dates.py @@ -0,0 +1,272 @@ +"""$dateTrunc truncation results by unit, binSize multiples, bin alignment, and week start.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DATE_Y2K + +# Property [Unit Truncation]: truncating to a unit returns the start of the period containing +# the date. +DATETRUNC_UNIT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "unit_year", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, + expected=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate to the start of the year", + ), + ExpressionTestCase( + "unit_quarter_q1", + doc={"date": datetime(2021, 2, 15, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "quarter"}}, + expected=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a Q1 date to the Q1 start", + ), + ExpressionTestCase( + "unit_quarter_q2", + doc={"date": datetime(2021, 5, 15, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "quarter"}}, + expected=datetime(2021, 4, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a Q2 date to the Q2 start", + ), + ExpressionTestCase( + "unit_quarter_q3", + doc={"date": datetime(2021, 8, 15, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "quarter"}}, + expected=datetime(2021, 7, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a Q3 date to the Q3 start", + ), + ExpressionTestCase( + "unit_quarter_q4", + doc={"date": datetime(2021, 11, 15, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "quarter"}}, + expected=datetime(2021, 10, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a Q4 date to the Q4 start", + ), + ExpressionTestCase( + "unit_month", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, + expected=datetime(2021, 3, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate to the start of the month", + ), + ExpressionTestCase( + "unit_day", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate to the start of the day", + ), + ExpressionTestCase( + "unit_hour", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour"}}, + expected=datetime(2021, 3, 20, 11, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate to the start of the hour", + ), + ExpressionTestCase( + "unit_minute", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "minute"}}, + expected=datetime(2021, 3, 20, 11, 30, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate to the start of the minute", + ), + ExpressionTestCase( + "unit_second", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, 500000, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "second"}}, + expected=datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc), + msg="$dateTrunc should truncate to the start of the second", + ), + ExpressionTestCase( + "unit_millisecond", + doc={"date": datetime(2021, 1, 1, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "millisecond"}}, + expected=datetime(2021, 1, 1, tzinfo=timezone.utc), + msg="$dateTrunc should truncate to the millisecond", + ), +] + +# Property [BinSize Multiple]: a binSize greater than one truncates to multiples of the unit. +DATETRUNC_BINSIZE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "bin_2hour", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "binSize": 2}}, + expected=datetime(2021, 3, 20, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate to a 2-hour bin", + ), + ExpressionTestCase( + "bin_10year", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year", "binSize": 10}}, + expected=datetime(2020, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate to a 10-year bin", + ), + ExpressionTestCase( + "bin_6month", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month", "binSize": 6}}, + expected=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate to a 6-month bin", + ), + ExpressionTestCase( + "bin_15min", + doc={"date": datetime(2021, 3, 20, 11, 37, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "minute", "binSize": 15}}, + expected=datetime(2021, 3, 20, 11, 30, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate to a 15-minute bin", + ), +] + +# Property [Bin Alignment]: bins are aligned relative to the 2000-01-01 reference date, extending +# backward before it. +DATETRUNC_BIN_ALIGNMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "bin_5year_at_ref", + doc={"date": DATE_Y2K}, + expression={"$dateTrunc": {"date": "$date", "unit": "year", "binSize": 5}}, + expected=DATE_Y2K, + msg="$dateTrunc should align a 5-year bin to the reference date", + ), + ExpressionTestCase( + "bin_5year_before_ref", + doc={"date": datetime(1999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year", "binSize": 5}}, + expected=datetime(1995, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should extend 5-year bins backward before the reference date", + ), + ExpressionTestCase( + "bin_5year_next_bin", + doc={"date": datetime(2005, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year", "binSize": 5}}, + expected=datetime(2005, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should align to the next 5-year bin", + ), + ExpressionTestCase( + "bin_3month_align", + doc={"date": datetime(2000, 2, 15, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month", "binSize": 3}}, + expected=DATE_Y2K, + msg="$dateTrunc should align a 3-month bin from the reference date", + ), + ExpressionTestCase( + "bin_3month_next", + doc={"date": datetime(2000, 4, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month", "binSize": 3}}, + expected=datetime(2000, 4, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should align to the next 3-month bin", + ), +] + +# Property [Week Truncation]: the week unit truncates to the configured start of week, defaulting +# to Sunday. +DATETRUNC_WEEK_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "week_default_sun", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week"}}, + expected=datetime(2021, 3, 14, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should default the week start to Sunday", + ), + ExpressionTestCase( + "week_monday", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "monday"}}, + expected=datetime(2021, 3, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the week to Monday", + ), + ExpressionTestCase( + "week_friday", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "friday"}}, + expected=datetime(2021, 3, 19, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the week to Friday", + ), + ExpressionTestCase( + "week_sunday", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "sunday"}}, + expected=datetime(2021, 6, 13, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the week to Sunday", + ), + ExpressionTestCase( + "week_tuesday", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "tuesday"}}, + expected=datetime(2021, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the week to Tuesday", + ), + ExpressionTestCase( + "week_wednesday", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "wednesday"}}, + expected=datetime(2021, 6, 16, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the week to Wednesday", + ), + ExpressionTestCase( + "week_thursday", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "thursday"}}, + expected=datetime(2021, 6, 10, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the week to Thursday", + ), + ExpressionTestCase( + "week_saturday", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "saturday"}}, + expected=datetime(2021, 6, 12, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the week to Saturday", + ), + ExpressionTestCase( + "week_mon_abbrev", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "mon"}}, + expected=datetime(2021, 6, 14, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept the mon abbreviation for startOfWeek", + ), + ExpressionTestCase( + "week_monday_mixed_case", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "Monday"}}, + expected=datetime(2021, 6, 14, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a mixed-case Monday for startOfWeek", + ), +] + +# Property [StartOfWeek Ignored]: startOfWeek has no effect for a non-week unit. +DATETRUNC_SOW_IGNORED_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "sow_ignored_month", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month", "startOfWeek": "friday"}}, + expected=datetime(2021, 3, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should ignore startOfWeek for the month unit", + ), +] + +DATETRUNC_DATE_TESTS: list[ExpressionTestCase] = ( + DATETRUNC_UNIT_TESTS + + DATETRUNC_BINSIZE_TESTS + + DATETRUNC_BIN_ALIGNMENT_TESTS + + DATETRUNC_WEEK_TESTS + + DATETRUNC_SOW_IGNORED_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETRUNC_DATE_TESTS)) +def test_dateTrunc_dates(collection, test_case: ExpressionTestCase): + """Test $dateTrunc truncation results.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_expressions.py new file mode 100644 index 000000000..5c879e7d5 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_expressions.py @@ -0,0 +1,171 @@ +"""$dateTrunc literal and field-reference inputs, array-path rejection, and return type.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( + oid_from_args, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import INVALID_DATE_VALUE_ERROR +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Literal Input]: $dateTrunc evaluates inline literal arguments. +DATETRUNC_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_basic", + expression={ + "$dateTrunc": { + "date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc), + "unit": "hour", + } + }, + expected=datetime(2021, 3, 20, 11, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate an inline literal date", + ), + ExpressionTestCase( + "literal_null", + expression={"$dateTrunc": {"date": None, "unit": "day"}}, + expected=None, + msg="$dateTrunc should return null for an inline null date literal", + ), +] + +# Property [Field Reference]: $dateTrunc resolves the date from field references, including a +# nested path, an ObjectId, and a Timestamp. +DATETRUNC_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "field_ref", + doc={"d": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$d", "unit": "day"}}, + expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should resolve the date from a field reference", + ), + ExpressionTestCase( + "nested_field", + doc={"doc": {"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}}, + expression={"$dateTrunc": {"date": "$doc.date", "unit": "day"}}, + expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should resolve the date from a nested field path", + ), + ExpressionTestCase( + "objectid_field_ref", + doc={"oid": oid_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$oid", "unit": "day"}}, + expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should resolve an ObjectId from a field reference", + ), + ExpressionTestCase( + "timestamp_field_ref", + doc={"ts": ts_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$ts", "unit": "day"}}, + expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should resolve a Timestamp from a field reference", + ), +] + +# Property [Missing Optional Field Reference]: a missing field reference for an optional parameter +# yields null. +DATETRUNC_MISSING_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_timezone_field_ref", + doc={"d": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$d", "unit": "day", "timezone": "$tz"}}, + expected=None, + msg="$dateTrunc should return null for a missing timezone field reference", + ), + ExpressionTestCase( + "missing_binSize_field_ref", + doc={"d": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$d", "unit": "hour", "binSize": "$bs"}}, + expected=None, + msg="$dateTrunc should return null for a missing binSize field reference", + ), + ExpressionTestCase( + "missing_startOfWeek_field_ref", + doc={"d": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$d", "unit": "week", "startOfWeek": "$sow"}}, + expected=None, + msg="$dateTrunc should return null for a missing startOfWeek field reference", + ), +] + +# Property [Array Path Rejection]: a field path resolving to an array is rejected as an invalid +# date value. +DATETRUNC_ARRAY_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "composite_array_path", + doc={ + "a": [ + {"b": datetime(2021, 6, 15, tzinfo=timezone.utc)}, + {"b": datetime(2021, 7, 1, tzinfo=timezone.utc)}, + ] + }, + expression={"$dateTrunc": {"date": "$a.b", "unit": "day"}}, + error_code=INVALID_DATE_VALUE_ERROR, + msg="$dateTrunc should reject a composite array field path", + ), + ExpressionTestCase( + "single_element_array_path", + doc={"a": [{"b": datetime(2021, 6, 15, 12, 30, 0, tzinfo=timezone.utc)}]}, + expression={"$dateTrunc": {"date": "$a.b", "unit": "day"}}, + error_code=INVALID_DATE_VALUE_ERROR, + msg="$dateTrunc should reject a single-element array field path", + ), +] + +# Property [Return Type]: $dateTrunc returns the date type regardless of the input date type. +DATETRUNC_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type_date", + expression={ + "$type": { + "$dateTrunc": { + "date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc), + "unit": "day", + } + } + }, + expected="date", + msg="$dateTrunc should return the date type for a Date input", + ), + ExpressionTestCase( + "return_type_from_timestamp", + doc={"ts": ts_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$type": {"$dateTrunc": {"date": "$ts", "unit": "day"}}}, + expected="date", + msg="$dateTrunc should return the date type for a Timestamp input", + ), + ExpressionTestCase( + "return_type_from_objectid", + doc={"oid": oid_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$type": {"$dateTrunc": {"date": "$oid", "unit": "day"}}}, + expected="date", + msg="$dateTrunc should return the date type for an ObjectId input", + ), +] + +DATETRUNC_EXPRESSION_TESTS: list[ExpressionTestCase] = ( + DATETRUNC_LITERAL_TESTS + + DATETRUNC_FIELD_REF_TESTS + + DATETRUNC_MISSING_REF_TESTS + + DATETRUNC_ARRAY_PATH_TESTS + + DATETRUNC_RETURN_TYPE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETRUNC_EXPRESSION_TESTS)) +def test_dateTrunc_expressions(collection, test_case: ExpressionTestCase): + """Test $dateTrunc literal and field-reference handling.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_types.py new file mode 100644 index 000000000..0df637af2 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_types.py @@ -0,0 +1,470 @@ +"""$dateTrunc with ObjectId/Timestamp inputs, timezone offsets, DST, boundaries, and extremes.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( + oid_from_args, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_EPOCH, + DATE_MS_BEFORE_EPOCH, + DATE_MS_EPOCH, + DATE_YEAR_1900, + OID_MAX_SIGNED32, + OID_MIN_SIGNED32, + TS_MAX_SIGNED32, + TS_MAX_UNSIGNED32, +) + +# Property [ObjectId And Timestamp Input]: ObjectId and Timestamp inputs are truncated by their +# embedded time. +DATETRUNC_OID_TS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "oid_trunc_day", + doc={"date": oid_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate an ObjectId to the day", + ), + ExpressionTestCase( + "oid_trunc_hour", + doc={"date": oid_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour"}}, + expected=datetime(2021, 3, 20, 11, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate an ObjectId to the hour", + ), + ExpressionTestCase( + "oid_trunc_month", + doc={"date": oid_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, + expected=datetime(2021, 3, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate an ObjectId to the month", + ), + ExpressionTestCase( + "oid_trunc_year", + doc={"date": oid_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, + expected=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate an ObjectId to the year", + ), + ExpressionTestCase( + "oid_with_tz", + doc={"date": oid_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "UTC"}}, + expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate an ObjectId with a timezone", + ), + ExpressionTestCase( + "ts_trunc_day", + doc={"date": ts_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a Timestamp to the day", + ), + ExpressionTestCase( + "ts_trunc_hour", + doc={"date": ts_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour"}}, + expected=datetime(2021, 3, 20, 11, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a Timestamp to the hour", + ), + ExpressionTestCase( + "ts_trunc_month", + doc={"date": ts_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, + expected=datetime(2021, 3, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a Timestamp to the month", + ), + ExpressionTestCase( + "ts_trunc_year", + doc={"date": ts_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, + expected=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a Timestamp to the year", + ), + ExpressionTestCase( + "ts_with_tz", + doc={"date": ts_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "UTC"}}, + expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a Timestamp with a timezone", + ), +] + +# Property [Timezone Offset]: named zones and numeric offsets shift the truncation boundary. +DATETRUNC_TIMEZONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_shifts_day", + doc={"date": datetime(2021, 3, 20, 2, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "-05:00"}}, + expected=datetime(2021, 3, 19, 5, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should shift the day boundary with a negative offset", + ), + ExpressionTestCase( + "tz_gmt", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "GMT"}}, + expected=datetime(2021, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept the GMT timezone", + ), + ExpressionTestCase( + "tz_utc", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "UTC"}}, + expected=datetime(2021, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept the UTC timezone", + ), + ExpressionTestCase( + "tz_offset_half_hour_east", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "+05:30"}}, + expected=datetime(2021, 6, 14, 18, 30, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate with a +05:30 offset", + ), + ExpressionTestCase( + "tz_zero_offset", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "+00:00"}}, + expected=datetime(2021, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should treat a +00:00 offset as UTC", + ), + ExpressionTestCase( + "tz_offset_no_colon", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "-0500"}}, + expected=datetime(2021, 6, 15, 5, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a no-colon offset format", + ), + ExpressionTestCase( + "tz_offset_hour_only", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "+03"}}, + expected=datetime(2021, 6, 14, 21, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept an hour-only offset format", + ), + ExpressionTestCase( + "tz_kolkata_day", + doc={"date": datetime(2021, 6, 15, 20, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "Asia/Kolkata"}}, + expected=datetime(2021, 6, 15, 18, 30, 0, tzinfo=timezone.utc), + msg="$dateTrunc should handle a half-hour named zone for day truncation", + ), +] + +# Property [DST Day Truncation]: day truncation in a DST zone accounts for the transition. +DATETRUNC_DST_DAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dst_spring_forward_day", + doc={"date": datetime(2021, 3, 14, 7, 30, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "America/New_York"}}, + expected=datetime(2021, 3, 14, 5, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should handle DST spring-forward day truncation", + ), + ExpressionTestCase( + "dst_fall_back_day", + doc={"date": datetime(2021, 11, 7, 6, 30, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "America/New_York"}}, + expected=datetime(2021, 11, 7, 4, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should handle DST fall-back day truncation", + ), +] + +# Property [DST Sub-Day Units]: hour and minute truncation are unaffected by DST transitions. +DATETRUNC_DST_SUBDAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dst_spring_hour_trunc", + doc={"date": datetime(2021, 3, 14, 7, 30, 0, tzinfo=timezone.utc)}, + expression={ + "$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "America/New_York"} + }, + expected=datetime(2021, 3, 14, 7, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the hour across DST spring-forward without adjustment", + ), + ExpressionTestCase( + "dst_spring_minute_trunc", + doc={"date": datetime(2021, 3, 14, 7, 0, 30, tzinfo=timezone.utc)}, + expression={ + "$dateTrunc": {"date": "$date", "unit": "minute", "timezone": "America/New_York"} + }, + expected=datetime(2021, 3, 14, 7, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the minute across DST spring-forward without adjustment", + ), + ExpressionTestCase( + "dst_fall_back_hour_trunc", + doc={"date": datetime(2021, 11, 7, 6, 30, 0, tzinfo=timezone.utc)}, + expression={ + "$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "America/New_York"} + }, + expected=datetime(2021, 11, 7, 6, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the hour across DST fall-back without adjustment", + ), +] + +# Property [Boundary Idempotence]: a date already at a unit boundary is returned unchanged. +DATETRUNC_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "year_at_boundary", + doc={"date": datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, + expected=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should return the date unchanged at a year boundary", + ), + ExpressionTestCase( + "month_at_boundary", + doc={"date": datetime(2021, 6, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, + expected=datetime(2021, 6, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should return the date unchanged at a month boundary", + ), + ExpressionTestCase( + "day_at_boundary", + doc={"date": datetime(2021, 6, 15, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=datetime(2021, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should return the date unchanged at a day boundary", + ), + ExpressionTestCase( + "hour_at_boundary", + doc={"date": datetime(2021, 6, 15, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour"}}, + expected=datetime(2021, 6, 15, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should return the date unchanged at an hour boundary", + ), + ExpressionTestCase( + "minute_at_boundary", + doc={"date": datetime(2021, 6, 15, 10, 30, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "minute"}}, + expected=datetime(2021, 6, 15, 10, 30, 0, tzinfo=timezone.utc), + msg="$dateTrunc should return the date unchanged at a minute boundary", + ), + ExpressionTestCase( + "second_at_boundary", + doc={"date": datetime(2021, 6, 15, 10, 30, 45, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "second"}}, + expected=datetime(2021, 6, 15, 10, 30, 45, tzinfo=timezone.utc), + msg="$dateTrunc should return the date unchanged at a second boundary", + ), +] + +# Property [End Of Period]: a date at the end of a period truncates to that period's start. +DATETRUNC_END_OF_PERIOD_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "year_end", + doc={"date": datetime(2021, 12, 31, 23, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, + expected=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the last millisecond of a year to the year start", + ), + ExpressionTestCase( + "quarter_end_q1", + doc={"date": datetime(2021, 3, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "quarter"}}, + expected=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the end of Q1 to the Q1 start", + ), + ExpressionTestCase( + "quarter_start_q2", + doc={"date": datetime(2021, 4, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "quarter"}}, + expected=datetime(2021, 4, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should return the Q2 start at the exact boundary", + ), + ExpressionTestCase( + "day_end", + doc={"date": datetime(2021, 6, 15, 23, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=datetime(2021, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the last millisecond of a day to the day start", + ), +] + +# Property [Epoch And Distant Dates]: epoch, pre-epoch, distant past, and distant future dates +# truncate correctly, including from ObjectId and Timestamp inputs. +DATETRUNC_EPOCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "epoch", + doc={"date": datetime(1970, 1, 1, 12, 30, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=DATE_EPOCH, + msg="$dateTrunc should truncate an epoch-day date", + ), + ExpressionTestCase( + "pre_epoch", + doc={"date": datetime(1969, 6, 15, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, + expected=datetime(1969, 6, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a pre-epoch date", + ), + ExpressionTestCase( + "distant_past", + doc={"date": datetime(1900, 3, 15, 8, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, + expected=DATE_YEAR_1900, + msg="$dateTrunc should truncate a distant past date", + ), + ExpressionTestCase( + "distant_future", + doc={"date": datetime(2100, 7, 20, 15, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "quarter"}}, + expected=datetime(2100, 7, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a distant future date", + ), + ExpressionTestCase( + "oid_epoch", + doc={"date": oid_from_args(1970, 1, 1, 12, 30, 0)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=DATE_EPOCH, + msg="$dateTrunc should truncate an epoch ObjectId", + ), + ExpressionTestCase( + "ts_epoch", + doc={"date": ts_from_args(1970, 1, 1, 12, 30, 0)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=DATE_EPOCH, + msg="$dateTrunc should truncate an epoch Timestamp", + ), + ExpressionTestCase( + "oid_future", + doc={"date": oid_from_args(2035, 7, 20, 15, 0, 0)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, + expected=datetime(2035, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a future ObjectId", + ), + ExpressionTestCase( + "ts_future", + doc={"date": ts_from_args(2100, 7, 20, 15, 0, 0)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, + expected=datetime(2100, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a future Timestamp", + ), +] + +# Property [Leap Year]: leap-day dates truncate correctly, including century leap-year rules. +DATETRUNC_LEAP_YEAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "leap_day_trunc_day", + doc={"date": datetime(2020, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=datetime(2020, 2, 29, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a leap day to the day start", + ), + ExpressionTestCase( + "leap_day_trunc_month", + doc={"date": datetime(2020, 2, 29, 23, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, + expected=datetime(2020, 2, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a leap day to the month start", + ), + ExpressionTestCase( + "century_non_leap_1900_trunc_month", + doc={"date": datetime(1900, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, + expected=datetime(1900, 2, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a 1900 February date to the month start", + ), + ExpressionTestCase( + "century_leap_2000_trunc_month", + doc={"date": datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, + expected=datetime(2000, 2, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a 2000 leap day to the month start", + ), +] + +# Property [Far Future]: dates in year 9999 truncate correctly. +DATETRUNC_FAR_FUTURE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "far_future_year", + doc={"date": datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, + expected=datetime(9999, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a year 9999 date to the year start", + ), + ExpressionTestCase( + "far_future_quarter", + doc={"date": datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "quarter"}}, + expected=datetime(9999, 10, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a year 9999 date to the Q4 start", + ), +] + +# Property [DatetimeMS And Numeric Boundaries]: DatetimeMS, max Timestamp, and signed-boundary +# ObjectId inputs truncate correctly. +DATETRUNC_NUMERIC_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_ms_epoch_trunc_day", + doc={"date": DATE_MS_EPOCH}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=DATE_EPOCH, + msg="$dateTrunc should truncate an epoch DatetimeMS to the day", + ), + ExpressionTestCase( + "date_ms_before_epoch_trunc_day", + doc={"date": DATE_MS_BEFORE_EPOCH}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=datetime(1969, 12, 31, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a pre-epoch DatetimeMS to the day", + ), + ExpressionTestCase( + "ts_max_s32_trunc_day", + doc={"date": TS_MAX_SIGNED32}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=datetime(2038, 1, 19, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a max signed 32-bit Timestamp to the day", + ), + ExpressionTestCase( + "ts_max_u32_trunc_month", + doc={"date": TS_MAX_UNSIGNED32}, + expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, + expected=datetime(2106, 2, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a max unsigned 32-bit Timestamp to the month", + ), + ExpressionTestCase( + "oid_max_signed32_trunc_day", + doc={"date": OID_MAX_SIGNED32}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=datetime(2038, 1, 19, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a max signed 32-bit ObjectId to the day", + ), + ExpressionTestCase( + "oid_high_bit_trunc_year", + doc={"date": OID_MIN_SIGNED32}, + expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, + expected=datetime(1901, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a high-bit ObjectId to the year", + ), +] + +DATETRUNC_TYPE_TESTS: list[ExpressionTestCase] = ( + DATETRUNC_OID_TS_TESTS + + DATETRUNC_TIMEZONE_TESTS + + DATETRUNC_DST_DAY_TESTS + + DATETRUNC_DST_SUBDAY_TESTS + + DATETRUNC_BOUNDARY_TESTS + + DATETRUNC_END_OF_PERIOD_TESTS + + DATETRUNC_EPOCH_TESTS + + DATETRUNC_LEAP_YEAR_TESTS + + DATETRUNC_FAR_FUTURE_TESTS + + DATETRUNC_NUMERIC_BOUNDARY_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETRUNC_TYPE_TESTS)) +def test_dateTrunc_types(collection, test_case: ExpressionTestCase): + """Test $dateTrunc date-typed inputs, timezones, and boundaries.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) From a7affa3eea11e70b8f9452fac0b622b2fa062d47 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Thu, 9 Jul 2026 17:04:46 -0700 Subject: [PATCH 5/5] Reorganize date operator tests by property group Splits the $dateAdd, $dateSubtract, $dateDiff, and $dateTrunc test files into smaller files grouped by property theme, and separates success cases from rejection (error) cases into dedicated files, per review feedback on #674. No test cases are added, removed, or changed; blocks are moved verbatim and parametrized IDs are preserved. The 885-line test_dateTrunc_basic.py and the other oversized mixed success/error files are replaced by cohesive per-theme files (arithmetic, calendar, timezone, input types, errors, etc.). Signed-off-by: Daniel Frankcom --- ...dd_basic.py => test_dateAdd_arithmetic.py} | 90 +- .../date/dateAdd/test_dateAdd_calendar.py | 246 +++++ .../date/dateAdd/test_dateAdd_date_range.py | 130 +++ .../date/dateAdd/test_dateAdd_dates.py | 514 ---------- ...ateAdd_types.py => test_dateAdd_errors.py} | 240 +++-- .../date/dateAdd/test_dateAdd_expressions.py | 62 +- .../date/dateAdd/test_dateAdd_input_types.py | 160 ++++ .../date/dateAdd/test_dateAdd_null.py | 138 +++ .../date/dateAdd/test_dateAdd_timezone.py | 57 +- .../date/dateDiff/test_dateDiff_basic.py | 359 ++----- ...iff_dates.py => test_dateDiff_counting.py} | 218 +---- .../date/dateDiff/test_dateDiff_errors.py | 438 +++++++++ .../dateDiff/test_dateDiff_expressions.py | 100 +- ..._types.py => test_dateDiff_input_types.py} | 228 +---- .../date/dateDiff/test_dateDiff_null.py | 135 +++ .../date/dateDiff/test_dateDiff_range.py | 165 ++++ .../date/dateDiff/test_dateDiff_timezone.py | 136 +-- .../date/dateDiff/test_dateDiff_week.py | 167 ++++ ...sic.py => test_dateSubtract_arithmetic.py} | 100 +- .../test_dateSubtract_calendar.py | 198 ++++ .../test_dateSubtract_date_range.py | 135 +++ .../dateSubtract/test_dateSubtract_dates.py | 473 ---------- ...t_types.py => test_dateSubtract_errors.py} | 247 +++-- .../test_dateSubtract_expressions.py | 61 +- .../test_dateSubtract_input_types.py | 161 ++++ .../dateSubtract/test_dateSubtract_null.py | 140 +++ .../test_dateSubtract_timezone.py | 70 +- .../dateTrunc/test_dateTrunc_arguments.py | 102 ++ .../date/dateTrunc/test_dateTrunc_basic.py | 885 ------------------ .../test_dateTrunc_binsize_errors.py | 207 ++++ .../date/dateTrunc/test_dateTrunc_errors.py | 377 ++++++++ .../dateTrunc/test_dateTrunc_expressions.py | 66 +- .../dateTrunc/test_dateTrunc_input_types.py | 193 ++++ .../date/dateTrunc/test_dateTrunc_null.py | 132 +++ .../date/dateTrunc/test_dateTrunc_range.py | 146 +++ .../date/dateTrunc/test_dateTrunc_timezone.py | 221 +++++ ..._dates.py => test_dateTrunc_truncation.py} | 134 ++- .../date/dateTrunc/test_dateTrunc_types.py | 470 ---------- .../date/dateTrunc/test_dateTrunc_week.py | 203 ++++ 39 files changed, 4334 insertions(+), 3970 deletions(-) rename documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/{test_dateAdd_basic.py => test_dateAdd_arithmetic.py} (78%) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_calendar.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_date_range.py delete mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_dates.py rename documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/{test_dateAdd_types.py => test_dateAdd_errors.py} (61%) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_input_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_null.py rename documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/{test_dateDiff_dates.py => test_dateDiff_counting.py} (63%) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_errors.py rename documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/{test_dateDiff_types.py => test_dateDiff_input_types.py} (52%) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_null.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_range.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_week.py rename documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/{test_dateSubtract_basic.py => test_dateSubtract_arithmetic.py} (77%) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_calendar.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_date_range.py delete mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_dates.py rename documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/{test_dateSubtract_types.py => test_dateSubtract_errors.py} (60%) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_input_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_null.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_arguments.py delete mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_basic.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_binsize_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_input_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_null.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_range.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_timezone.py rename documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/{test_dateTrunc_dates.py => test_dateTrunc_truncation.py} (67%) delete mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_week.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_basic.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_arithmetic.py similarity index 78% rename from documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_basic.py rename to documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_arithmetic.py index b50eaa798..3e5272bde 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_basic.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_arithmetic.py @@ -1,4 +1,4 @@ -"""$dateAdd basic operations, amount types, and boundaries.""" +"""$dateAdd core arithmetic: unit application, sign, amount types, and unit equivalence.""" from datetime import datetime, timezone @@ -12,13 +12,9 @@ assert_expression_result, execute_expression_with_insert, ) -from documentdb_tests.framework.error_codes import DATEADD_INVALID_AMOUNT_ERROR from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import ( DECIMAL128_NEGATIVE_ZERO, - DECIMAL128_ONE_AND_HALF, - DOUBLE_MIN_SUBNORMAL, - DOUBLE_NEAR_MIN, DOUBLE_NEGATIVE_ZERO, INT32_MAX, INT32_MIN, @@ -222,82 +218,58 @@ ), ] -# Property [Amount Non-Integral]: a non-integral numeric amount is rejected. -DATEADD_AMOUNT_ERROR_TESTS: list[ExpressionTestCase] = [ +# Property [Unit Equivalence]: a smaller unit times its multiple equals the larger unit. +DATEADD_UNIT_EQUIVALENCE_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( - "amount_non_integral_double_1_5", + "millisecond_1000_equals_second_1", doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1.5}}, - error_code=DATEADD_INVALID_AMOUNT_ERROR, - msg="$dateAdd should reject a non-integral double amount", + expression={"$dateAdd": {"startDate": "$date", "unit": "millisecond", "amount": 1000}}, + expected=datetime(2000, 1, 1, 12, 0, 1, tzinfo=timezone.utc), + msg="$dateAdd of 1000 milliseconds should equal adding 1 second", ), ExpressionTestCase( - "amount_non_integral_double_5_9", + "millisecond_precision_1ms", doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 5.9}}, - error_code=DATEADD_INVALID_AMOUNT_ERROR, - msg="$dateAdd should reject a non-integral double amount close to an integer", + expression={"$dateAdd": {"startDate": "$date", "unit": "millisecond", "amount": 1}}, + expected=datetime(2000, 1, 1, 12, 0, 0, 1000, tzinfo=timezone.utc), + msg="$dateAdd should increment by exactly 1 millisecond", ), ExpressionTestCase( - "amount_non_integral_double_negative", - doc={"date": datetime(2000, 1, 10, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": -5.9}}, - error_code=DATEADD_INVALID_AMOUNT_ERROR, - msg="$dateAdd should reject a non-integral negative double amount", + "day_7_equals_week", + doc={"date": datetime(2000, 6, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 7}}, + expected=datetime(2000, 6, 8, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd of 7 days should equal adding 1 week", ), ExpressionTestCase( - "amount_non_integral_decimal128", - doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={ - "$dateAdd": {"startDate": "$date", "unit": "day", "amount": DECIMAL128_ONE_AND_HALF} - }, - error_code=DATEADD_INVALID_AMOUNT_ERROR, - msg="$dateAdd should reject a non-integral decimal128 amount", - ), - ExpressionTestCase( - "amount_decimal128_non_integral_34th_digit", - doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={ - "$dateAdd": { - "startDate": "$date", - "unit": "day", - "amount": Decimal128("3.000000000000000000000000000000001"), - } - }, - error_code=DATEADD_INVALID_AMOUNT_ERROR, - msg="$dateAdd should reject a decimal128 amount that is non-integral at the 34th digit", + "month_12_equals_year", + doc={"date": datetime(2000, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 12}}, + expected=datetime(2001, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd of 12 months should equal adding 1 year", ), ExpressionTestCase( - "amount_double_near_min", - doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": DOUBLE_NEAR_MIN}}, - error_code=DATEADD_INVALID_AMOUNT_ERROR, - msg="$dateAdd should reject a near-minimum double amount as non-integral", - ), - ExpressionTestCase( - "amount_double_min_subnormal", - doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={ - "$dateAdd": {"startDate": "$date", "unit": "day", "amount": DOUBLE_MIN_SUBNORMAL} - }, - error_code=DATEADD_INVALID_AMOUNT_ERROR, - msg="$dateAdd should reject a minimum subnormal double amount as non-integral", + "month_3_equals_quarter", + doc={"date": datetime(2000, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 3}}, + expected=datetime(2000, 9, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd of 3 months should equal adding 1 quarter", ), ] -DATEADD_BASIC_TESTS: list[ExpressionTestCase] = ( +DATEADD_ARITHMETIC_TESTS: list[ExpressionTestCase] = ( DATEADD_UNIT_TESTS + DATEADD_NEGATIVE_TESTS + DATEADD_ZERO_TESTS + DATEADD_AMOUNT_TYPE_TESTS + DATEADD_AMOUNT_BOUNDARY_TESTS - + DATEADD_AMOUNT_ERROR_TESTS + + DATEADD_UNIT_EQUIVALENCE_TESTS ) -@pytest.mark.parametrize("test_case", pytest_params(DATEADD_BASIC_TESTS)) -def test_dateAdd_basic(collection, test_case: ExpressionTestCase): - """Test $dateAdd basic operations.""" +@pytest.mark.parametrize("test_case", pytest_params(DATEADD_ARITHMETIC_TESTS)) +def test_dateAdd_arithmetic(collection, test_case: ExpressionTestCase): + """Test $dateAdd arithmetic across units, amount signs, and amount types.""" result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_calendar.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_calendar.py new file mode 100644 index 000000000..935dae4c8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_calendar.py @@ -0,0 +1,246 @@ +"""$dateAdd calendar rules: leap years, century leap rule, month clamping, and boundary carry.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_Y2K, +) + +# Property [Leap Year]: adding across February resolves leap-year day counts correctly. +DATEADD_LEAP_YEAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "leap_year_feb29_add_year", + doc={"date": datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=datetime(2001, 2, 28, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Feb 29 to Feb 28 when adding a year into a non-leap year", + ), + ExpressionTestCase( + "leap_year_add_day_to_feb28", + doc={"date": datetime(2000, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should land on Feb 29 in a leap year", + ), + ExpressionTestCase( + "non_leap_year_feb28_add_day", + doc={"date": datetime(1999, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1999, 3, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should roll to Mar 1 in a non-leap year", + ), + ExpressionTestCase( + "leap_year_feb29_add_month", + doc={"date": datetime(2020, 2, 29, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2020, 3, 29, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should add a month from Feb 29 to Mar 29", + ), + ExpressionTestCase( + "leap_year_feb27_add_2days", + doc={"date": datetime(2020, 2, 27, 14, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 2}}, + expected=datetime(2020, 2, 29, 14, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should land on Feb 29 in a leap year", + ), + ExpressionTestCase( + "non_leap_year_feb27_add_2days", + doc={"date": datetime(2021, 2, 27, 14, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 2}}, + expected=datetime(2021, 3, 1, 14, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should roll to Mar 1 in a non-leap year", + ), + ExpressionTestCase( + "leap_year_365_days", + doc={"date": datetime(2020, 1, 1, 15, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 365}}, + expected=datetime(2020, 12, 31, 15, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should not wrap the year when adding 365 days in a leap year", + ), + ExpressionTestCase( + "non_leap_year_365_days", + doc={"date": datetime(2019, 1, 1, 15, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 365}}, + expected=datetime(2020, 1, 1, 15, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should wrap to the next year when adding 365 days in a non-leap year", + ), +] + +# Property [Century Leap Year]: the divisible-by-100/400 leap rule is honored. +DATEADD_CENTURY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "century_non_leap_1900_feb28_add_day", + doc={"date": datetime(1900, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1900, 3, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should roll to Mar 1 in 1900, a non-leap century year", + ), + ExpressionTestCase( + "century_leap_2000_feb28_add_day", + doc={"date": datetime(2000, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should land on Feb 29 in 2000, a leap century year", + ), +] + +# Property [Month Clamping]: adding months or quarters clamps to the last valid day of the +# target month. +DATEADD_MONTH_CLAMP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "jan31_add_month_leap", + doc={"date": datetime(2020, 1, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2020, 2, 29, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Jan 31 plus 1 month to Feb 29 in a leap year", + ), + ExpressionTestCase( + "jan31_add_month_non_leap", + doc={"date": datetime(2021, 1, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2021, 2, 28, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Jan 31 plus 1 month to Feb 28 in a non-leap year", + ), + ExpressionTestCase( + "jan29_add_month_non_leap", + doc={"date": datetime(2021, 1, 29, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2021, 2, 28, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Jan 29 plus 1 month to Feb 28 in a non-leap year", + ), + ExpressionTestCase( + "oct31_add_month", + doc={"date": datetime(2020, 10, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2020, 11, 30, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Oct 31 plus 1 month to Nov 30", + ), + ExpressionTestCase( + "mar31_add_month", + doc={"date": datetime(2000, 3, 31, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2000, 4, 30, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Mar 31 plus 1 month to Apr 30", + ), + ExpressionTestCase( + "may31_add_month", + doc={"date": datetime(2000, 5, 31, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2000, 6, 30, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp May 31 plus 1 month to Jun 30", + ), + ExpressionTestCase( + "aug31_add_month", + doc={"date": datetime(2020, 8, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2020, 9, 30, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Aug 31 plus 1 month to Sep 30", + ), + ExpressionTestCase( + "dec31_add_year_no_adjustment", + doc={"date": datetime(2020, 12, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=datetime(2021, 12, 31, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should not adjust Dec 31 when adding a year", + ), + ExpressionTestCase( + "mar31_subtract_month_leap", + doc={"date": datetime(2020, 3, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": -1}}, + expected=datetime(2020, 2, 29, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Mar 31 minus 1 month to Feb 29 in a leap year", + ), + ExpressionTestCase( + "mar31_subtract_month_non_leap", + doc={"date": datetime(2021, 3, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": -1}}, + expected=datetime(2021, 2, 28, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Mar 31 minus 1 month to Feb 28 in a non-leap year", + ), + ExpressionTestCase( + "jan31_subtract_month_no_adjustment", + doc={"date": datetime(2021, 1, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": -1}}, + expected=datetime(2020, 12, 31, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should not adjust Jan 31 minus 1 month, landing on Dec 31", + ), + ExpressionTestCase( + "jan31_add_quarter", + doc={"date": datetime(2021, 1, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "quarter", "amount": 1}}, + expected=datetime(2021, 4, 30, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should clamp Jan 31 plus 1 quarter to Apr 30", + ), + ExpressionTestCase( + "quarter_subtract", + doc={"date": datetime(2021, 4, 30, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "quarter", "amount": -1}}, + expected=datetime(2021, 1, 30, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should subtract 1 quarter", + ), + ExpressionTestCase( + "large_positive_month", + doc={"date": datetime(2020, 12, 31, 12, 10, 5, 10000, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 49}}, + expected=datetime(2025, 1, 31, 12, 10, 5, 10000, tzinfo=timezone.utc), + msg="$dateAdd should clamp a large positive month amount to end-of-month", + ), + ExpressionTestCase( + "large_negative_month", + doc={"date": datetime(2020, 12, 31, 12, 10, 5, 10000, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": -49}}, + expected=datetime(2016, 11, 30, 12, 10, 5, 10000, tzinfo=timezone.utc), + msg="$dateAdd should clamp a large negative month amount to end-of-month", + ), +] + +# Property [Boundary Crossing]: adding across day, month, and year boundaries carries correctly. +DATEADD_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dec31_add_day", + doc={"date": datetime(2000, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(2001, 1, 1, 23, 59, 59, tzinfo=timezone.utc), + msg="$dateAdd should cross the year boundary from Dec 31 plus 1 day", + ), + ExpressionTestCase( + "jan1_subtract_day", + doc={"date": DATE_Y2K}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": -1}}, + expected=datetime(1999, 12, 31, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should cross the year boundary from Jan 1 minus 1 day", + ), + ExpressionTestCase( + "dec31_add_hour", + doc={"date": datetime(2000, 12, 31, 23, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "hour", "amount": 2}}, + expected=datetime(2001, 1, 1, 1, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should cross the year boundary from Dec 31 plus 2 hours", + ), +] + +DATEADD_CALENDAR_TESTS: list[ExpressionTestCase] = ( + DATEADD_LEAP_YEAR_TESTS + + DATEADD_CENTURY_TESTS + + DATEADD_MONTH_CLAMP_TESTS + + DATEADD_BOUNDARY_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEADD_CALENDAR_TESTS)) +def test_dateAdd_calendar(collection, test_case: ExpressionTestCase): + """Test $dateAdd honors calendar rules when adding months, years, and across boundaries.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_date_range.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_date_range.py new file mode 100644 index 000000000..fee91defc --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_date_range.py @@ -0,0 +1,130 @@ +"""$dateAdd across the representable date range: historical, future, epoch, and limit dates.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_EPOCH, + DATE_YEAR_1900, + DATE_YEAR_9999, +) + +# Property [Historical And Future]: distant past and future start dates are handled. +DATEADD_HISTORICAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "historical_date", + doc={"date": datetime(1900, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 10}}, + expected=datetime(1910, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should handle a 1900 historical date", + ), + ExpressionTestCase( + "pre_epoch_1960", + doc={"date": datetime(1960, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 100}}, + expected=datetime(1960, 4, 10, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should handle a pre-epoch 1960 date", + ), + ExpressionTestCase( + "far_future", + doc={"date": datetime(2100, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 100}}, + expected=datetime(2200, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should handle a far-future 2100 date", + ), + ExpressionTestCase( + "large_year_amount", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 1000}}, + expected=datetime(3000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should handle adding 1000 years", + ), + ExpressionTestCase( + "distant_past", + doc={"date": DATE_YEAR_1900}, + expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=datetime(1901, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should handle 1900 plus 1 year", + ), + ExpressionTestCase( + "distant_future_month", + doc={"date": datetime(2100, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 6}}, + expected=datetime(2100, 7, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should handle 2100 plus 6 months", + ), +] + +# Property [Epoch Crossing]: adding forward and backward across the Unix epoch is correct. +DATEADD_EPOCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "epoch_add_day", + doc={"date": DATE_EPOCH}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1970, 1, 2, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should add a day to the epoch", + ), + ExpressionTestCase( + "pre_epoch_add_day", + doc={"date": datetime(1969, 12, 31, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=DATE_EPOCH, + msg="$dateAdd should cross the epoch forward from 1969", + ), + ExpressionTestCase( + "cross_epoch_back", + doc={"date": DATE_EPOCH}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": -1}}, + expected=datetime(1969, 12, 31, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should cross the epoch backward to 1969", + ), +] + +# Property [Date Limits]: adding near the maximum representable date is handled. +DATEADD_DATE_LIMIT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "near_max_date", + doc={"date": datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "millisecond", "amount": 1}}, + expected=datetime(9999, 12, 31, 23, 59, 59, 1000, tzinfo=timezone.utc), + msg="$dateAdd should add 1 millisecond near the maximum date", + ), + ExpressionTestCase( + "epoch_plus_large_ms", + doc={"date": DATE_EPOCH}, + expression={ + "$dateAdd": {"startDate": "$date", "unit": "millisecond", "amount": 253_402_300_799_999} + }, + expected=DATE_YEAR_9999, + msg="$dateAdd should reach the near-maximum date from the epoch with a large ms amount", + ), + ExpressionTestCase( + "at_python_max_year", + doc={"date": datetime(9999, 12, 31, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "hour", "amount": 23}}, + expected=datetime(9999, 12, 31, 23, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should add 23 hours at year 9999", + ), +] + +DATEADD_DATE_RANGE_TESTS: list[ExpressionTestCase] = ( + DATEADD_HISTORICAL_TESTS + DATEADD_EPOCH_TESTS + DATEADD_DATE_LIMIT_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEADD_DATE_RANGE_TESTS)) +def test_dateAdd_date_range(collection, test_case: ExpressionTestCase): + """Test $dateAdd remains correct across distant, epoch-crossing, and boundary dates.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_dates.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_dates.py deleted file mode 100644 index 2e90bee76..000000000 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_dates.py +++ /dev/null @@ -1,514 +0,0 @@ -"""$dateAdd date edge cases: leap years, month clamping, boundaries, and non-date starts.""" - -from datetime import datetime, timezone - -import pytest - -from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( - ExpressionTestCase, -) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( - oid_from_args, - ts_from_args, -) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( - assert_expression_result, - execute_expression_with_insert, -) -from documentdb_tests.framework.error_codes import DATEADD_INVALID_LARGE_VALUE_ERROR -from documentdb_tests.framework.parametrize import pytest_params -from documentdb_tests.framework.test_constants import ( - DATE_EPOCH, - DATE_MS_EPOCH, - DATE_Y2K, - DATE_YEAR_1900, - DATE_YEAR_9999, - OID_EPOCH, - OID_MAX_SIGNED32, - OID_MIN_SIGNED32, - TS_EPOCH, - TS_MAX_SIGNED32, - TS_MAX_UNSIGNED32, -) - -# Property [Leap Year]: adding across February resolves leap-year day counts correctly. -DATEADD_LEAP_YEAR_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "leap_year_feb29_add_year", - doc={"date": datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 1}}, - expected=datetime(2001, 2, 28, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should clamp Feb 29 to Feb 28 when adding a year into a non-leap year", - ), - ExpressionTestCase( - "leap_year_add_day_to_feb28", - doc={"date": datetime(2000, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, - expected=datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should land on Feb 29 in a leap year", - ), - ExpressionTestCase( - "non_leap_year_feb28_add_day", - doc={"date": datetime(1999, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, - expected=datetime(1999, 3, 1, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should roll to Mar 1 in a non-leap year", - ), - ExpressionTestCase( - "leap_year_feb29_add_month", - doc={"date": datetime(2020, 2, 29, 10, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, - expected=datetime(2020, 3, 29, 10, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should add a month from Feb 29 to Mar 29", - ), - ExpressionTestCase( - "leap_year_feb27_add_2days", - doc={"date": datetime(2020, 2, 27, 14, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 2}}, - expected=datetime(2020, 2, 29, 14, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should land on Feb 29 in a leap year", - ), - ExpressionTestCase( - "non_leap_year_feb27_add_2days", - doc={"date": datetime(2021, 2, 27, 14, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 2}}, - expected=datetime(2021, 3, 1, 14, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should roll to Mar 1 in a non-leap year", - ), - ExpressionTestCase( - "leap_year_365_days", - doc={"date": datetime(2020, 1, 1, 15, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 365}}, - expected=datetime(2020, 12, 31, 15, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should not wrap the year when adding 365 days in a leap year", - ), - ExpressionTestCase( - "non_leap_year_365_days", - doc={"date": datetime(2019, 1, 1, 15, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 365}}, - expected=datetime(2020, 1, 1, 15, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should wrap to the next year when adding 365 days in a non-leap year", - ), -] - -# Property [Century Leap Year]: the divisible-by-100/400 leap rule is honored. -DATEADD_CENTURY_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "century_non_leap_1900_feb28_add_day", - doc={"date": datetime(1900, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, - expected=datetime(1900, 3, 1, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should roll to Mar 1 in 1900, a non-leap century year", - ), - ExpressionTestCase( - "century_leap_2000_feb28_add_day", - doc={"date": datetime(2000, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, - expected=datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should land on Feb 29 in 2000, a leap century year", - ), -] - -# Property [Month Clamping]: adding months or quarters clamps to the last valid day of the -# target month. -DATEADD_MONTH_CLAMP_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "jan31_add_month_leap", - doc={"date": datetime(2020, 1, 31, 10, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, - expected=datetime(2020, 2, 29, 10, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should clamp Jan 31 plus 1 month to Feb 29 in a leap year", - ), - ExpressionTestCase( - "jan31_add_month_non_leap", - doc={"date": datetime(2021, 1, 31, 10, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, - expected=datetime(2021, 2, 28, 10, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should clamp Jan 31 plus 1 month to Feb 28 in a non-leap year", - ), - ExpressionTestCase( - "jan29_add_month_non_leap", - doc={"date": datetime(2021, 1, 29, 10, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, - expected=datetime(2021, 2, 28, 10, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should clamp Jan 29 plus 1 month to Feb 28 in a non-leap year", - ), - ExpressionTestCase( - "oct31_add_month", - doc={"date": datetime(2020, 10, 31, 10, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, - expected=datetime(2020, 11, 30, 10, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should clamp Oct 31 plus 1 month to Nov 30", - ), - ExpressionTestCase( - "mar31_add_month", - doc={"date": datetime(2000, 3, 31, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, - expected=datetime(2000, 4, 30, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should clamp Mar 31 plus 1 month to Apr 30", - ), - ExpressionTestCase( - "may31_add_month", - doc={"date": datetime(2000, 5, 31, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, - expected=datetime(2000, 6, 30, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should clamp May 31 plus 1 month to Jun 30", - ), - ExpressionTestCase( - "aug31_add_month", - doc={"date": datetime(2020, 8, 31, 10, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 1}}, - expected=datetime(2020, 9, 30, 10, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should clamp Aug 31 plus 1 month to Sep 30", - ), - ExpressionTestCase( - "dec31_add_year_no_adjustment", - doc={"date": datetime(2020, 12, 31, 10, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 1}}, - expected=datetime(2021, 12, 31, 10, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should not adjust Dec 31 when adding a year", - ), - ExpressionTestCase( - "mar31_subtract_month_leap", - doc={"date": datetime(2020, 3, 31, 10, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": -1}}, - expected=datetime(2020, 2, 29, 10, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should clamp Mar 31 minus 1 month to Feb 29 in a leap year", - ), - ExpressionTestCase( - "mar31_subtract_month_non_leap", - doc={"date": datetime(2021, 3, 31, 10, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": -1}}, - expected=datetime(2021, 2, 28, 10, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should clamp Mar 31 minus 1 month to Feb 28 in a non-leap year", - ), - ExpressionTestCase( - "jan31_subtract_month_no_adjustment", - doc={"date": datetime(2021, 1, 31, 10, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": -1}}, - expected=datetime(2020, 12, 31, 10, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should not adjust Jan 31 minus 1 month, landing on Dec 31", - ), - ExpressionTestCase( - "jan31_add_quarter", - doc={"date": datetime(2021, 1, 31, 10, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "quarter", "amount": 1}}, - expected=datetime(2021, 4, 30, 10, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should clamp Jan 31 plus 1 quarter to Apr 30", - ), - ExpressionTestCase( - "quarter_subtract", - doc={"date": datetime(2021, 4, 30, 10, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "quarter", "amount": -1}}, - expected=datetime(2021, 1, 30, 10, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should subtract 1 quarter", - ), - ExpressionTestCase( - "large_positive_month", - doc={"date": datetime(2020, 12, 31, 12, 10, 5, 10000, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 49}}, - expected=datetime(2025, 1, 31, 12, 10, 5, 10000, tzinfo=timezone.utc), - msg="$dateAdd should clamp a large positive month amount to end-of-month", - ), - ExpressionTestCase( - "large_negative_month", - doc={"date": datetime(2020, 12, 31, 12, 10, 5, 10000, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": -49}}, - expected=datetime(2016, 11, 30, 12, 10, 5, 10000, tzinfo=timezone.utc), - msg="$dateAdd should clamp a large negative month amount to end-of-month", - ), -] - -# Property [Boundary Crossing]: adding across day, month, and year boundaries carries correctly. -DATEADD_BOUNDARY_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "dec31_add_day", - doc={"date": datetime(2000, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, - expected=datetime(2001, 1, 1, 23, 59, 59, tzinfo=timezone.utc), - msg="$dateAdd should cross the year boundary from Dec 31 plus 1 day", - ), - ExpressionTestCase( - "jan1_subtract_day", - doc={"date": DATE_Y2K}, - expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": -1}}, - expected=datetime(1999, 12, 31, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should cross the year boundary from Jan 1 minus 1 day", - ), - ExpressionTestCase( - "dec31_add_hour", - doc={"date": datetime(2000, 12, 31, 23, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "hour", "amount": 2}}, - expected=datetime(2001, 1, 1, 1, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should cross the year boundary from Dec 31 plus 2 hours", - ), -] - -# Property [Historical And Future]: distant past and future start dates are handled. -DATEADD_HISTORICAL_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "historical_date", - doc={"date": datetime(1900, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 10}}, - expected=datetime(1910, 1, 1, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should handle a 1900 historical date", - ), - ExpressionTestCase( - "pre_epoch_1960", - doc={"date": datetime(1960, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 100}}, - expected=datetime(1960, 4, 10, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should handle a pre-epoch 1960 date", - ), - ExpressionTestCase( - "far_future", - doc={"date": datetime(2100, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 100}}, - expected=datetime(2200, 1, 1, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should handle a far-future 2100 date", - ), - ExpressionTestCase( - "large_year_amount", - doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 1000}}, - expected=datetime(3000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should handle adding 1000 years", - ), - ExpressionTestCase( - "distant_past", - doc={"date": DATE_YEAR_1900}, - expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 1}}, - expected=datetime(1901, 1, 1, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should handle 1900 plus 1 year", - ), - ExpressionTestCase( - "distant_future_month", - doc={"date": datetime(2100, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 6}}, - expected=datetime(2100, 7, 1, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should handle 2100 plus 6 months", - ), -] - -# Property [Epoch Crossing]: adding forward and backward across the Unix epoch is correct. -DATEADD_EPOCH_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "epoch_add_day", - doc={"date": DATE_EPOCH}, - expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, - expected=datetime(1970, 1, 2, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should add a day to the epoch", - ), - ExpressionTestCase( - "pre_epoch_add_day", - doc={"date": datetime(1969, 12, 31, 0, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, - expected=DATE_EPOCH, - msg="$dateAdd should cross the epoch forward from 1969", - ), - ExpressionTestCase( - "cross_epoch_back", - doc={"date": DATE_EPOCH}, - expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": -1}}, - expected=datetime(1969, 12, 31, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should cross the epoch backward to 1969", - ), -] - -# Property [Date Limits]: adding near the maximum representable date is handled. -DATEADD_DATE_LIMIT_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "near_max_date", - doc={"date": datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "millisecond", "amount": 1}}, - expected=datetime(9999, 12, 31, 23, 59, 59, 1000, tzinfo=timezone.utc), - msg="$dateAdd should add 1 millisecond near the maximum date", - ), - ExpressionTestCase( - "epoch_plus_large_ms", - doc={"date": DATE_EPOCH}, - expression={ - "$dateAdd": {"startDate": "$date", "unit": "millisecond", "amount": 253_402_300_799_999} - }, - expected=DATE_YEAR_9999, - msg="$dateAdd should reach the near-maximum date from the epoch with a large ms amount", - ), - ExpressionTestCase( - "at_python_max_year", - doc={"date": datetime(9999, 12, 31, 0, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "hour", "amount": 23}}, - expected=datetime(9999, 12, 31, 23, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should add 23 hours at year 9999", - ), -] - -# Property [Unit Equivalence]: a smaller unit times its multiple equals the larger unit. -DATEADD_UNIT_EQUIVALENCE_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "millisecond_1000_equals_second_1", - doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "millisecond", "amount": 1000}}, - expected=datetime(2000, 1, 1, 12, 0, 1, tzinfo=timezone.utc), - msg="$dateAdd of 1000 milliseconds should equal adding 1 second", - ), - ExpressionTestCase( - "millisecond_precision_1ms", - doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "millisecond", "amount": 1}}, - expected=datetime(2000, 1, 1, 12, 0, 0, 1000, tzinfo=timezone.utc), - msg="$dateAdd should increment by exactly 1 millisecond", - ), - ExpressionTestCase( - "day_7_equals_week", - doc={"date": datetime(2000, 6, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 7}}, - expected=datetime(2000, 6, 8, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd of 7 days should equal adding 1 week", - ), - ExpressionTestCase( - "month_12_equals_year", - doc={"date": datetime(2000, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 12}}, - expected=datetime(2001, 6, 15, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd of 12 months should equal adding 1 year", - ), - ExpressionTestCase( - "month_3_equals_quarter", - doc={"date": datetime(2000, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": 3}}, - expected=datetime(2000, 9, 15, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd of 3 months should equal adding 1 quarter", - ), -] - -# Property [Timestamp Start Date]: a Timestamp or DatetimeMS start date is accepted and returns -# a Date. -DATEADD_TIMESTAMP_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "timestamp_startDate_day", - doc={"date": ts_from_args(2020, 12, 31, 12, 10, 5)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, - expected=datetime(2021, 1, 1, 12, 10, 5, tzinfo=timezone.utc), - msg="$dateAdd should accept a Timestamp start date and return a Date", - ), - ExpressionTestCase( - "timestamp_startDate_second", - doc={"date": ts_from_args(2000, 1, 1, 12, 0, 0)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "second", "amount": 1}}, - expected=datetime(2000, 1, 1, 12, 0, 1, tzinfo=timezone.utc), - msg="$dateAdd should add a second to a Timestamp start date", - ), - ExpressionTestCase( - "timestamp_epoch", - doc={"date": TS_EPOCH}, - expression={"$dateAdd": {"startDate": "$date", "unit": "second", "amount": 1}}, - expected=datetime(1970, 1, 1, 0, 0, 1, tzinfo=timezone.utc), - msg="$dateAdd should accept an epoch Timestamp start date", - ), - ExpressionTestCase( - "date_ms_epoch_add_day", - doc={"date": DATE_MS_EPOCH}, - expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, - expected=datetime(1970, 1, 2, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should add a day to an epoch DatetimeMS start date", - ), - ExpressionTestCase( - "ts_max_s32_add_second", - doc={"date": TS_MAX_SIGNED32}, - expression={"$dateAdd": {"startDate": "$date", "unit": "second", "amount": 1}}, - expected=datetime(2038, 1, 19, 3, 14, 8, tzinfo=timezone.utc), - msg="$dateAdd should add a second to a max signed 32-bit Timestamp", - ), - ExpressionTestCase( - "ts_max_u32_add_second", - doc={"date": TS_MAX_UNSIGNED32}, - expression={"$dateAdd": {"startDate": "$date", "unit": "second", "amount": 1}}, - expected=datetime(2106, 2, 7, 6, 28, 16, tzinfo=timezone.utc), - msg="$dateAdd should add a second to a max unsigned 32-bit Timestamp", - ), -] - -# Property [ObjectId Start Date]: an ObjectId start date uses its embedded timestamp and -# returns a Date. -DATEADD_OBJECTID_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "objectid_startDate_day", - doc={"date": oid_from_args(2022, 7, 15, 22, 32, 25)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 1}}, - expected=datetime(2023, 7, 15, 22, 32, 25, tzinfo=timezone.utc), - msg="$dateAdd should accept an ObjectId start date and return a Date", - ), - ExpressionTestCase( - "objectid_startDate_second", - doc={"date": oid_from_args(2022, 7, 15, 22, 32, 25)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "second", "amount": 1}}, - expected=datetime(2022, 7, 15, 22, 32, 26, tzinfo=timezone.utc), - msg="$dateAdd should add a second to an ObjectId start date", - ), - ExpressionTestCase( - "objectid_startDate_millisecond", - doc={"date": oid_from_args(2022, 7, 15, 22, 32, 25)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "millisecond", "amount": 500}}, - expected=datetime(2022, 7, 15, 22, 32, 25, 500000, tzinfo=timezone.utc), - msg="$dateAdd should add sub-second precision to an ObjectId start date", - ), - ExpressionTestCase( - "objectid_epoch", - doc={"date": OID_EPOCH}, - expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, - expected=datetime(1970, 1, 2, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateAdd should accept an epoch ObjectId start date", - ), - ExpressionTestCase( - "objectid_max_signed32", - doc={"date": OID_MAX_SIGNED32}, - expression={"$dateAdd": {"startDate": "$date", "unit": "second", "amount": 1}}, - expected=datetime(2038, 1, 19, 3, 14, 8, tzinfo=timezone.utc), - msg="$dateAdd should add a second to a max signed 32-bit ObjectId", - ), - ExpressionTestCase( - "objectid_high_bit_pre_epoch", - doc={"date": OID_MIN_SIGNED32}, - expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, - expected=datetime(1901, 12, 14, 20, 45, 52, tzinfo=timezone.utc), - msg="$dateAdd should handle an ObjectId with the high timestamp bit set", - ), -] - -DATEADD_DATE_SUCCESS_TESTS = ( - DATEADD_LEAP_YEAR_TESTS - + DATEADD_CENTURY_TESTS - + DATEADD_MONTH_CLAMP_TESTS - + DATEADD_BOUNDARY_TESTS - + DATEADD_HISTORICAL_TESTS - + DATEADD_EPOCH_TESTS - + DATEADD_DATE_LIMIT_TESTS - + DATEADD_UNIT_EQUIVALENCE_TESTS - + DATEADD_TIMESTAMP_TESTS - + DATEADD_OBJECTID_TESTS -) - -# Property [Overflow]: an amount that pushes the result beyond the representable date range -# is rejected. -DATEADD_OVERFLOW_ERROR_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "large_negative_month_overflow", - doc={"date": datetime(2020, 12, 31, 12, 10, 5, tzinfo=timezone.utc)}, - expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": -30_000_000_000}}, - error_code=DATEADD_INVALID_LARGE_VALUE_ERROR, - msg="$dateAdd should reject a month amount that overflows the representable date range", - ), -] - -DATEADD_DATE_TESTS: list[ExpressionTestCase] = ( - DATEADD_DATE_SUCCESS_TESTS + DATEADD_OVERFLOW_ERROR_TESTS -) - - -@pytest.mark.parametrize("test_case", pytest_params(DATEADD_DATE_TESTS)) -def test_dateAdd_dates(collection, test_case: ExpressionTestCase): - """Test $dateAdd date edge cases.""" - result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) - assert_expression_result( - result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg - ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_errors.py similarity index 61% rename from documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_types.py rename to documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_errors.py index a354a9c74..566125fe2 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_types.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_errors.py @@ -1,4 +1,4 @@ -"""$dateAdd type validation: null/missing, invalid field types, unit strings, argument shape.""" +"""$dateAdd rejection cases: invalid operand types/values, bad timezone, overflow, and shape.""" from datetime import datetime, timezone @@ -14,135 +14,91 @@ ) from documentdb_tests.framework.error_codes import ( DATEADD_INVALID_AMOUNT_ERROR, + DATEADD_INVALID_LARGE_VALUE_ERROR, DATEADD_INVALID_STARTDATE_ERROR, DATEADD_MISSING_FIELD_ERROR, DATEADD_UNKNOWN_FIELD_ERROR, FAILED_TO_PARSE_ERROR, INVALID_DATE_UNIT_ERROR, + INVALID_TIMEZONE_ERROR, + INVALID_TIMEZONE_TYPE_ERROR, ) from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import ( DECIMAL128_INFINITY, DECIMAL128_NAN, DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_ONE_AND_HALF, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEAR_MIN, FLOAT_INFINITY, FLOAT_NAN, FLOAT_NEGATIVE_INFINITY, - MISSING, ) -# Property [Null Handling]: a null literal for startDate, amount, or unit returns null. -DATEADD_NULL_TESTS: list[ExpressionTestCase] = [ +# Property [Amount Non-Integral]: a non-integral numeric amount is rejected. +DATEADD_AMOUNT_ERROR_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( - "null_startDate", - doc={"date": None}, - expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, - expected=None, - msg="$dateAdd should return null for a null startDate", - ), - ExpressionTestCase( - "null_amount", - expression={ - "$dateAdd": { - "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), - "unit": "day", - "amount": None, - } - }, - expected=None, - msg="$dateAdd should return null for a null amount", - ), - ExpressionTestCase( - "null_unit", - expression={ - "$dateAdd": { - "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), - "unit": None, - "amount": 1, - } - }, - expected=None, - msg="$dateAdd should return null for a null unit", - ), - ExpressionTestCase( - "null_startDate_zero_amount", - doc={"date": None}, - expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 0}}, - expected=None, - msg="$dateAdd should return null for a null startDate even with a zero amount", + "amount_non_integral_double_1_5", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1.5}}, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateAdd should reject a non-integral double amount", ), ExpressionTestCase( - "all_null", - expression={"$dateAdd": {"startDate": None, "unit": None, "amount": None}}, - expected=None, - msg="$dateAdd should return null when all inputs are null", + "amount_non_integral_double_5_9", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 5.9}}, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateAdd should reject a non-integral double amount close to an integer", ), -] - -# Property [Missing Field Reference]: a missing startDate, amount, unit, or timezone field -# reference returns null. -DATEADD_MISSING_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( - "missing_startDate", - expression={"$dateAdd": {"startDate": MISSING, "unit": "day", "amount": 1}}, - expected=None, - msg="$dateAdd should return null for a missing startDate field reference", + "amount_non_integral_double_negative", + doc={"date": datetime(2000, 1, 10, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": -5.9}}, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateAdd should reject a non-integral negative double amount", ), ExpressionTestCase( - "missing_amount", + "amount_non_integral_decimal128", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, expression={ - "$dateAdd": { - "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), - "unit": "day", - "amount": MISSING, - } + "$dateAdd": {"startDate": "$date", "unit": "day", "amount": DECIMAL128_ONE_AND_HALF} }, - expected=None, - msg="$dateAdd should return null for a missing amount field reference", + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateAdd should reject a non-integral decimal128 amount", ), ExpressionTestCase( - "missing_unit", + "amount_decimal128_non_integral_34th_digit", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, expression={ "$dateAdd": { - "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), - "unit": MISSING, - "amount": 1, + "startDate": "$date", + "unit": "day", + "amount": Decimal128("3.000000000000000000000000000000001"), } }, - expected=None, - msg="$dateAdd should return null for a missing unit field reference", - ), - ExpressionTestCase( - "missing_startDate_zero_amount", - expression={"$dateAdd": {"startDate": MISSING, "unit": "day", "amount": 0}}, - expected=None, - msg="$dateAdd should return null for a missing startDate field reference and zero amount", + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateAdd should reject a decimal128 amount that is non-integral at the 34th digit", ), ExpressionTestCase( - "missing_timezone", - expression={ - "$dateAdd": { - "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), - "unit": "day", - "amount": 1, - "timezone": MISSING, - } - }, - expected=None, - msg="$dateAdd should return null for a missing timezone field reference", + "amount_double_near_min", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": DOUBLE_NEAR_MIN}}, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateAdd should reject a near-minimum double amount as non-integral", ), ExpressionTestCase( - "missing_startDate_with_tz", + "amount_double_min_subnormal", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, expression={ - "$dateAdd": {"startDate": MISSING, "unit": "day", "amount": 1, "timezone": "UTC"} + "$dateAdd": {"startDate": "$date", "unit": "day", "amount": DOUBLE_MIN_SUBNORMAL} }, - expected=None, - msg="$dateAdd should return null for a missing startDate even with a timezone", + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateAdd should reject a minimum subnormal double amount as non-integral", ), ] -DATEADD_TYPE_SUCCESS_TESTS = DATEADD_NULL_TESTS + DATEADD_MISSING_TESTS - # Property [StartDate Type]: a non-date, non-Timestamp, non-ObjectId startDate is rejected. DATEADD_STARTDATE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( @@ -307,13 +263,83 @@ ] ] -DATEADD_TYPE_ERROR_TESTS = ( - DATEADD_STARTDATE_TYPE_ERROR_TESTS - + DATEADD_AMOUNT_TYPE_ERROR_TESTS - + DATEADD_AMOUNT_NONFINITE_ERROR_TESTS - + DATEADD_UNIT_TYPE_ERROR_TESTS - + DATEADD_UNIT_STRING_ERROR_TESTS -) +# Property [Invalid Timezone]: an unrecognized or malformed timezone string is rejected. +DATEADD_TIMEZONE_INVALID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"timezone_{tid}", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": {"startDate": "$date", "unit": "hour", "amount": 5, "timezone": tz} + }, + error_code=INVALID_TIMEZONE_ERROR, + msg=f"$dateAdd should reject {desc}", + ) + for tid, tz, desc in [ + ("offset_3digit_hours_invalid", "+100:00", "a 3-digit hour offset"), + ("invalid", "Invalid/Timezone", "an unrecognized Olson timezone"), + ("empty_string", "", "an empty string timezone"), + ("olson_wrong_case_lowercase", "america/new_york", "an all-lowercase Olson name"), + ("olson_wrong_case_uppercase", "AMERICA/NEW_YORK", "an all-uppercase Olson name"), + ("olson_wrong_case_mixed", "america/New_York", "a mixed-case Olson name"), + ] +] + +# Property [Timezone Type]: a non-string timezone is rejected as an invalid type. +DATEADD_TIMEZONE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"timezone_{tid}", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateAdd": {"startDate": "$date", "unit": "hour", "amount": 5, "timezone": tz} + }, + error_code=INVALID_TIMEZONE_TYPE_ERROR, + msg=f"$dateAdd should reject a {tid} timezone", + ) + for tid, tz in [ + ("number", 5), + ("boolean", True), + ("array", ["UTC"]), + ("object", {"tz": "UTC"}), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Overflow]: an amount that pushes the result beyond the representable date range +# is rejected. +DATEADD_OVERFLOW_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "large_negative_month_overflow", + doc={"date": datetime(2020, 12, 31, 12, 10, 5, tzinfo=timezone.utc)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "month", "amount": -30_000_000_000}}, + error_code=DATEADD_INVALID_LARGE_VALUE_ERROR, + msg="$dateAdd should reject a month amount that overflows the representable date range", + ), +] + +# Property [Array-Resolving Path]: a startDate field path that resolves to an array is rejected +# by the operator's startDate type contract. +DATEADD_ARRAY_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "composite_array_path", + doc={ + "a": [ + {"b": datetime(2021, 6, 15, tzinfo=timezone.utc)}, + {"b": datetime(2021, 7, 1, tzinfo=timezone.utc)}, + ] + }, + expression={"$dateAdd": {"startDate": "$a.b", "unit": "day", "amount": 1}}, + error_code=DATEADD_INVALID_STARTDATE_ERROR, + msg="$dateAdd should reject a composite array field path as startDate", + ), + ExpressionTestCase( + "single_element_array_path", + doc={"a": [{"b": datetime(2021, 6, 15, tzinfo=timezone.utc)}]}, + expression={"$dateAdd": {"startDate": "$a.b", "unit": "day", "amount": 1}}, + error_code=DATEADD_INVALID_STARTDATE_ERROR, + msg="$dateAdd should reject a single-element array field path as startDate", + ), +] # Property [Argument Shape]: a missing required field or an unknown field is rejected. DATEADD_ARGUMENT_TESTS: list[ExpressionTestCase] = [ @@ -366,14 +392,24 @@ ), ] -DATEADD_TYPE_TESTS: list[ExpressionTestCase] = ( - DATEADD_TYPE_SUCCESS_TESTS + DATEADD_TYPE_ERROR_TESTS + DATEADD_ARGUMENT_TESTS +DATEADD_ERROR_TESTS: list[ExpressionTestCase] = ( + DATEADD_AMOUNT_ERROR_TESTS + + DATEADD_STARTDATE_TYPE_ERROR_TESTS + + DATEADD_AMOUNT_TYPE_ERROR_TESTS + + DATEADD_AMOUNT_NONFINITE_ERROR_TESTS + + DATEADD_UNIT_TYPE_ERROR_TESTS + + DATEADD_UNIT_STRING_ERROR_TESTS + + DATEADD_TIMEZONE_INVALID_TESTS + + DATEADD_TIMEZONE_TYPE_ERROR_TESTS + + DATEADD_OVERFLOW_ERROR_TESTS + + DATEADD_ARRAY_PATH_TESTS + + DATEADD_ARGUMENT_TESTS ) -@pytest.mark.parametrize("test_case", pytest_params(DATEADD_TYPE_TESTS)) -def test_dateAdd_types(collection, test_case: ExpressionTestCase): - """Test $dateAdd type validation and argument handling.""" +@pytest.mark.parametrize("test_case", pytest_params(DATEADD_ERROR_TESTS)) +def test_dateAdd_errors(collection, test_case: ExpressionTestCase): + """Test $dateAdd rejects invalid operands, timezones, overflow, and argument shapes.""" result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_expressions.py index 308b83513..6a8753256 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_expressions.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_expressions.py @@ -1,9 +1,8 @@ -"""$dateAdd expression inputs, array-resolving field paths, and Date return type.""" +"""$dateAdd operand evaluation: literal operands and field-reference resolution.""" from datetime import datetime, timezone import pytest -from bson import ObjectId, Timestamp from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( ExpressionTestCase, @@ -12,7 +11,6 @@ assert_expression_result, execute_expression_with_insert, ) -from documentdb_tests.framework.error_codes import DATEADD_INVALID_STARTDATE_ERROR from documentdb_tests.framework.parametrize import pytest_params # Property [Literal Input]: $dateAdd evaluates literal operands. @@ -79,66 +77,12 @@ ), ] -# Property [Array-Resolving Path]: a startDate field path that resolves to an array is rejected -# by the operator's startDate type contract. -DATEADD_ARRAY_PATH_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "composite_array_path", - doc={ - "a": [ - {"b": datetime(2021, 6, 15, tzinfo=timezone.utc)}, - {"b": datetime(2021, 7, 1, tzinfo=timezone.utc)}, - ] - }, - expression={"$dateAdd": {"startDate": "$a.b", "unit": "day", "amount": 1}}, - error_code=DATEADD_INVALID_STARTDATE_ERROR, - msg="$dateAdd should reject a composite array field path as startDate", - ), - ExpressionTestCase( - "single_element_array_path", - doc={"a": [{"b": datetime(2021, 6, 15, tzinfo=timezone.utc)}]}, - expression={"$dateAdd": {"startDate": "$a.b", "unit": "day", "amount": 1}}, - error_code=DATEADD_INVALID_STARTDATE_ERROR, - msg="$dateAdd should reject a single-element array field path as startDate", - ), -] - -# Property [Return Type]: $dateAdd returns a Date regardless of the start date's date-like type. -DATEADD_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "return_type_from_date", - doc={"date": datetime(2021, 1, 1, tzinfo=timezone.utc)}, - expression={"$type": {"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}}, - expected="date", - msg="$dateAdd should return a Date from a Date start date", - ), - ExpressionTestCase( - "return_type_from_timestamp", - doc={"date": Timestamp(1609459200, 1)}, - expression={"$type": {"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}}, - expected="date", - msg="$dateAdd should return a Date from a Timestamp start date", - ), - ExpressionTestCase( - "return_type_from_objectid", - doc={"date": ObjectId("600000000000000000000000")}, - expression={"$type": {"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}}, - expected="date", - msg="$dateAdd should return a Date from an ObjectId start date", - ), -] - -DATEADD_EXPRESSION_TESTS: list[ExpressionTestCase] = ( - DATEADD_LITERAL_TESTS - + DATEADD_FIELD_REF_TESTS - + DATEADD_ARRAY_PATH_TESTS - + DATEADD_RETURN_TYPE_TESTS -) +DATEADD_EXPRESSION_TESTS: list[ExpressionTestCase] = DATEADD_LITERAL_TESTS + DATEADD_FIELD_REF_TESTS @pytest.mark.parametrize("test_case", pytest_params(DATEADD_EXPRESSION_TESTS)) def test_dateAdd_expressions(collection, test_case: ExpressionTestCase): - """Test $dateAdd expression inputs and return type.""" + """Test $dateAdd evaluates literal and field-reference operands.""" result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_input_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_input_types.py new file mode 100644 index 000000000..69e507585 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_input_types.py @@ -0,0 +1,160 @@ +"""$dateAdd date-like input types: Timestamp and ObjectId start dates always return a Date.""" + +from datetime import datetime, timezone + +import pytest +from bson import ObjectId, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( + oid_from_args, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_MS_EPOCH, + OID_EPOCH, + OID_MAX_SIGNED32, + OID_MIN_SIGNED32, + TS_EPOCH, + TS_MAX_SIGNED32, + TS_MAX_UNSIGNED32, +) + +# Property [Timestamp Start Date]: a Timestamp or DatetimeMS start date is accepted and returns +# a Date. +DATEADD_TIMESTAMP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "timestamp_startDate_day", + doc={"date": ts_from_args(2020, 12, 31, 12, 10, 5)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(2021, 1, 1, 12, 10, 5, tzinfo=timezone.utc), + msg="$dateAdd should accept a Timestamp start date and return a Date", + ), + ExpressionTestCase( + "timestamp_startDate_second", + doc={"date": ts_from_args(2000, 1, 1, 12, 0, 0)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2000, 1, 1, 12, 0, 1, tzinfo=timezone.utc), + msg="$dateAdd should add a second to a Timestamp start date", + ), + ExpressionTestCase( + "timestamp_epoch", + doc={"date": TS_EPOCH}, + expression={"$dateAdd": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(1970, 1, 1, 0, 0, 1, tzinfo=timezone.utc), + msg="$dateAdd should accept an epoch Timestamp start date", + ), + ExpressionTestCase( + "date_ms_epoch_add_day", + doc={"date": DATE_MS_EPOCH}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1970, 1, 2, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should add a day to an epoch DatetimeMS start date", + ), + ExpressionTestCase( + "ts_max_s32_add_second", + doc={"date": TS_MAX_SIGNED32}, + expression={"$dateAdd": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2038, 1, 19, 3, 14, 8, tzinfo=timezone.utc), + msg="$dateAdd should add a second to a max signed 32-bit Timestamp", + ), + ExpressionTestCase( + "ts_max_u32_add_second", + doc={"date": TS_MAX_UNSIGNED32}, + expression={"$dateAdd": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2106, 2, 7, 6, 28, 16, tzinfo=timezone.utc), + msg="$dateAdd should add a second to a max unsigned 32-bit Timestamp", + ), +] + +# Property [ObjectId Start Date]: an ObjectId start date uses its embedded timestamp and +# returns a Date. +DATEADD_OBJECTID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "objectid_startDate_day", + doc={"date": oid_from_args(2022, 7, 15, 22, 32, 25)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=datetime(2023, 7, 15, 22, 32, 25, tzinfo=timezone.utc), + msg="$dateAdd should accept an ObjectId start date and return a Date", + ), + ExpressionTestCase( + "objectid_startDate_second", + doc={"date": oid_from_args(2022, 7, 15, 22, 32, 25)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2022, 7, 15, 22, 32, 26, tzinfo=timezone.utc), + msg="$dateAdd should add a second to an ObjectId start date", + ), + ExpressionTestCase( + "objectid_startDate_millisecond", + doc={"date": oid_from_args(2022, 7, 15, 22, 32, 25)}, + expression={"$dateAdd": {"startDate": "$date", "unit": "millisecond", "amount": 500}}, + expected=datetime(2022, 7, 15, 22, 32, 25, 500000, tzinfo=timezone.utc), + msg="$dateAdd should add sub-second precision to an ObjectId start date", + ), + ExpressionTestCase( + "objectid_epoch", + doc={"date": OID_EPOCH}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1970, 1, 2, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should accept an epoch ObjectId start date", + ), + ExpressionTestCase( + "objectid_max_signed32", + doc={"date": OID_MAX_SIGNED32}, + expression={"$dateAdd": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2038, 1, 19, 3, 14, 8, tzinfo=timezone.utc), + msg="$dateAdd should add a second to a max signed 32-bit ObjectId", + ), + ExpressionTestCase( + "objectid_high_bit_pre_epoch", + doc={"date": OID_MIN_SIGNED32}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1901, 12, 14, 20, 45, 52, tzinfo=timezone.utc), + msg="$dateAdd should handle an ObjectId with the high timestamp bit set", + ), +] + +# Property [Return Type]: $dateAdd returns a Date regardless of the start date's date-like type. +DATEADD_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type_from_date", + doc={"date": datetime(2021, 1, 1, tzinfo=timezone.utc)}, + expression={"$type": {"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}}, + expected="date", + msg="$dateAdd should return a Date from a Date start date", + ), + ExpressionTestCase( + "return_type_from_timestamp", + doc={"date": Timestamp(1609459200, 1)}, + expression={"$type": {"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}}, + expected="date", + msg="$dateAdd should return a Date from a Timestamp start date", + ), + ExpressionTestCase( + "return_type_from_objectid", + doc={"date": ObjectId("600000000000000000000000")}, + expression={"$type": {"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}}, + expected="date", + msg="$dateAdd should return a Date from an ObjectId start date", + ), +] + +DATEADD_INPUT_TYPE_TESTS: list[ExpressionTestCase] = ( + DATEADD_TIMESTAMP_TESTS + DATEADD_OBJECTID_TESTS + DATEADD_RETURN_TYPE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEADD_INPUT_TYPE_TESTS)) +def test_dateAdd_input_types(collection, test_case: ExpressionTestCase): + """Test $dateAdd accepts date-like input types and always returns a Date.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_null.py new file mode 100644 index 000000000..1461d9bed --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_null.py @@ -0,0 +1,138 @@ +"""$dateAdd null and missing propagation: a null or missing operand returns null.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + MISSING, +) + +# Property [Null Handling]: a null literal for startDate, amount, or unit returns null. +DATEADD_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_startDate", + doc={"date": None}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=None, + msg="$dateAdd should return null for a null startDate", + ), + ExpressionTestCase( + "null_amount", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": None, + } + }, + expected=None, + msg="$dateAdd should return null for a null amount", + ), + ExpressionTestCase( + "null_unit", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": None, + "amount": 1, + } + }, + expected=None, + msg="$dateAdd should return null for a null unit", + ), + ExpressionTestCase( + "null_startDate_zero_amount", + doc={"date": None}, + expression={"$dateAdd": {"startDate": "$date", "unit": "day", "amount": 0}}, + expected=None, + msg="$dateAdd should return null for a null startDate even with a zero amount", + ), + ExpressionTestCase( + "all_null", + expression={"$dateAdd": {"startDate": None, "unit": None, "amount": None}}, + expected=None, + msg="$dateAdd should return null when all inputs are null", + ), +] + +# Property [Missing Field Reference]: a missing startDate, amount, unit, or timezone field +# reference returns null. +DATEADD_MISSING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_startDate", + expression={"$dateAdd": {"startDate": MISSING, "unit": "day", "amount": 1}}, + expected=None, + msg="$dateAdd should return null for a missing startDate field reference", + ), + ExpressionTestCase( + "missing_amount", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": MISSING, + } + }, + expected=None, + msg="$dateAdd should return null for a missing amount field reference", + ), + ExpressionTestCase( + "missing_unit", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": MISSING, + "amount": 1, + } + }, + expected=None, + msg="$dateAdd should return null for a missing unit field reference", + ), + ExpressionTestCase( + "missing_startDate_zero_amount", + expression={"$dateAdd": {"startDate": MISSING, "unit": "day", "amount": 0}}, + expected=None, + msg="$dateAdd should return null for a missing startDate field reference and zero amount", + ), + ExpressionTestCase( + "missing_timezone", + expression={ + "$dateAdd": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": 1, + "timezone": MISSING, + } + }, + expected=None, + msg="$dateAdd should return null for a missing timezone field reference", + ), + ExpressionTestCase( + "missing_startDate_with_tz", + expression={ + "$dateAdd": {"startDate": MISSING, "unit": "day", "amount": 1, "timezone": "UTC"} + }, + expected=None, + msg="$dateAdd should return null for a missing startDate even with a timezone", + ), +] + +DATEADD_NULL_MISSING_TESTS: list[ExpressionTestCase] = DATEADD_NULL_TESTS + DATEADD_MISSING_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(DATEADD_NULL_MISSING_TESTS)) +def test_dateAdd_null(collection, test_case: ExpressionTestCase): + """Test $dateAdd returns null for null literals and missing field references.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_timezone.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_timezone.py index bad17664d..cf51468db 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_timezone.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_timezone.py @@ -1,9 +1,8 @@ -"""$dateAdd timezone: Olson IDs, UTC offsets, DST, null, and invalid values and types.""" +"""$dateAdd timezone handling: Olson ids, UTC offsets, DST transitions, and field references.""" from datetime import datetime, timezone import pytest -from bson import MaxKey, MinKey from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( ExpressionTestCase, @@ -12,10 +11,6 @@ assert_expression_result, execute_expression_with_insert, ) -from documentdb_tests.framework.error_codes import ( - INVALID_TIMEZONE_ERROR, - INVALID_TIMEZONE_TYPE_ERROR, -) from documentdb_tests.framework.parametrize import pytest_params # Property [Olson Timezone]: a valid Olson timezone id is accepted. @@ -305,7 +300,7 @@ ), ] -DATEADD_TIMEZONE_SUCCESS_TESTS = ( +DATEADD_TIMEZONE_TESTS: list[ExpressionTestCase] = ( DATEADD_TIMEZONE_OLSON_TESTS + DATEADD_TIMEZONE_OFFSET_TESTS + DATEADD_TIMEZONE_DST_TESTS @@ -313,56 +308,10 @@ + DATEADD_TIMEZONE_NULL_TESTS ) -# Property [Invalid Timezone]: an unrecognized or malformed timezone string is rejected. -DATEADD_TIMEZONE_INVALID_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - f"timezone_{tid}", - doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={ - "$dateAdd": {"startDate": "$date", "unit": "hour", "amount": 5, "timezone": tz} - }, - error_code=INVALID_TIMEZONE_ERROR, - msg=f"$dateAdd should reject {desc}", - ) - for tid, tz, desc in [ - ("offset_3digit_hours_invalid", "+100:00", "a 3-digit hour offset"), - ("invalid", "Invalid/Timezone", "an unrecognized Olson timezone"), - ("empty_string", "", "an empty string timezone"), - ("olson_wrong_case_lowercase", "america/new_york", "an all-lowercase Olson name"), - ("olson_wrong_case_uppercase", "AMERICA/NEW_YORK", "an all-uppercase Olson name"), - ("olson_wrong_case_mixed", "america/New_York", "a mixed-case Olson name"), - ] -] - -# Property [Timezone Type]: a non-string timezone is rejected as an invalid type. -DATEADD_TIMEZONE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - f"timezone_{tid}", - doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={ - "$dateAdd": {"startDate": "$date", "unit": "hour", "amount": 5, "timezone": tz} - }, - error_code=INVALID_TIMEZONE_TYPE_ERROR, - msg=f"$dateAdd should reject a {tid} timezone", - ) - for tid, tz in [ - ("number", 5), - ("boolean", True), - ("array", ["UTC"]), - ("object", {"tz": "UTC"}), - ("minkey", MinKey()), - ("maxkey", MaxKey()), - ] -] - -DATEADD_TIMEZONE_ERROR_TESTS = DATEADD_TIMEZONE_INVALID_TESTS + DATEADD_TIMEZONE_TYPE_ERROR_TESTS - -DATEADD_TIMEZONE_TESTS = DATEADD_TIMEZONE_SUCCESS_TESTS + DATEADD_TIMEZONE_ERROR_TESTS - @pytest.mark.parametrize("test_case", pytest_params(DATEADD_TIMEZONE_TESTS)) def test_dateAdd_timezone(collection, test_case: ExpressionTestCase): - """Test $dateAdd timezone handling.""" + """Test $dateAdd applies the timezone operand for calendar-aware units.""" result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_basic.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_basic.py index 5b6ebbd05..5cae1776f 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_basic.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_basic.py @@ -1,9 +1,9 @@ -"""$dateDiff argument handling, valid operations, null, and invalid date and unit inputs.""" +"""$dateDiff basic behavior: valid differences, result sign, and long return type.""" from datetime import datetime, timezone import pytest -from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp +from bson import Int64 from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( ExpressionTestCase, @@ -12,21 +12,9 @@ assert_expression_result, execute_expression_with_insert, ) -from documentdb_tests.framework.error_codes import ( - DATEDIFF_MISSING_ENDDATE_ERROR, - DATEDIFF_MISSING_STARTDATE_ERROR, - DATEDIFF_MISSING_UNIT_ERROR, - DATEDIFF_NON_OBJECT_ERROR, - DATEDIFF_UNKNOWN_FIELD_ERROR, - FAILED_TO_PARSE_ERROR, - INVALID_DATE_UNIT_ERROR, - INVALID_DATE_VALUE_ERROR, -) from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import ( - DECIMAL128_INFINITY, - DECIMAL128_NEGATIVE_INFINITY, - MISSING, + INT64_ZERO, ) # Property [Valid Operation]: with valid start date, end date, and unit, the difference is @@ -86,350 +74,111 @@ ), ] -# Property [Null Handling]: a null literal for startDate, endDate, or unit returns null. -DATEDIFF_NULL_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "null_startDate", - expression={ - "$dateDiff": { - "startDate": None, - "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), - "unit": "day", - } - }, - expected=None, - msg="$dateDiff should return null for a null startDate", - ), - ExpressionTestCase( - "null_endDate", - expression={ - "$dateDiff": { - "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), - "endDate": None, - "unit": "day", - } - }, - expected=None, - msg="$dateDiff should return null for a null endDate", - ), +# Property [Sign Handling]: the difference is positive when endDate follows startDate, +# negative when it precedes, and zero when they are equal. +DATEDIFF_SIGN_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( - "null_unit", + "positive_diff", expression={ "$dateDiff": { "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), - "unit": None, - } - }, - expected=None, - msg="$dateDiff should return null for a null unit", - ), -] - -# Property [Missing Field Reference]: a missing startDate, endDate, unit, timezone, or -# startOfWeek field reference returns null. -DATEDIFF_MISSING_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "missing_startDate_ref", - expression={ - "$dateDiff": { - "startDate": MISSING, - "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), "unit": "day", } }, - expected=None, - msg="$dateDiff should return null for a missing startDate field reference", + expected=Int64(152), + msg="$dateDiff should return a positive difference when endDate follows startDate", ), ExpressionTestCase( - "missing_endDate_ref", + "negative_diff", expression={ "$dateDiff": { - "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), - "endDate": MISSING, + "startDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 1, 1, tzinfo=timezone.utc), "unit": "day", } }, - expected=None, - msg="$dateDiff should return null for a missing endDate field reference", + expected=Int64(-152), + msg="$dateDiff should return a negative difference when endDate precedes startDate", ), ExpressionTestCase( - "missing_unit_ref", + "zero_diff", expression={ "$dateDiff": { "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), - "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), - "unit": MISSING, - } - }, - expected=None, - msg="$dateDiff should return null for a missing unit field reference", - ), - ExpressionTestCase( - "missing_timezone_ref", - expression={ - "$dateDiff": { - "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), - "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 1, 1, tzinfo=timezone.utc), "unit": "day", - "timezone": MISSING, } }, - expected=None, - msg="$dateDiff should return null for a missing timezone field reference", + expected=INT64_ZERO, + msg="$dateDiff should return zero when startDate equals endDate", ), ExpressionTestCase( - "missing_startOfWeek_ref", + "negative_year", expression={ "$dateDiff": { - "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), - "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), - "unit": "week", - "startOfWeek": MISSING, + "startDate": datetime(2022, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "year", } }, - expected=None, - msg="$dateDiff should return null for a missing startOfWeek field reference", + expected=Int64(-1), + msg="$dateDiff should return a negative year difference", ), -] - -DATEDIFF_BASIC_SUCCESS_TESTS = DATEDIFF_VALID_TESTS + DATEDIFF_NULL_TESTS + DATEDIFF_MISSING_TESTS - -# Property [StartDate Type]: a non-date, non-Timestamp, non-ObjectId startDate is rejected. -DATEDIFF_STARTDATE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( - f"startDate_{tid}", + "negative_month", expression={ "$dateDiff": { - "startDate": val, - "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), - "unit": "day", + "startDate": datetime(2021, 6, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "month", } }, - error_code=INVALID_DATE_VALUE_ERROR, - msg=f"$dateDiff should reject a {tid} startDate", - ) - for tid, val in [ - ("string", "2024-01-01"), - ("int", 123), - ("int64", Int64(123)), - ("double", 1.0), - ("decimal128", Decimal128("1")), - ("boolean", True), - ("array", [2024, 1, 1]), - ("empty_array", []), - ("single_date_array", [datetime(2024, 1, 1, tzinfo=timezone.utc)]), - ("object", {"year": 2024}), - ("binary", Binary(b"\x01\x02\x03")), - ("regex", Regex(".*")), - ("javascript", Code("function() {}")), - ("minkey", MinKey()), - ("maxkey", MaxKey()), - ("decimal128_infinity", DECIMAL128_INFINITY), - ("decimal128_neg_infinity", DECIMAL128_NEGATIVE_INFINITY), - ] -] - -# Property [EndDate Type]: a non-date, non-Timestamp, non-ObjectId endDate is rejected. -DATEDIFF_ENDDATE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - f"endDate_{tid}", - expression={ - "$dateDiff": { - "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), - "endDate": val, - "unit": "day", - } - }, - error_code=INVALID_DATE_VALUE_ERROR, - msg=f"$dateDiff should reject a {tid} endDate", - ) - for tid, val in [ - ("string", "2024-06-01"), - ("int", 123), - ("int64", Int64(123)), - ("double", 1.0), - ("decimal128", Decimal128("1")), - ("boolean", True), - ("array", [2024, 6, 1]), - ("empty_array", []), - ("single_date_array", [datetime(2024, 6, 1, tzinfo=timezone.utc)]), - ("object", {"year": 2024}), - ("binary", Binary(b"\x01\x02\x03")), - ("regex", Regex(".*")), - ("javascript", Code("function() {}")), - ("minkey", MinKey()), - ("maxkey", MaxKey()), - ("decimal128_infinity", DECIMAL128_INFINITY), - ("decimal128_neg_infinity", DECIMAL128_NEGATIVE_INFINITY), - ] -] - -# Property [Unit Type]: a non-string unit is rejected as an invalid date unit. -DATEDIFF_UNIT_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - f"unit_{tid}", - expression={ - "$dateDiff": { - "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), - "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), - "unit": val, - } - }, - error_code=INVALID_DATE_UNIT_ERROR, - msg=f"$dateDiff should reject a {tid} unit", - ) - for tid, val in [ - ("number", 1), - ("int64", Int64(1)), - ("double", 1.0), - ("decimal128", Decimal128("1")), - ("boolean", True), - ("array", ["day"]), - ("object", {"t": "day"}), - ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), - ("objectid", ObjectId("600000000000000000000000")), - ("timestamp", Timestamp(1, 1)), - ("binary", Binary(b"\x01\x02\x03")), - ("regex", Regex("day")), - ("javascript", Code("function() {}")), - ("minkey", MinKey()), - ("maxkey", MaxKey()), - ] -] - -# Property [Unit String]: an unrecognized unit string, including wrong case and plurals, is -# rejected at parse time. -DATEDIFF_UNIT_STRING_ERROR_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - f"unit_{tid}", - expression={ - "$dateDiff": { - "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), - "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), - "unit": val, - } - }, - error_code=FAILED_TO_PARSE_ERROR, - msg=f"$dateDiff should reject the {desc}", - ) - for tid, val, desc in [ - ("invalid_string", "invalid", "unrecognized unit string"), - ("empty_string", "", "empty string unit"), - ("epoch_invalid", "epoch", "invalid unit epoch"), - ("mixed_case_Day", "Day", "mixed-case unit Day"), - ("mixed_case_Hour", "Hour", "mixed-case unit Hour"), - ("mixed_case_Month", "Month", "mixed-case unit Month"), - ("mixed_case_Quarter", "Quarter", "mixed-case unit Quarter"), - ("mixed_case_Week", "Week", "mixed-case unit Week"), - ("mixed_case_Second", "Second", "mixed-case unit Second"), - ("mixed_case_Minute", "Minute", "mixed-case unit Minute"), - ("mixed_case_Millisecond", "Millisecond", "mixed-case unit Millisecond"), - ("mixed_case_Year", "Year", "mixed-case unit Year"), - ("uppercase_DAY", "DAY", "uppercase unit DAY"), - ("uppercase_YEAR", "YEAR", "uppercase unit YEAR"), - ("uppercase_HOUR", "HOUR", "uppercase unit HOUR"), - ("uppercase_MONTH", "MONTH", "uppercase unit MONTH"), - ("uppercase_MILLISECOND", "MILLISECOND", "uppercase unit MILLISECOND"), - ("uppercase_QUARTER", "QUARTER", "uppercase unit QUARTER"), - ("uppercase_WEEK", "WEEK", "uppercase unit WEEK"), - ("uppercase_SECOND", "SECOND", "uppercase unit SECOND"), - ("uppercase_MINUTE", "MINUTE", "uppercase unit MINUTE"), - ("plural_years", "years", "plural unit years"), - ("plural_months", "months", "plural unit months"), - ("plural_days", "days", "plural unit days"), - ("plural_hours", "hours", "plural unit hours"), - ("plural_minutes", "minutes", "plural unit minutes"), - ("plural_seconds", "seconds", "plural unit seconds"), - ("plural_milliseconds", "milliseconds", "plural unit milliseconds"), - ("plural_weeks", "weeks", "plural unit weeks"), - ("plural_quarters", "quarters", "plural unit quarters"), - ] + expected=Int64(-5), + msg="$dateDiff should return a negative month difference", + ), ] -# Property [Argument Shape]: a missing required field, an unknown field, or a non-object -# argument is rejected. -DATEDIFF_ARGUMENT_TESTS: list[ExpressionTestCase] = [ +# Property [Return Type]: $dateDiff returns a long regardless of the unit. +DATEDIFF_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( - "arg_missing_startDate", + "return_type_day", expression={ - "$dateDiff": {"endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), "unit": "day"} - }, - error_code=DATEDIFF_MISSING_STARTDATE_ERROR, - msg="$dateDiff should error when startDate is missing", - ), - ExpressionTestCase( - "arg_missing_endDate", - expression={ - "$dateDiff": {"startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), "unit": "day"} - }, - error_code=DATEDIFF_MISSING_ENDDATE_ERROR, - msg="$dateDiff should error when endDate is missing", - ), - ExpressionTestCase( - "arg_missing_unit", - expression={ - "$dateDiff": { - "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), - "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "$type": { + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 2, tzinfo=timezone.utc), + "unit": "day", + } } }, - error_code=DATEDIFF_MISSING_UNIT_ERROR, - msg="$dateDiff should error when unit is missing", + expected="long", + msg="$dateDiff should return a long for a day difference", ), ExpressionTestCase( - "arg_empty_object", - expression={"$dateDiff": {}}, - error_code=DATEDIFF_MISSING_STARTDATE_ERROR, - msg="$dateDiff should report a missing startDate for an empty argument object", - ), - ExpressionTestCase( - "arg_unknown_field", + "return_type_millisecond", expression={ - "$dateDiff": { - "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), - "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), - "unit": "day", - "foo": 1, + "$type": { + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 2, tzinfo=timezone.utc), + "unit": "millisecond", + } } }, - error_code=DATEDIFF_UNKNOWN_FIELD_ERROR, - msg="$dateDiff should error for an unknown field", - ), - ExpressionTestCase( - "arg_non_object_string", - expression={"$dateDiff": "string"}, - error_code=DATEDIFF_NON_OBJECT_ERROR, - msg="$dateDiff should reject a string argument as a non-object", - ), - ExpressionTestCase( - "arg_non_object_array", - expression={"$dateDiff": [1, 2]}, - error_code=DATEDIFF_NON_OBJECT_ERROR, - msg="$dateDiff should reject an array argument as a non-object", - ), - ExpressionTestCase( - "arg_non_object_number", - expression={"$dateDiff": 123}, - error_code=DATEDIFF_NON_OBJECT_ERROR, - msg="$dateDiff should reject a number argument as a non-object", + expected="long", + msg="$dateDiff should return a long for a millisecond difference", ), ] -DATEDIFF_BASIC_ERROR_TESTS = ( - DATEDIFF_STARTDATE_TYPE_ERROR_TESTS - + DATEDIFF_ENDDATE_TYPE_ERROR_TESTS - + DATEDIFF_UNIT_TYPE_ERROR_TESTS - + DATEDIFF_UNIT_STRING_ERROR_TESTS - + DATEDIFF_ARGUMENT_TESTS +DATEDIFF_BASIC_TESTS: list[ExpressionTestCase] = ( + DATEDIFF_VALID_TESTS + DATEDIFF_SIGN_TESTS + DATEDIFF_RETURN_TYPE_TESTS ) -DATEDIFF_BASIC_TESTS = DATEDIFF_BASIC_SUCCESS_TESTS + DATEDIFF_BASIC_ERROR_TESTS - @pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_BASIC_TESTS)) def test_dateDiff_basic(collection, test_case: ExpressionTestCase): - """Test $dateDiff argument handling and basic operations.""" + """Test $dateDiff produces the correct value, sign, and long return type.""" result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_dates.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_counting.py similarity index 63% rename from documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_dates.py rename to documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_counting.py index b0a2cd441..a14765805 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_dates.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_counting.py @@ -1,4 +1,4 @@ -"""$dateDiff sign handling, unit boundary counting, quarter counting, and week counting.""" +"""$dateDiff boundary counting: unit boundaries crossed, quarters, and leap-year day counts.""" from datetime import datetime, timezone @@ -15,71 +15,6 @@ from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import DATE_YEAR_1, INT64_ZERO -# Property [Sign Handling]: the difference is positive when endDate follows startDate, -# negative when it precedes, and zero when they are equal. -DATEDIFF_SIGN_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "positive_diff", - expression={ - "$dateDiff": { - "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), - "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), - "unit": "day", - } - }, - expected=Int64(152), - msg="$dateDiff should return a positive difference when endDate follows startDate", - ), - ExpressionTestCase( - "negative_diff", - expression={ - "$dateDiff": { - "startDate": datetime(2024, 6, 1, tzinfo=timezone.utc), - "endDate": datetime(2024, 1, 1, tzinfo=timezone.utc), - "unit": "day", - } - }, - expected=Int64(-152), - msg="$dateDiff should return a negative difference when endDate precedes startDate", - ), - ExpressionTestCase( - "zero_diff", - expression={ - "$dateDiff": { - "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), - "endDate": datetime(2024, 1, 1, tzinfo=timezone.utc), - "unit": "day", - } - }, - expected=INT64_ZERO, - msg="$dateDiff should return zero when startDate equals endDate", - ), - ExpressionTestCase( - "negative_year", - expression={ - "$dateDiff": { - "startDate": datetime(2022, 1, 1, tzinfo=timezone.utc), - "endDate": datetime(2021, 1, 1, tzinfo=timezone.utc), - "unit": "year", - } - }, - expected=Int64(-1), - msg="$dateDiff should return a negative year difference", - ), - ExpressionTestCase( - "negative_month", - expression={ - "$dateDiff": { - "startDate": datetime(2021, 6, 1, tzinfo=timezone.utc), - "endDate": datetime(2021, 1, 1, tzinfo=timezone.utc), - "unit": "month", - } - }, - expected=Int64(-5), - msg="$dateDiff should return a negative month difference", - ), -] - # Property [Boundary Counting]: the difference counts unit boundaries crossed, not elapsed # whole units, so sub-unit remainders do not change the count. DATEDIFF_BOUNDARY_TESTS: list[ExpressionTestCase] = [ @@ -365,157 +300,90 @@ ), ] -# Property [Week Counting]: the difference counts week boundaries crossed relative to -# startOfWeek, defaulting to Sunday. -DATEDIFF_WEEK_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "jan_default_sun", - expression={ - "$dateDiff": { - "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), - "endDate": datetime(2021, 1, 31, tzinfo=timezone.utc), - "unit": "week", - } - }, - expected=Int64(5), - msg="$dateDiff should count January weeks from the default Sunday start", - ), +# Property [Leap Year]: day counting honors leap years, including the century leap rule. +DATEDIFF_LEAP_YEAR_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( - "jan_monday", + "leap_feb28_mar1", expression={ "$dateDiff": { - "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), - "endDate": datetime(2021, 1, 31, tzinfo=timezone.utc), - "unit": "week", - "startOfWeek": "monday", - } - }, - expected=Int64(4), - msg="$dateDiff should count January weeks from a Monday start", - ), - ExpressionTestCase( - "jan_friday", - expression={ - "$dateDiff": { - "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), - "endDate": datetime(2021, 1, 31, tzinfo=timezone.utc), - "unit": "week", - "startOfWeek": "fri", - } - }, - expected=Int64(4), - msg="$dateDiff should count January weeks from a Friday start", - ), - ExpressionTestCase( - "feb_default_sun", - expression={ - "$dateDiff": { - "startDate": datetime(2021, 2, 1, tzinfo=timezone.utc), - "endDate": datetime(2021, 2, 28, tzinfo=timezone.utc), - "unit": "week", - } - }, - expected=Int64(4), - msg="$dateDiff should count February weeks from the default Sunday start", - ), - ExpressionTestCase( - "feb_monday", - expression={ - "$dateDiff": { - "startDate": datetime(2021, 2, 1, tzinfo=timezone.utc), - "endDate": datetime(2021, 2, 28, tzinfo=timezone.utc), - "unit": "week", - "startOfWeek": "monday", + "startDate": datetime(2020, 2, 28, tzinfo=timezone.utc), + "endDate": datetime(2020, 3, 1, tzinfo=timezone.utc), + "unit": "day", } }, - expected=Int64(3), - msg="$dateDiff should count February weeks from a Monday start", + expected=Int64(2), + msg="$dateDiff should count two days across Feb 29 in a leap year", ), ExpressionTestCase( - "feb_friday", + "non_leap_feb28_mar1", expression={ "$dateDiff": { - "startDate": datetime(2021, 2, 1, tzinfo=timezone.utc), - "endDate": datetime(2021, 2, 28, tzinfo=timezone.utc), - "unit": "week", - "startOfWeek": "fri", + "startDate": datetime(2021, 2, 28, tzinfo=timezone.utc), + "endDate": datetime(2021, 3, 1, tzinfo=timezone.utc), + "unit": "day", } }, - expected=Int64(4), - msg="$dateDiff should count February weeks from a Friday start", + expected=Int64(1), + msg="$dateDiff should count one day from Feb 28 in a non-leap year", ), ExpressionTestCase( - "mar_default_sun", + "full_leap_year", expression={ "$dateDiff": { - "startDate": datetime(2021, 3, 1, tzinfo=timezone.utc), - "endDate": datetime(2021, 3, 31, tzinfo=timezone.utc), - "unit": "week", + "startDate": datetime(2020, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", } }, - expected=Int64(4), - msg="$dateDiff should count March weeks from the default Sunday start", + expected=Int64(366), + msg="$dateDiff should count 366 days across a full leap year", ), ExpressionTestCase( - "mar_monday", + "full_non_leap_year", expression={ "$dateDiff": { - "startDate": datetime(2021, 3, 1, tzinfo=timezone.utc), - "endDate": datetime(2021, 3, 31, tzinfo=timezone.utc), - "unit": "week", - "startOfWeek": "monday", + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2022, 1, 1, tzinfo=timezone.utc), + "unit": "day", } }, - expected=Int64(4), - msg="$dateDiff should count March weeks from a Monday start", + expected=Int64(365), + msg="$dateDiff should count 365 days across a full non-leap year", ), ExpressionTestCase( - "mar_friday", + "century_leap_2000", expression={ "$dateDiff": { - "startDate": datetime(2021, 3, 1, tzinfo=timezone.utc), - "endDate": datetime(2021, 3, 31, tzinfo=timezone.utc), - "unit": "week", - "startOfWeek": "fri", + "startDate": datetime(2000, 2, 28, tzinfo=timezone.utc), + "endDate": datetime(2000, 3, 1, tzinfo=timezone.utc), + "unit": "day", } }, - expected=Int64(4), - msg="$dateDiff should count March weeks from a Friday start", + expected=Int64(2), + msg="$dateDiff should treat 2000 as a leap century year", ), ExpressionTestCase( - "cross_year_week", + "century_non_leap_1900", expression={ "$dateDiff": { - "startDate": datetime(2020, 12, 28, tzinfo=timezone.utc), - "endDate": datetime(2021, 1, 4, tzinfo=timezone.utc), - "unit": "week", + "startDate": datetime(1900, 2, 28, tzinfo=timezone.utc), + "endDate": datetime(1900, 3, 1, tzinfo=timezone.utc), + "unit": "day", } }, expected=Int64(1), - msg="$dateDiff should count one week across the year boundary", - ), - ExpressionTestCase( - "cross_month_week", - expression={ - "$dateDiff": { - "startDate": datetime(2021, 1, 25, tzinfo=timezone.utc), - "endDate": datetime(2021, 2, 8, tzinfo=timezone.utc), - "unit": "week", - } - }, - expected=Int64(2), - msg="$dateDiff should count two weeks across the month boundary", + msg="$dateDiff should treat 1900 as a non-leap century year", ), ] -DATEDIFF_DATE_TESTS = ( - DATEDIFF_SIGN_TESTS + DATEDIFF_BOUNDARY_TESTS + DATEDIFF_QUARTER_TESTS + DATEDIFF_WEEK_TESTS +DATEDIFF_COUNTING_TESTS: list[ExpressionTestCase] = ( + DATEDIFF_BOUNDARY_TESTS + DATEDIFF_QUARTER_TESTS + DATEDIFF_LEAP_YEAR_TESTS ) -@pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_DATE_TESTS)) -def test_dateDiff_dates(collection, test_case: ExpressionTestCase): - """Test $dateDiff sign handling and unit boundary counting.""" +@pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_COUNTING_TESTS)) +def test_dateDiff_counting(collection, test_case: ExpressionTestCase): + """Test $dateDiff counts unit boundaries crossed, including quarters and leap years.""" result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_errors.py new file mode 100644 index 000000000..bb2d414f4 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_errors.py @@ -0,0 +1,438 @@ +"""$dateDiff rejection cases: invalid operand types, unit, timezone, startOfWeek, and shape.""" + +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.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + DATEDIFF_MISSING_ENDDATE_ERROR, + DATEDIFF_MISSING_STARTDATE_ERROR, + DATEDIFF_MISSING_UNIT_ERROR, + DATEDIFF_NON_OBJECT_ERROR, + DATEDIFF_UNKNOWN_FIELD_ERROR, + FAILED_TO_PARSE_ERROR, + INVALID_DATE_UNIT_ERROR, + INVALID_DATE_VALUE_ERROR, + INVALID_STARTOFWEEK_ERROR, + INVALID_STARTOFWEEK_TYPE_ERROR, + INVALID_TIMEZONE_ERROR, + INVALID_TIMEZONE_TYPE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NEGATIVE_INFINITY, +) + +# Property [StartDate Type]: a non-date, non-Timestamp, non-ObjectId startDate is rejected. +DATEDIFF_STARTDATE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"startDate_{tid}", + expression={ + "$dateDiff": { + "startDate": val, + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + error_code=INVALID_DATE_VALUE_ERROR, + msg=f"$dateDiff should reject a {tid} startDate", + ) + for tid, val in [ + ("string", "2024-01-01"), + ("int", 123), + ("int64", Int64(123)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", [2024, 1, 1]), + ("empty_array", []), + ("single_date_array", [datetime(2024, 1, 1, tzinfo=timezone.utc)]), + ("object", {"year": 2024}), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128_infinity", DECIMAL128_INFINITY), + ("decimal128_neg_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + +# Property [EndDate Type]: a non-date, non-Timestamp, non-ObjectId endDate is rejected. +DATEDIFF_ENDDATE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"endDate_{tid}", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": val, + "unit": "day", + } + }, + error_code=INVALID_DATE_VALUE_ERROR, + msg=f"$dateDiff should reject a {tid} endDate", + ) + for tid, val in [ + ("string", "2024-06-01"), + ("int", 123), + ("int64", Int64(123)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", [2024, 6, 1]), + ("empty_array", []), + ("single_date_array", [datetime(2024, 6, 1, tzinfo=timezone.utc)]), + ("object", {"year": 2024}), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("decimal128_infinity", DECIMAL128_INFINITY), + ("decimal128_neg_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + +# Property [Unit Type]: a non-string unit is rejected as an invalid date unit. +DATEDIFF_UNIT_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"unit_{tid}", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": val, + } + }, + error_code=INVALID_DATE_UNIT_ERROR, + msg=f"$dateDiff should reject a {tid} unit", + ) + for tid, val in [ + ("number", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", ["day"]), + ("object", {"t": "day"}), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex("day")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Unit String]: an unrecognized unit string, including wrong case and plurals, is +# rejected at parse time. +DATEDIFF_UNIT_STRING_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"unit_{tid}", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": val, + } + }, + error_code=FAILED_TO_PARSE_ERROR, + msg=f"$dateDiff should reject the {desc}", + ) + for tid, val, desc in [ + ("invalid_string", "invalid", "unrecognized unit string"), + ("empty_string", "", "empty string unit"), + ("epoch_invalid", "epoch", "invalid unit epoch"), + ("mixed_case_Day", "Day", "mixed-case unit Day"), + ("mixed_case_Hour", "Hour", "mixed-case unit Hour"), + ("mixed_case_Month", "Month", "mixed-case unit Month"), + ("mixed_case_Quarter", "Quarter", "mixed-case unit Quarter"), + ("mixed_case_Week", "Week", "mixed-case unit Week"), + ("mixed_case_Second", "Second", "mixed-case unit Second"), + ("mixed_case_Minute", "Minute", "mixed-case unit Minute"), + ("mixed_case_Millisecond", "Millisecond", "mixed-case unit Millisecond"), + ("mixed_case_Year", "Year", "mixed-case unit Year"), + ("uppercase_DAY", "DAY", "uppercase unit DAY"), + ("uppercase_YEAR", "YEAR", "uppercase unit YEAR"), + ("uppercase_HOUR", "HOUR", "uppercase unit HOUR"), + ("uppercase_MONTH", "MONTH", "uppercase unit MONTH"), + ("uppercase_MILLISECOND", "MILLISECOND", "uppercase unit MILLISECOND"), + ("uppercase_QUARTER", "QUARTER", "uppercase unit QUARTER"), + ("uppercase_WEEK", "WEEK", "uppercase unit WEEK"), + ("uppercase_SECOND", "SECOND", "uppercase unit SECOND"), + ("uppercase_MINUTE", "MINUTE", "uppercase unit MINUTE"), + ("plural_years", "years", "plural unit years"), + ("plural_months", "months", "plural unit months"), + ("plural_days", "days", "plural unit days"), + ("plural_hours", "hours", "plural unit hours"), + ("plural_minutes", "minutes", "plural unit minutes"), + ("plural_seconds", "seconds", "plural unit seconds"), + ("plural_milliseconds", "milliseconds", "plural unit milliseconds"), + ("plural_weeks", "weeks", "plural unit weeks"), + ("plural_quarters", "quarters", "plural unit quarters"), + ] +] + +# Property [Array-Resolving Path]: a field path that resolves to an array is delivered to the +# operator, which rejects it under its date type contract. +DATEDIFF_ARRAY_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "composite_array_path", + doc={ + "a": [ + {"b": datetime(2021, 1, 1, tzinfo=timezone.utc)}, + {"b": datetime(2021, 6, 1, tzinfo=timezone.utc)}, + ] + }, + expression={ + "$dateDiff": { + "startDate": "$a.b", + "endDate": datetime(2021, 1, 2, tzinfo=timezone.utc), + "unit": "day", + } + }, + error_code=INVALID_DATE_VALUE_ERROR, + msg="$dateDiff should reject a composite array field path as startDate", + ), + ExpressionTestCase( + "single_element_array_path", + doc={"a": [{"b": datetime(2021, 6, 15, tzinfo=timezone.utc)}]}, + expression={ + "$dateDiff": { + "startDate": "$a.b", + "endDate": datetime(2021, 6, 16, tzinfo=timezone.utc), + "unit": "day", + } + }, + error_code=INVALID_DATE_VALUE_ERROR, + msg="$dateDiff should reject a single-element array field path as startDate", + ), + ExpressionTestCase( + "array_index_path", + doc={"a": [{"b": datetime(2021, 1, 1, tzinfo=timezone.utc)}]}, + expression={ + "$dateDiff": { + "startDate": "$a.0.b", + "endDate": datetime(2021, 1, 2, tzinfo=timezone.utc), + "unit": "day", + } + }, + error_code=INVALID_DATE_VALUE_ERROR, + msg="$dateDiff should reject an array-index field path resolving to an array as startDate", + ), +] + +# Property [Invalid Timezone]: an unrecognized or malformed timezone string is rejected. +DATEDIFF_TIMEZONE_INVALID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"tz_{tid}", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": "day", + "timezone": tz, + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg=f"$dateDiff should reject {desc}", + ) + for tid, tz, desc in [ + ("offset_3digit_hours", "+100:00", "a 3-digit hour offset"), + ("invalid_string", "NotATimezone", "an unrecognized Olson timezone"), + ("empty_string", "", "an empty string timezone"), + ("olson_wrong_case_lowercase", "america/new_york", "an all-lowercase Olson name"), + ("olson_wrong_case_uppercase", "AMERICA/NEW_YORK", "an all-uppercase Olson name"), + ] +] + +# Property [Timezone Type]: a non-string timezone is rejected as an invalid type. +DATEDIFF_TIMEZONE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"tz_{tid}", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 2, tzinfo=timezone.utc), + "unit": "day", + "timezone": val, + } + }, + error_code=INVALID_TIMEZONE_TYPE_ERROR, + msg=f"$dateDiff should reject a {tid} timezone", + ) + for tid, val in [ + ("number", 5), + ("int64", Int64(5)), + ("double", 5.0), + ("decimal128", Decimal128("5")), + ("boolean", True), + ("array", ["UTC"]), + ("object", {"tz": "UTC"}), + ("datetime", datetime(2021, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex("UTC")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Invalid StartOfWeek]: an unrecognized startOfWeek string is rejected. +DATEDIFF_STARTOFWEEK_INVALID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"sow_{tid}", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 1, 31, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": sow, + } + }, + error_code=INVALID_STARTOFWEEK_ERROR, + msg=f"$dateDiff should reject {desc}", + ) + for tid, sow, desc in [ + ("invalid_string", "notaday", "an unrecognized startOfWeek string"), + ("empty_string", "", "an empty startOfWeek string"), + ] +] + +# Property [StartOfWeek Type]: a non-string startOfWeek is rejected as an invalid type. +DATEDIFF_STARTOFWEEK_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"sow_{tid}", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 1, 31, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": val, + } + }, + error_code=INVALID_STARTOFWEEK_TYPE_ERROR, + msg=f"$dateDiff should reject a {tid} startOfWeek", + ) + for tid, val in [ + ("number", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", ["monday"]), + ("object", {"day": "monday"}), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex("monday")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Argument Shape]: a missing required field, an unknown field, or a non-object +# argument is rejected. +DATEDIFF_ARGUMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arg_missing_startDate", + expression={ + "$dateDiff": {"endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), "unit": "day"} + }, + error_code=DATEDIFF_MISSING_STARTDATE_ERROR, + msg="$dateDiff should error when startDate is missing", + ), + ExpressionTestCase( + "arg_missing_endDate", + expression={ + "$dateDiff": {"startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), "unit": "day"} + }, + error_code=DATEDIFF_MISSING_ENDDATE_ERROR, + msg="$dateDiff should error when endDate is missing", + ), + ExpressionTestCase( + "arg_missing_unit", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + } + }, + error_code=DATEDIFF_MISSING_UNIT_ERROR, + msg="$dateDiff should error when unit is missing", + ), + ExpressionTestCase( + "arg_empty_object", + expression={"$dateDiff": {}}, + error_code=DATEDIFF_MISSING_STARTDATE_ERROR, + msg="$dateDiff should report a missing startDate for an empty argument object", + ), + ExpressionTestCase( + "arg_unknown_field", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": "day", + "foo": 1, + } + }, + error_code=DATEDIFF_UNKNOWN_FIELD_ERROR, + msg="$dateDiff should error for an unknown field", + ), + ExpressionTestCase( + "arg_non_object_string", + expression={"$dateDiff": "string"}, + error_code=DATEDIFF_NON_OBJECT_ERROR, + msg="$dateDiff should reject a string argument as a non-object", + ), + ExpressionTestCase( + "arg_non_object_array", + expression={"$dateDiff": [1, 2]}, + error_code=DATEDIFF_NON_OBJECT_ERROR, + msg="$dateDiff should reject an array argument as a non-object", + ), + ExpressionTestCase( + "arg_non_object_number", + expression={"$dateDiff": 123}, + error_code=DATEDIFF_NON_OBJECT_ERROR, + msg="$dateDiff should reject a number argument as a non-object", + ), +] + +DATEDIFF_ERROR_TESTS: list[ExpressionTestCase] = ( + DATEDIFF_STARTDATE_TYPE_ERROR_TESTS + + DATEDIFF_ENDDATE_TYPE_ERROR_TESTS + + DATEDIFF_UNIT_TYPE_ERROR_TESTS + + DATEDIFF_UNIT_STRING_ERROR_TESTS + + DATEDIFF_ARRAY_PATH_TESTS + + DATEDIFF_TIMEZONE_INVALID_TESTS + + DATEDIFF_TIMEZONE_TYPE_ERROR_TESTS + + DATEDIFF_STARTOFWEEK_INVALID_TESTS + + DATEDIFF_STARTOFWEEK_TYPE_ERROR_TESTS + + DATEDIFF_ARGUMENT_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_ERROR_TESTS)) +def test_dateDiff_errors(collection, test_case: ExpressionTestCase): + """Test $dateDiff rejects invalid operand types, timezones, startOfWeek, and shapes.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_expressions.py index f2a9ec535..f667b17d8 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_expressions.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_expressions.py @@ -1,4 +1,4 @@ -"""$dateDiff field-reference and expression operands, array-resolving paths, and return type.""" +"""$dateDiff operand evaluation: startDate, endDate, unit, and timezone from field references.""" from datetime import datetime, timezone @@ -12,7 +12,6 @@ assert_expression_result, execute_expression_with_insert, ) -from documentdb_tests.framework.error_codes import INVALID_DATE_VALUE_ERROR from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import INT64_ZERO @@ -82,103 +81,10 @@ ), ] -# Property [Array-Resolving Path]: a field path that resolves to an array is delivered to the -# operator, which rejects it under its date type contract. -DATEDIFF_ARRAY_PATH_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "composite_array_path", - doc={ - "a": [ - {"b": datetime(2021, 1, 1, tzinfo=timezone.utc)}, - {"b": datetime(2021, 6, 1, tzinfo=timezone.utc)}, - ] - }, - expression={ - "$dateDiff": { - "startDate": "$a.b", - "endDate": datetime(2021, 1, 2, tzinfo=timezone.utc), - "unit": "day", - } - }, - error_code=INVALID_DATE_VALUE_ERROR, - msg="$dateDiff should reject a composite array field path as startDate", - ), - ExpressionTestCase( - "single_element_array_path", - doc={"a": [{"b": datetime(2021, 6, 15, tzinfo=timezone.utc)}]}, - expression={ - "$dateDiff": { - "startDate": "$a.b", - "endDate": datetime(2021, 6, 16, tzinfo=timezone.utc), - "unit": "day", - } - }, - error_code=INVALID_DATE_VALUE_ERROR, - msg="$dateDiff should reject a single-element array field path as startDate", - ), - ExpressionTestCase( - "array_index_path", - doc={"a": [{"b": datetime(2021, 1, 1, tzinfo=timezone.utc)}]}, - expression={ - "$dateDiff": { - "startDate": "$a.0.b", - "endDate": datetime(2021, 1, 2, tzinfo=timezone.utc), - "unit": "day", - } - }, - error_code=INVALID_DATE_VALUE_ERROR, - msg="$dateDiff should reject an array-index field path resolving to an array as startDate", - ), -] -DATEDIFF_EXPRESSION_TESTS = DATEDIFF_FIELD_REF_TESTS + DATEDIFF_ARRAY_PATH_TESTS - - -@pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_EXPRESSION_TESTS)) +@pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_FIELD_REF_TESTS)) def test_dateDiff_expressions(collection, test_case: ExpressionTestCase): - """Test $dateDiff field-reference and expression operands.""" - result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) - assert_expression_result( - result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg - ) - - -# Property [Return Type]: $dateDiff returns a long regardless of the unit. -DATEDIFF_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "return_type_day", - expression={ - "$type": { - "$dateDiff": { - "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), - "endDate": datetime(2021, 1, 2, tzinfo=timezone.utc), - "unit": "day", - } - } - }, - expected="long", - msg="$dateDiff should return a long for a day difference", - ), - ExpressionTestCase( - "return_type_millisecond", - expression={ - "$type": { - "$dateDiff": { - "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), - "endDate": datetime(2021, 1, 2, tzinfo=timezone.utc), - "unit": "millisecond", - } - } - }, - expected="long", - msg="$dateDiff should return a long for a millisecond difference", - ), -] - - -@pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_RETURN_TYPE_TESTS)) -def test_dateDiff_return_type(collection, test_case: ExpressionTestCase): - """Test $dateDiff return type.""" + """Test $dateDiff resolves its operands from field references.""" result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_input_types.py similarity index 52% rename from documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_types.py rename to documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_input_types.py index ad6f75448..4ae0d0a59 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_types.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_input_types.py @@ -1,4 +1,4 @@ -"""$dateDiff leap years, ObjectId/Timestamp/DatetimeMS date sources, and extreme boundaries.""" +"""$dateDiff date-like operand types: ObjectId, Timestamp, and mixed sources.""" from datetime import datetime, timezone @@ -18,95 +18,9 @@ ) from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import ( - DATE_EPOCH, - DATE_MS_BEFORE_EPOCH, - DATE_MS_EPOCH, - DATE_MS_MAX, - DATE_MS_MIN, - DATE_YEAR_1900, INT64_ZERO, - OID_MAX_SIGNED32, - OID_MIN_SIGNED32, - TS_MAX_SIGNED32, - TS_MAX_UNSIGNED32, ) -# Property [Leap Year]: day counting honors leap years, including the century leap rule. -DATEDIFF_LEAP_YEAR_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "leap_feb28_mar1", - expression={ - "$dateDiff": { - "startDate": datetime(2020, 2, 28, tzinfo=timezone.utc), - "endDate": datetime(2020, 3, 1, tzinfo=timezone.utc), - "unit": "day", - } - }, - expected=Int64(2), - msg="$dateDiff should count two days across Feb 29 in a leap year", - ), - ExpressionTestCase( - "non_leap_feb28_mar1", - expression={ - "$dateDiff": { - "startDate": datetime(2021, 2, 28, tzinfo=timezone.utc), - "endDate": datetime(2021, 3, 1, tzinfo=timezone.utc), - "unit": "day", - } - }, - expected=Int64(1), - msg="$dateDiff should count one day from Feb 28 in a non-leap year", - ), - ExpressionTestCase( - "full_leap_year", - expression={ - "$dateDiff": { - "startDate": datetime(2020, 1, 1, tzinfo=timezone.utc), - "endDate": datetime(2021, 1, 1, tzinfo=timezone.utc), - "unit": "day", - } - }, - expected=Int64(366), - msg="$dateDiff should count 366 days across a full leap year", - ), - ExpressionTestCase( - "full_non_leap_year", - expression={ - "$dateDiff": { - "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), - "endDate": datetime(2022, 1, 1, tzinfo=timezone.utc), - "unit": "day", - } - }, - expected=Int64(365), - msg="$dateDiff should count 365 days across a full non-leap year", - ), - ExpressionTestCase( - "century_leap_2000", - expression={ - "$dateDiff": { - "startDate": datetime(2000, 2, 28, tzinfo=timezone.utc), - "endDate": datetime(2000, 3, 1, tzinfo=timezone.utc), - "unit": "day", - } - }, - expected=Int64(2), - msg="$dateDiff should treat 2000 as a leap century year", - ), - ExpressionTestCase( - "century_non_leap_1900", - expression={ - "$dateDiff": { - "startDate": datetime(1900, 2, 28, tzinfo=timezone.utc), - "endDate": datetime(1900, 3, 1, tzinfo=timezone.utc), - "unit": "day", - } - }, - expected=Int64(1), - msg="$dateDiff should treat 1900 as a non-leap century year", - ), -] - # Property [ObjectId Source]: an ObjectId date operand uses its embedded timestamp for either # position and for the timezone option. DATEDIFF_OBJECTID_TESTS: list[ExpressionTestCase] = [ @@ -306,144 +220,14 @@ ), ] -# Property [Epoch Crossing]: dates around the Unix epoch and far from it are counted correctly. -DATEDIFF_EPOCH_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "epoch_date", - doc={"s": DATE_EPOCH}, - expression={ - "$dateDiff": { - "startDate": "$s", - "endDate": datetime(1970, 1, 2, tzinfo=timezone.utc), - "unit": "day", - } - }, - expected=Int64(1), - msg="$dateDiff should count one day from the epoch", - ), - ExpressionTestCase( - "pre_epoch", - expression={ - "$dateDiff": { - "startDate": datetime(1969, 12, 31, tzinfo=timezone.utc), - "endDate": DATE_EPOCH, - "unit": "day", - } - }, - expected=Int64(1), - msg="$dateDiff should count one day across the epoch from 1969", - ), - ExpressionTestCase( - "distant_past", - doc={"s": DATE_YEAR_1900}, - expression={ - "$dateDiff": { - "startDate": "$s", - "endDate": datetime(1900, 12, 31, tzinfo=timezone.utc), - "unit": "day", - } - }, - expected=Int64(364), - msg="$dateDiff should count days within a distant past year", - ), - ExpressionTestCase( - "distant_future", - expression={ - "$dateDiff": { - "startDate": datetime(2100, 1, 1, tzinfo=timezone.utc), - "endDate": datetime(2100, 12, 31, tzinfo=timezone.utc), - "unit": "day", - } - }, - expected=Int64(364), - msg="$dateDiff should count days within a distant future year", - ), - ExpressionTestCase( - "cross_epoch", - expression={ - "$dateDiff": { - "startDate": datetime(1969, 6, 1, tzinfo=timezone.utc), - "endDate": datetime(1970, 6, 1, tzinfo=timezone.utc), - "unit": "year", - } - }, - expected=Int64(1), - msg="$dateDiff should count one year crossing the epoch", - ), -] - -# Property [Extreme Boundary]: DatetimeMS, Timestamp, and ObjectId values at the edges of the -# 32-bit and 64-bit ranges are counted correctly. -DATEDIFF_EXTREME_BOUNDARY_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "date_ms_epoch_diff", - doc={"s": DATE_MS_EPOCH}, - expression={ - "$dateDiff": { - "startDate": "$s", - "endDate": datetime(1970, 1, 2, tzinfo=timezone.utc), - "unit": "day", - } - }, - expected=Int64(1), - msg="$dateDiff should count one day from an epoch DatetimeMS", - ), - ExpressionTestCase( - "date_ms_before_epoch_diff", - doc={"s": DATE_MS_BEFORE_EPOCH}, - expression={"$dateDiff": {"startDate": "$s", "endDate": DATE_EPOCH, "unit": "millisecond"}}, - expected=Int64(1), - msg="$dateDiff should count one millisecond from just before the epoch", - ), - ExpressionTestCase( - "ts_boundary_max_s32", - doc={"s": TS_MAX_SIGNED32, "e": TS_MAX_UNSIGNED32}, - expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "year"}}, - expected=Int64(68), - msg="$dateDiff should count years between the signed and unsigned 32-bit Timestamp limits", - ), - ExpressionTestCase( - "oid_max_signed32", - doc={"s": OID_MAX_SIGNED32}, - expression={ - "$dateDiff": { - "startDate": "$s", - "endDate": datetime(2038, 1, 20, tzinfo=timezone.utc), - "unit": "day", - } - }, - expected=Int64(1), - msg="$dateDiff should count from a max signed 32-bit ObjectId", - ), - ExpressionTestCase( - "oid_high_bit_pre_epoch", - doc={"s": OID_MIN_SIGNED32}, - expression={"$dateDiff": {"startDate": "$s", "endDate": DATE_EPOCH, "unit": "year"}}, - expected=Int64(69), - msg="$dateDiff should handle an ObjectId with the high timestamp bit set as pre-epoch", - ), - ExpressionTestCase( - "ms_overflow_int64", - doc={"s": DATE_MS_MIN, "e": DATE_MS_MAX}, - expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "year"}}, - expected=Int64(584_554_049), - msg="$dateDiff should count years between the minimum and maximum representable dates", - ), -] - -DATEDIFF_TYPE_TESTS = ( - DATEDIFF_LEAP_YEAR_TESTS - + DATEDIFF_OBJECTID_TESTS - + DATEDIFF_TIMESTAMP_TESTS - + DATEDIFF_MIXED_SOURCE_TESTS - + DATEDIFF_EPOCH_TESTS - + DATEDIFF_EXTREME_BOUNDARY_TESTS +DATEDIFF_INPUT_TYPE_TESTS: list[ExpressionTestCase] = ( + DATEDIFF_OBJECTID_TESTS + DATEDIFF_TIMESTAMP_TESTS + DATEDIFF_MIXED_SOURCE_TESTS ) -@pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_TYPE_TESTS)) -def test_dateDiff_types(collection, test_case: ExpressionTestCase): - """Test $dateDiff date-source types and boundaries.""" +@pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_INPUT_TYPE_TESTS)) +def test_dateDiff_input_types(collection, test_case: ExpressionTestCase): + """Test $dateDiff accepts ObjectId, Timestamp, and mixed date-like operands.""" result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_null.py new file mode 100644 index 000000000..462b483f3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_null.py @@ -0,0 +1,135 @@ +"""$dateDiff null and missing propagation: a null or missing operand returns null.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + MISSING, +) + +# Property [Null Handling]: a null literal for startDate, endDate, or unit returns null. +DATEDIFF_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_startDate", + expression={ + "$dateDiff": { + "startDate": None, + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=None, + msg="$dateDiff should return null for a null startDate", + ), + ExpressionTestCase( + "null_endDate", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": None, + "unit": "day", + } + }, + expected=None, + msg="$dateDiff should return null for a null endDate", + ), + ExpressionTestCase( + "null_unit", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": None, + } + }, + expected=None, + msg="$dateDiff should return null for a null unit", + ), +] + +# Property [Missing Field Reference]: a missing startDate, endDate, unit, timezone, or +# startOfWeek field reference returns null. +DATEDIFF_MISSING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_startDate_ref", + expression={ + "$dateDiff": { + "startDate": MISSING, + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=None, + msg="$dateDiff should return null for a missing startDate field reference", + ), + ExpressionTestCase( + "missing_endDate_ref", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": MISSING, + "unit": "day", + } + }, + expected=None, + msg="$dateDiff should return null for a missing endDate field reference", + ), + ExpressionTestCase( + "missing_unit_ref", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": MISSING, + } + }, + expected=None, + msg="$dateDiff should return null for a missing unit field reference", + ), + ExpressionTestCase( + "missing_timezone_ref", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": "day", + "timezone": MISSING, + } + }, + expected=None, + msg="$dateDiff should return null for a missing timezone field reference", + ), + ExpressionTestCase( + "missing_startOfWeek_ref", + expression={ + "$dateDiff": { + "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": MISSING, + } + }, + expected=None, + msg="$dateDiff should return null for a missing startOfWeek field reference", + ), +] + +DATEDIFF_NULL_MISSING_TESTS: list[ExpressionTestCase] = DATEDIFF_NULL_TESTS + DATEDIFF_MISSING_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_NULL_MISSING_TESTS)) +def test_dateDiff_null(collection, test_case: ExpressionTestCase): + """Test $dateDiff returns null for null literals and missing field references.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_range.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_range.py new file mode 100644 index 000000000..5d50a7eae --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_range.py @@ -0,0 +1,165 @@ +"""$dateDiff across the representable date range: epoch-crossing and extreme boundary values.""" + +from datetime import datetime, timezone + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_EPOCH, + DATE_MS_BEFORE_EPOCH, + DATE_MS_EPOCH, + DATE_MS_MAX, + DATE_MS_MIN, + DATE_YEAR_1900, + OID_MAX_SIGNED32, + OID_MIN_SIGNED32, + TS_MAX_SIGNED32, + TS_MAX_UNSIGNED32, +) + +# Property [Epoch Crossing]: dates around the Unix epoch and far from it are counted correctly. +DATEDIFF_EPOCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "epoch_date", + doc={"s": DATE_EPOCH}, + expression={ + "$dateDiff": { + "startDate": "$s", + "endDate": datetime(1970, 1, 2, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(1), + msg="$dateDiff should count one day from the epoch", + ), + ExpressionTestCase( + "pre_epoch", + expression={ + "$dateDiff": { + "startDate": datetime(1969, 12, 31, tzinfo=timezone.utc), + "endDate": DATE_EPOCH, + "unit": "day", + } + }, + expected=Int64(1), + msg="$dateDiff should count one day across the epoch from 1969", + ), + ExpressionTestCase( + "distant_past", + doc={"s": DATE_YEAR_1900}, + expression={ + "$dateDiff": { + "startDate": "$s", + "endDate": datetime(1900, 12, 31, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(364), + msg="$dateDiff should count days within a distant past year", + ), + ExpressionTestCase( + "distant_future", + expression={ + "$dateDiff": { + "startDate": datetime(2100, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2100, 12, 31, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(364), + msg="$dateDiff should count days within a distant future year", + ), + ExpressionTestCase( + "cross_epoch", + expression={ + "$dateDiff": { + "startDate": datetime(1969, 6, 1, tzinfo=timezone.utc), + "endDate": datetime(1970, 6, 1, tzinfo=timezone.utc), + "unit": "year", + } + }, + expected=Int64(1), + msg="$dateDiff should count one year crossing the epoch", + ), +] + +# Property [Extreme Boundary]: DatetimeMS, Timestamp, and ObjectId values at the edges of the +# 32-bit and 64-bit ranges are counted correctly. +DATEDIFF_EXTREME_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_ms_epoch_diff", + doc={"s": DATE_MS_EPOCH}, + expression={ + "$dateDiff": { + "startDate": "$s", + "endDate": datetime(1970, 1, 2, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(1), + msg="$dateDiff should count one day from an epoch DatetimeMS", + ), + ExpressionTestCase( + "date_ms_before_epoch_diff", + doc={"s": DATE_MS_BEFORE_EPOCH}, + expression={"$dateDiff": {"startDate": "$s", "endDate": DATE_EPOCH, "unit": "millisecond"}}, + expected=Int64(1), + msg="$dateDiff should count one millisecond from just before the epoch", + ), + ExpressionTestCase( + "ts_boundary_max_s32", + doc={"s": TS_MAX_SIGNED32, "e": TS_MAX_UNSIGNED32}, + expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "year"}}, + expected=Int64(68), + msg="$dateDiff should count years between the signed and unsigned 32-bit Timestamp limits", + ), + ExpressionTestCase( + "oid_max_signed32", + doc={"s": OID_MAX_SIGNED32}, + expression={ + "$dateDiff": { + "startDate": "$s", + "endDate": datetime(2038, 1, 20, tzinfo=timezone.utc), + "unit": "day", + } + }, + expected=Int64(1), + msg="$dateDiff should count from a max signed 32-bit ObjectId", + ), + ExpressionTestCase( + "oid_high_bit_pre_epoch", + doc={"s": OID_MIN_SIGNED32}, + expression={"$dateDiff": {"startDate": "$s", "endDate": DATE_EPOCH, "unit": "year"}}, + expected=Int64(69), + msg="$dateDiff should handle an ObjectId with the high timestamp bit set as pre-epoch", + ), + ExpressionTestCase( + "ms_overflow_int64", + doc={"s": DATE_MS_MIN, "e": DATE_MS_MAX}, + expression={"$dateDiff": {"startDate": "$s", "endDate": "$e", "unit": "year"}}, + expected=Int64(584_554_049), + msg="$dateDiff should count years between the minimum and maximum representable dates", + ), +] + +DATEDIFF_RANGE_TESTS: list[ExpressionTestCase] = ( + DATEDIFF_EPOCH_TESTS + DATEDIFF_EXTREME_BOUNDARY_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_RANGE_TESTS)) +def test_dateDiff_range(collection, test_case: ExpressionTestCase): + """Test $dateDiff counts correctly across epoch and extreme boundary dates.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_timezone.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_timezone.py index 03516daee..d55e12200 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_timezone.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_timezone.py @@ -1,9 +1,9 @@ -"""$dateDiff timezone acceptance, DST behavior, startOfWeek values, and their type errors.""" +"""$dateDiff timezone handling: offsets, DST, startOfWeek values, and unit gating.""" from datetime import datetime, timezone import pytest -from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp +from bson import Int64 from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( ExpressionTestCase, @@ -12,12 +12,6 @@ assert_expression_result, execute_expression_with_insert, ) -from documentdb_tests.framework.error_codes import ( - INVALID_STARTOFWEEK_ERROR, - INVALID_STARTOFWEEK_TYPE_ERROR, - INVALID_TIMEZONE_ERROR, - INVALID_TIMEZONE_TYPE_ERROR, -) from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import INT64_ZERO @@ -279,7 +273,7 @@ ), ] -DATEDIFF_TIMEZONE_SUCCESS_TESTS = ( +DATEDIFF_TIMEZONE_TESTS: list[ExpressionTestCase] = ( DATEDIFF_TIMEZONE_VALID_TESTS + DATEDIFF_TIMEZONE_BOUNDARY_TESTS + DATEDIFF_TIMEZONE_DST_TESTS @@ -288,132 +282,10 @@ + DATEDIFF_STARTOFWEEK_GATING_TESTS ) -# Property [Invalid Timezone]: an unrecognized or malformed timezone string is rejected. -DATEDIFF_TIMEZONE_INVALID_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - f"tz_{tid}", - expression={ - "$dateDiff": { - "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), - "endDate": datetime(2024, 6, 1, tzinfo=timezone.utc), - "unit": "day", - "timezone": tz, - } - }, - error_code=INVALID_TIMEZONE_ERROR, - msg=f"$dateDiff should reject {desc}", - ) - for tid, tz, desc in [ - ("offset_3digit_hours", "+100:00", "a 3-digit hour offset"), - ("invalid_string", "NotATimezone", "an unrecognized Olson timezone"), - ("empty_string", "", "an empty string timezone"), - ("olson_wrong_case_lowercase", "america/new_york", "an all-lowercase Olson name"), - ("olson_wrong_case_uppercase", "AMERICA/NEW_YORK", "an all-uppercase Olson name"), - ] -] - -# Property [Timezone Type]: a non-string timezone is rejected as an invalid type. -DATEDIFF_TIMEZONE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - f"tz_{tid}", - expression={ - "$dateDiff": { - "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), - "endDate": datetime(2021, 1, 2, tzinfo=timezone.utc), - "unit": "day", - "timezone": val, - } - }, - error_code=INVALID_TIMEZONE_TYPE_ERROR, - msg=f"$dateDiff should reject a {tid} timezone", - ) - for tid, val in [ - ("number", 5), - ("int64", Int64(5)), - ("double", 5.0), - ("decimal128", Decimal128("5")), - ("boolean", True), - ("array", ["UTC"]), - ("object", {"tz": "UTC"}), - ("datetime", datetime(2021, 1, 1, tzinfo=timezone.utc)), - ("objectid", ObjectId("600000000000000000000000")), - ("timestamp", Timestamp(1, 1)), - ("binary", Binary(b"\x01\x02\x03")), - ("regex", Regex("UTC")), - ("javascript", Code("function() {}")), - ("minkey", MinKey()), - ("maxkey", MaxKey()), - ] -] - -# Property [Invalid StartOfWeek]: an unrecognized startOfWeek string is rejected. -DATEDIFF_STARTOFWEEK_INVALID_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - f"sow_{tid}", - expression={ - "$dateDiff": { - "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), - "endDate": datetime(2024, 1, 31, tzinfo=timezone.utc), - "unit": "week", - "startOfWeek": sow, - } - }, - error_code=INVALID_STARTOFWEEK_ERROR, - msg=f"$dateDiff should reject {desc}", - ) - for tid, sow, desc in [ - ("invalid_string", "notaday", "an unrecognized startOfWeek string"), - ("empty_string", "", "an empty startOfWeek string"), - ] -] - -# Property [StartOfWeek Type]: a non-string startOfWeek is rejected as an invalid type. -DATEDIFF_STARTOFWEEK_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - f"sow_{tid}", - expression={ - "$dateDiff": { - "startDate": datetime(2024, 1, 1, tzinfo=timezone.utc), - "endDate": datetime(2024, 1, 31, tzinfo=timezone.utc), - "unit": "week", - "startOfWeek": val, - } - }, - error_code=INVALID_STARTOFWEEK_TYPE_ERROR, - msg=f"$dateDiff should reject a {tid} startOfWeek", - ) - for tid, val in [ - ("number", 1), - ("int64", Int64(1)), - ("double", 1.0), - ("decimal128", Decimal128("1")), - ("boolean", True), - ("array", ["monday"]), - ("object", {"day": "monday"}), - ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), - ("objectid", ObjectId("600000000000000000000000")), - ("timestamp", Timestamp(1, 1)), - ("binary", Binary(b"\x01\x02\x03")), - ("regex", Regex("monday")), - ("javascript", Code("function() {}")), - ("minkey", MinKey()), - ("maxkey", MaxKey()), - ] -] - -DATEDIFF_TIMEZONE_ERROR_TESTS = ( - DATEDIFF_TIMEZONE_INVALID_TESTS - + DATEDIFF_TIMEZONE_TYPE_ERROR_TESTS - + DATEDIFF_STARTOFWEEK_INVALID_TESTS - + DATEDIFF_STARTOFWEEK_TYPE_ERROR_TESTS -) - -DATEDIFF_TIMEZONE_TESTS = DATEDIFF_TIMEZONE_SUCCESS_TESTS + DATEDIFF_TIMEZONE_ERROR_TESTS - @pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_TIMEZONE_TESTS)) def test_dateDiff_timezone(collection, test_case: ExpressionTestCase): - """Test $dateDiff timezone and startOfWeek handling.""" + """Test $dateDiff applies timezone and startOfWeek to the local wall clock.""" result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_week.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_week.py new file mode 100644 index 000000000..a6cb80d06 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_week.py @@ -0,0 +1,167 @@ +"""$dateDiff week counting: week boundaries crossed relative to startOfWeek.""" + +from datetime import datetime, timezone + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Week Counting]: the difference counts week boundaries crossed relative to +# startOfWeek, defaulting to Sunday. +DATEDIFF_WEEK_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "jan_default_sun", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 31, tzinfo=timezone.utc), + "unit": "week", + } + }, + expected=Int64(5), + msg="$dateDiff should count January weeks from the default Sunday start", + ), + ExpressionTestCase( + "jan_monday", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 31, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": "monday", + } + }, + expected=Int64(4), + msg="$dateDiff should count January weeks from a Monday start", + ), + ExpressionTestCase( + "jan_friday", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 31, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": "fri", + } + }, + expected=Int64(4), + msg="$dateDiff should count January weeks from a Friday start", + ), + ExpressionTestCase( + "feb_default_sun", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 2, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 2, 28, tzinfo=timezone.utc), + "unit": "week", + } + }, + expected=Int64(4), + msg="$dateDiff should count February weeks from the default Sunday start", + ), + ExpressionTestCase( + "feb_monday", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 2, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 2, 28, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": "monday", + } + }, + expected=Int64(3), + msg="$dateDiff should count February weeks from a Monday start", + ), + ExpressionTestCase( + "feb_friday", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 2, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 2, 28, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": "fri", + } + }, + expected=Int64(4), + msg="$dateDiff should count February weeks from a Friday start", + ), + ExpressionTestCase( + "mar_default_sun", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 3, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 3, 31, tzinfo=timezone.utc), + "unit": "week", + } + }, + expected=Int64(4), + msg="$dateDiff should count March weeks from the default Sunday start", + ), + ExpressionTestCase( + "mar_monday", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 3, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 3, 31, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": "monday", + } + }, + expected=Int64(4), + msg="$dateDiff should count March weeks from a Monday start", + ), + ExpressionTestCase( + "mar_friday", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 3, 1, tzinfo=timezone.utc), + "endDate": datetime(2021, 3, 31, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": "fri", + } + }, + expected=Int64(4), + msg="$dateDiff should count March weeks from a Friday start", + ), + ExpressionTestCase( + "cross_year_week", + expression={ + "$dateDiff": { + "startDate": datetime(2020, 12, 28, tzinfo=timezone.utc), + "endDate": datetime(2021, 1, 4, tzinfo=timezone.utc), + "unit": "week", + } + }, + expected=Int64(1), + msg="$dateDiff should count one week across the year boundary", + ), + ExpressionTestCase( + "cross_month_week", + expression={ + "$dateDiff": { + "startDate": datetime(2021, 1, 25, tzinfo=timezone.utc), + "endDate": datetime(2021, 2, 8, tzinfo=timezone.utc), + "unit": "week", + } + }, + expected=Int64(2), + msg="$dateDiff should count two weeks across the month boundary", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_WEEK_TESTS)) +def test_dateDiff_week(collection, test_case: ExpressionTestCase): + """Test $dateDiff counts week boundaries relative to the configured start of week.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_basic.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_arithmetic.py similarity index 77% rename from documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_basic.py rename to documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_arithmetic.py index 7626c8765..e5b377832 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_basic.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_arithmetic.py @@ -1,4 +1,4 @@ -"""$dateSubtract basic operations, amount types, and boundaries.""" +"""$dateSubtract core arithmetic: unit application, sign, amount types, and unit equivalence.""" from datetime import datetime, timezone @@ -12,13 +12,9 @@ assert_expression_result, execute_expression_with_insert, ) -from documentdb_tests.framework.error_codes import DATEADD_INVALID_AMOUNT_ERROR from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import ( DECIMAL128_NEGATIVE_ZERO, - DECIMAL128_ONE_AND_HALF, - DOUBLE_MIN_SUBNORMAL, - DOUBLE_NEAR_MIN, DOUBLE_NEGATIVE_ZERO, INT32_MAX, INT32_MIN, @@ -236,88 +232,58 @@ ), ] -# Property [Amount Non-Integral]: a non-integral numeric amount is rejected. -DATESUBTRACT_AMOUNT_ERROR_TESTS: list[ExpressionTestCase] = [ +# Property [Unit Equivalence]: a smaller unit times its multiple equals the larger unit. +DATESUBTRACT_UNIT_EQUIVALENCE_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( - "amount_non_integral_double_1_5", - doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1.5}}, - error_code=DATEADD_INVALID_AMOUNT_ERROR, - msg="$dateSubtract should reject a non-integral double amount", - ), - ExpressionTestCase( - "amount_non_integral_double_5_9", - doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 5.9}}, - error_code=DATEADD_INVALID_AMOUNT_ERROR, - msg="$dateSubtract should reject a non-integral double amount close to an integer", - ), - ExpressionTestCase( - "amount_non_integral_double_negative", - doc={"date": datetime(2000, 1, 10, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": -5.9}}, - error_code=DATEADD_INVALID_AMOUNT_ERROR, - msg="$dateSubtract should reject a non-integral negative double amount", + "millisecond_1000_equals_second_1", + doc={"date": datetime(2000, 1, 1, 12, 0, 1, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "millisecond", "amount": 1000}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract of 1000 milliseconds should equal subtracting 1 second", ), ExpressionTestCase( - "amount_non_integral_decimal128", - doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={ - "$dateSubtract": { - "startDate": "$date", - "unit": "day", - "amount": DECIMAL128_ONE_AND_HALF, - } - }, - error_code=DATEADD_INVALID_AMOUNT_ERROR, - msg="$dateSubtract should reject a non-integral decimal128 amount", + "millisecond_precision_1ms", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, 1000, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "millisecond", "amount": 1}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should decrement by exactly 1 millisecond", ), ExpressionTestCase( - "amount_decimal128_non_integral_34th_digit", - doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={ - "$dateSubtract": { - "startDate": "$date", - "unit": "day", - "amount": Decimal128("3.000000000000000000000000000000001"), - } - }, - error_code=DATEADD_INVALID_AMOUNT_ERROR, - msg="$dateSubtract should reject a decimal128 amount non-integral at the 34th digit", + "day_7_equals_week", + doc={"date": datetime(2000, 6, 8, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 7}}, + expected=datetime(2000, 6, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract of 7 days should equal subtracting 1 week", ), ExpressionTestCase( - "amount_double_near_min", - doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={ - "$dateSubtract": {"startDate": "$date", "unit": "day", "amount": DOUBLE_NEAR_MIN} - }, - error_code=DATEADD_INVALID_AMOUNT_ERROR, - msg="$dateSubtract should reject a near-minimum double amount as non-integral", + "month_12_equals_year", + doc={"date": datetime(2001, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 12}}, + expected=datetime(2000, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract of 12 months should equal subtracting 1 year", ), ExpressionTestCase( - "amount_double_min_subnormal", - doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={ - "$dateSubtract": {"startDate": "$date", "unit": "day", "amount": DOUBLE_MIN_SUBNORMAL} - }, - error_code=DATEADD_INVALID_AMOUNT_ERROR, - msg="$dateSubtract should reject a minimum subnormal double amount as non-integral", + "month_3_equals_quarter", + doc={"date": datetime(2000, 9, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 3}}, + expected=datetime(2000, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract of 3 months should equal subtracting 1 quarter", ), ] -DATESUBTRACT_BASIC_TESTS: list[ExpressionTestCase] = ( +DATESUBTRACT_ARITHMETIC_TESTS: list[ExpressionTestCase] = ( DATESUBTRACT_UNIT_TESTS + DATESUBTRACT_NEGATIVE_TESTS + DATESUBTRACT_ZERO_TESTS + DATESUBTRACT_AMOUNT_TYPE_TESTS + DATESUBTRACT_AMOUNT_BOUNDARY_TESTS - + DATESUBTRACT_AMOUNT_ERROR_TESTS + + DATESUBTRACT_UNIT_EQUIVALENCE_TESTS ) -@pytest.mark.parametrize("test_case", pytest_params(DATESUBTRACT_BASIC_TESTS)) -def test_dateSubtract_basic(collection, test_case: ExpressionTestCase): - """Test $dateSubtract basic operations.""" +@pytest.mark.parametrize("test_case", pytest_params(DATESUBTRACT_ARITHMETIC_TESTS)) +def test_dateSubtract_arithmetic(collection, test_case: ExpressionTestCase): + """Test $dateSubtract arithmetic across units, amount signs, and amount types.""" result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_calendar.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_calendar.py new file mode 100644 index 000000000..3f0d74b0c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_calendar.py @@ -0,0 +1,198 @@ +"""$dateSubtract calendar rules: leap years, century rule, month clamping, and boundary carry.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_Y2K, +) + +# Property [Leap Year]: subtracting across February resolves leap-year day counts correctly. +DATESUBTRACT_LEAP_YEAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "leap_year_feb28_sub_year", + doc={"date": datetime(2001, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=datetime(2000, 2, 28, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should land on Feb 28 when subtracting a year into a leap year", + ), + ExpressionTestCase( + "leap_year_feb29_sub_year", + doc={"date": datetime(2020, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=datetime(2019, 2, 28, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should clamp Feb 29 to Feb 28 subtracting a year into a non-leap year", + ), + ExpressionTestCase( + "leap_year_sub_day_to_feb29", + doc={"date": datetime(2000, 3, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should land on Feb 29 in a leap year", + ), + ExpressionTestCase( + "non_leap_year_mar1_sub_day", + doc={"date": datetime(1999, 3, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1999, 2, 28, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should land on Feb 28 in a non-leap year", + ), + ExpressionTestCase( + "leap_year_mar29_sub_month", + doc={"date": datetime(2020, 3, 29, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2020, 2, 29, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract a month from Mar 29 to Feb 29 in a leap year", + ), + ExpressionTestCase( + "leap_year_365_days", + doc={"date": datetime(2020, 12, 31, 15, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 365}}, + expected=datetime(2020, 1, 1, 15, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should not wrap the year when subtracting 365 days in a leap year", + ), + ExpressionTestCase( + "non_leap_year_365_days", + doc={"date": datetime(2020, 1, 1, 15, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 365}}, + expected=datetime(2019, 1, 1, 15, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should wrap to the prior year subtracting 365 days into a non-leap year", + ), +] + +# Property [Century Leap Year]: the divisible-by-100/400 leap rule is honored. +DATESUBTRACT_CENTURY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "century_non_leap_1900_mar1_sub_day", + doc={"date": datetime(1900, 3, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1900, 2, 28, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should land on Feb 28 in 1900, a non-leap century year", + ), + ExpressionTestCase( + "century_leap_2000_mar1_sub_day", + doc={"date": datetime(2000, 3, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should land on Feb 29 in 2000, a leap century year", + ), +] + +# Property [Month Clamping]: subtracting months or quarters clamps to the last valid day of the +# target month. +DATESUBTRACT_MONTH_CLAMP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "mar31_sub_month_leap", + doc={"date": datetime(2000, 3, 31, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should clamp Mar 31 minus 1 month to Feb 29 in a leap year", + ), + ExpressionTestCase( + "mar31_sub_month_non_leap", + doc={"date": datetime(2021, 3, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2021, 2, 28, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should clamp Mar 31 minus 1 month to Feb 28 in a non-leap year", + ), + ExpressionTestCase( + "may31_sub_month", + doc={"date": datetime(2000, 5, 31, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2000, 4, 30, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should clamp May 31 minus 1 month to Apr 30", + ), + ExpressionTestCase( + "jul31_sub_month", + doc={"date": datetime(2000, 7, 31, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2000, 6, 30, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should clamp Jul 31 minus 1 month to Jun 30", + ), + ExpressionTestCase( + "jan31_sub_month_no_adjustment", + doc={"date": datetime(2021, 1, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 1}}, + expected=datetime(2020, 12, 31, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should not adjust Jan 31 minus 1 month, landing on Dec 31", + ), + ExpressionTestCase( + "dec31_sub_year_no_adjustment", + doc={"date": datetime(2021, 12, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=datetime(2020, 12, 31, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should not adjust Dec 31 when subtracting a year", + ), + ExpressionTestCase( + "apr30_sub_quarter", + doc={"date": datetime(2021, 4, 30, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "quarter", "amount": 1}}, + expected=datetime(2021, 1, 30, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract 1 quarter from Apr 30 to Jan 30", + ), + ExpressionTestCase( + "large_positive_month", + doc={"date": datetime(2020, 12, 31, 12, 10, 5, 10000, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 49}}, + expected=datetime(2016, 11, 30, 12, 10, 5, 10000, tzinfo=timezone.utc), + msg="$dateSubtract should clamp a large positive month amount to end-of-month", + ), + ExpressionTestCase( + "large_negative_month", + doc={"date": datetime(2020, 12, 31, 12, 10, 5, 10000, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": -50}}, + expected=datetime(2025, 2, 28, 12, 10, 5, 10000, tzinfo=timezone.utc), + msg="$dateSubtract should clamp a large negative month amount to end-of-month", + ), +] + +# Property [Boundary Crossing]: subtracting across day, month, and year boundaries carries +# correctly. +DATESUBTRACT_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dec31_sub_day", + doc={"date": datetime(2001, 1, 1, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(2000, 12, 31, 23, 59, 59, tzinfo=timezone.utc), + msg="$dateSubtract should cross the year boundary from Jan 1 minus 1 day", + ), + ExpressionTestCase( + "jan1_add_day", + doc={"date": DATE_Y2K}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": -1}}, + expected=datetime(2000, 1, 2, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should cross the day boundary from Jan 1 with a negative amount", + ), + ExpressionTestCase( + "dec31_sub_hour", + doc={"date": datetime(2001, 1, 1, 1, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "hour", "amount": 2}}, + expected=datetime(2000, 12, 31, 23, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should cross the year boundary from Jan 1 minus 2 hours", + ), +] + +DATESUBTRACT_CALENDAR_TESTS: list[ExpressionTestCase] = ( + DATESUBTRACT_LEAP_YEAR_TESTS + + DATESUBTRACT_CENTURY_TESTS + + DATESUBTRACT_MONTH_CLAMP_TESTS + + DATESUBTRACT_BOUNDARY_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATESUBTRACT_CALENDAR_TESTS)) +def test_dateSubtract_calendar(collection, test_case: ExpressionTestCase): + """Test $dateSubtract honors calendar rules across months, years, and boundaries.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_date_range.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_date_range.py new file mode 100644 index 000000000..56fa7c45d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_date_range.py @@ -0,0 +1,135 @@ +"""$dateSubtract across the representable date range: historical, future, epoch, and limit dates.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_EPOCH, + DATE_YEAR_1, + DATE_YEAR_1900, + DATE_YEAR_9999, +) + +# Property [Historical And Future]: distant past and future start dates are handled. +DATESUBTRACT_HISTORICAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "historical_date", + doc={"date": datetime(1910, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 10}}, + expected=datetime(1900, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should handle a 1910 historical date", + ), + ExpressionTestCase( + "pre_epoch_1960", + doc={"date": datetime(1960, 4, 10, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 100}}, + expected=datetime(1960, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should handle a pre-epoch 1960 date", + ), + ExpressionTestCase( + "far_future", + doc={"date": datetime(2200, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 100}}, + expected=datetime(2100, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should handle a far-future 2200 date", + ), + ExpressionTestCase( + "large_year_amount", + doc={"date": datetime(3000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 1000}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should handle subtracting 1000 years", + ), + ExpressionTestCase( + "distant_past", + doc={"date": datetime(1901, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=DATE_YEAR_1900, + msg="$dateSubtract should handle 1901 minus 1 year", + ), + ExpressionTestCase( + "distant_future_month", + doc={"date": datetime(2100, 7, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 6}}, + expected=datetime(2100, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should handle 2100 minus 6 months", + ), +] + +# Property [Epoch Crossing]: subtracting forward and backward across the Unix epoch is correct. +DATESUBTRACT_EPOCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "epoch_sub_day", + doc={"date": datetime(1970, 1, 2, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=DATE_EPOCH, + msg="$dateSubtract should subtract a day to reach the epoch", + ), + ExpressionTestCase( + "cross_epoch_back", + doc={"date": DATE_EPOCH}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1969, 12, 31, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should cross the epoch backward to 1969", + ), + ExpressionTestCase( + "pre_epoch_to_epoch", + doc={"date": datetime(1969, 12, 31, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": -1}}, + expected=DATE_EPOCH, + msg="$dateSubtract should cross the epoch forward from 1969 with a negative amount", + ), +] + +# Property [Date Limits]: subtracting near the minimum representable date is handled. +DATESUBTRACT_DATE_LIMIT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "near_min_date", + doc={"date": datetime(1, 1, 1, 0, 0, 1, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "millisecond", "amount": 1}}, + expected=datetime(1, 1, 1, 0, 0, 0, 999000, tzinfo=timezone.utc), + msg="$dateSubtract should subtract 1 millisecond near the minimum date", + ), + ExpressionTestCase( + "year_9999_sub_large_ms", + doc={"date": DATE_YEAR_9999}, + expression={ + "$dateSubtract": { + "startDate": "$date", + "unit": "millisecond", + "amount": 253_402_300_799_999, + } + }, + expected=DATE_EPOCH, + msg="$dateSubtract should reach the epoch from the max date with a large ms amount", + ), + ExpressionTestCase( + "at_python_min_year", + doc={"date": datetime(1, 1, 1, 23, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "hour", "amount": 23}}, + expected=DATE_YEAR_1, + msg="$dateSubtract should subtract 23 hours at year 1", + ), +] + +DATESUBTRACT_DATE_RANGE_TESTS: list[ExpressionTestCase] = ( + DATESUBTRACT_HISTORICAL_TESTS + DATESUBTRACT_EPOCH_TESTS + DATESUBTRACT_DATE_LIMIT_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATESUBTRACT_DATE_RANGE_TESTS)) +def test_dateSubtract_date_range(collection, test_case: ExpressionTestCase): + """Test $dateSubtract remains correct across distant, epoch-crossing, and boundary dates.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_dates.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_dates.py deleted file mode 100644 index a0df57026..000000000 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_dates.py +++ /dev/null @@ -1,473 +0,0 @@ -"""$dateSubtract date edge cases: leap years, month clamping, boundaries, and non-date starts.""" - -from datetime import datetime, timezone - -import pytest - -from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( - ExpressionTestCase, -) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( - oid_from_args, - ts_from_args, -) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( - assert_expression_result, - execute_expression_with_insert, -) -from documentdb_tests.framework.error_codes import DATEADD_INVALID_LARGE_VALUE_ERROR -from documentdb_tests.framework.parametrize import pytest_params -from documentdb_tests.framework.test_constants import ( - DATE_EPOCH, - DATE_MS_EPOCH, - DATE_Y2K, - DATE_YEAR_1, - DATE_YEAR_1900, - DATE_YEAR_9999, - OID_EPOCH, - OID_MAX_SIGNED32, - OID_MIN_SIGNED32, - TS_EPOCH, - TS_MAX_SIGNED32, - TS_MAX_UNSIGNED32, -) - -# Property [Leap Year]: subtracting across February resolves leap-year day counts correctly. -DATESUBTRACT_LEAP_YEAR_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "leap_year_feb28_sub_year", - doc={"date": datetime(2001, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 1}}, - expected=datetime(2000, 2, 28, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract should land on Feb 28 when subtracting a year into a leap year", - ), - ExpressionTestCase( - "leap_year_feb29_sub_year", - doc={"date": datetime(2020, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 1}}, - expected=datetime(2019, 2, 28, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract should clamp Feb 29 to Feb 28 subtracting a year into a non-leap year", - ), - ExpressionTestCase( - "leap_year_sub_day_to_feb29", - doc={"date": datetime(2000, 3, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, - expected=datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract should land on Feb 29 in a leap year", - ), - ExpressionTestCase( - "non_leap_year_mar1_sub_day", - doc={"date": datetime(1999, 3, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, - expected=datetime(1999, 2, 28, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract should land on Feb 28 in a non-leap year", - ), - ExpressionTestCase( - "leap_year_mar29_sub_month", - doc={"date": datetime(2020, 3, 29, 10, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 1}}, - expected=datetime(2020, 2, 29, 10, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract should subtract a month from Mar 29 to Feb 29 in a leap year", - ), - ExpressionTestCase( - "leap_year_365_days", - doc={"date": datetime(2020, 12, 31, 15, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 365}}, - expected=datetime(2020, 1, 1, 15, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract should not wrap the year when subtracting 365 days in a leap year", - ), - ExpressionTestCase( - "non_leap_year_365_days", - doc={"date": datetime(2020, 1, 1, 15, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 365}}, - expected=datetime(2019, 1, 1, 15, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract should wrap to the prior year subtracting 365 days into a non-leap year", - ), -] - -# Property [Century Leap Year]: the divisible-by-100/400 leap rule is honored. -DATESUBTRACT_CENTURY_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "century_non_leap_1900_mar1_sub_day", - doc={"date": datetime(1900, 3, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, - expected=datetime(1900, 2, 28, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract should land on Feb 28 in 1900, a non-leap century year", - ), - ExpressionTestCase( - "century_leap_2000_mar1_sub_day", - doc={"date": datetime(2000, 3, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, - expected=datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract should land on Feb 29 in 2000, a leap century year", - ), -] - -# Property [Month Clamping]: subtracting months or quarters clamps to the last valid day of the -# target month. -DATESUBTRACT_MONTH_CLAMP_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "mar31_sub_month_leap", - doc={"date": datetime(2000, 3, 31, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 1}}, - expected=datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract should clamp Mar 31 minus 1 month to Feb 29 in a leap year", - ), - ExpressionTestCase( - "mar31_sub_month_non_leap", - doc={"date": datetime(2021, 3, 31, 10, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 1}}, - expected=datetime(2021, 2, 28, 10, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract should clamp Mar 31 minus 1 month to Feb 28 in a non-leap year", - ), - ExpressionTestCase( - "may31_sub_month", - doc={"date": datetime(2000, 5, 31, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 1}}, - expected=datetime(2000, 4, 30, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract should clamp May 31 minus 1 month to Apr 30", - ), - ExpressionTestCase( - "jul31_sub_month", - doc={"date": datetime(2000, 7, 31, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 1}}, - expected=datetime(2000, 6, 30, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract should clamp Jul 31 minus 1 month to Jun 30", - ), - ExpressionTestCase( - "jan31_sub_month_no_adjustment", - doc={"date": datetime(2021, 1, 31, 10, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 1}}, - expected=datetime(2020, 12, 31, 10, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract should not adjust Jan 31 minus 1 month, landing on Dec 31", - ), - ExpressionTestCase( - "dec31_sub_year_no_adjustment", - doc={"date": datetime(2021, 12, 31, 10, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 1}}, - expected=datetime(2020, 12, 31, 10, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract should not adjust Dec 31 when subtracting a year", - ), - ExpressionTestCase( - "apr30_sub_quarter", - doc={"date": datetime(2021, 4, 30, 10, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "quarter", "amount": 1}}, - expected=datetime(2021, 1, 30, 10, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract should subtract 1 quarter from Apr 30 to Jan 30", - ), - ExpressionTestCase( - "large_positive_month", - doc={"date": datetime(2020, 12, 31, 12, 10, 5, 10000, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 49}}, - expected=datetime(2016, 11, 30, 12, 10, 5, 10000, tzinfo=timezone.utc), - msg="$dateSubtract should clamp a large positive month amount to end-of-month", - ), - ExpressionTestCase( - "large_negative_month", - doc={"date": datetime(2020, 12, 31, 12, 10, 5, 10000, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": -50}}, - expected=datetime(2025, 2, 28, 12, 10, 5, 10000, tzinfo=timezone.utc), - msg="$dateSubtract should clamp a large negative month amount to end-of-month", - ), -] - -# Property [Boundary Crossing]: subtracting across day, month, and year boundaries carries -# correctly. -DATESUBTRACT_BOUNDARY_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "dec31_sub_day", - doc={"date": datetime(2001, 1, 1, 23, 59, 59, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, - expected=datetime(2000, 12, 31, 23, 59, 59, tzinfo=timezone.utc), - msg="$dateSubtract should cross the year boundary from Jan 1 minus 1 day", - ), - ExpressionTestCase( - "jan1_add_day", - doc={"date": DATE_Y2K}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": -1}}, - expected=datetime(2000, 1, 2, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract should cross the day boundary from Jan 1 with a negative amount", - ), - ExpressionTestCase( - "dec31_sub_hour", - doc={"date": datetime(2001, 1, 1, 1, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "hour", "amount": 2}}, - expected=datetime(2000, 12, 31, 23, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract should cross the year boundary from Jan 1 minus 2 hours", - ), -] - -# Property [Historical And Future]: distant past and future start dates are handled. -DATESUBTRACT_HISTORICAL_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "historical_date", - doc={"date": datetime(1910, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 10}}, - expected=datetime(1900, 1, 1, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract should handle a 1910 historical date", - ), - ExpressionTestCase( - "pre_epoch_1960", - doc={"date": datetime(1960, 4, 10, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 100}}, - expected=datetime(1960, 1, 1, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract should handle a pre-epoch 1960 date", - ), - ExpressionTestCase( - "far_future", - doc={"date": datetime(2200, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 100}}, - expected=datetime(2100, 1, 1, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract should handle a far-future 2200 date", - ), - ExpressionTestCase( - "large_year_amount", - doc={"date": datetime(3000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 1000}}, - expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract should handle subtracting 1000 years", - ), - ExpressionTestCase( - "distant_past", - doc={"date": datetime(1901, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 1}}, - expected=DATE_YEAR_1900, - msg="$dateSubtract should handle 1901 minus 1 year", - ), - ExpressionTestCase( - "distant_future_month", - doc={"date": datetime(2100, 7, 1, 0, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 6}}, - expected=datetime(2100, 1, 1, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract should handle 2100 minus 6 months", - ), -] - -# Property [Epoch Crossing]: subtracting forward and backward across the Unix epoch is correct. -DATESUBTRACT_EPOCH_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "epoch_sub_day", - doc={"date": datetime(1970, 1, 2, 0, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, - expected=DATE_EPOCH, - msg="$dateSubtract should subtract a day to reach the epoch", - ), - ExpressionTestCase( - "cross_epoch_back", - doc={"date": DATE_EPOCH}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, - expected=datetime(1969, 12, 31, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract should cross the epoch backward to 1969", - ), - ExpressionTestCase( - "pre_epoch_to_epoch", - doc={"date": datetime(1969, 12, 31, 0, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": -1}}, - expected=DATE_EPOCH, - msg="$dateSubtract should cross the epoch forward from 1969 with a negative amount", - ), -] - -# Property [Date Limits]: subtracting near the minimum representable date is handled. -DATESUBTRACT_DATE_LIMIT_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "near_min_date", - doc={"date": datetime(1, 1, 1, 0, 0, 1, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "millisecond", "amount": 1}}, - expected=datetime(1, 1, 1, 0, 0, 0, 999000, tzinfo=timezone.utc), - msg="$dateSubtract should subtract 1 millisecond near the minimum date", - ), - ExpressionTestCase( - "year_9999_sub_large_ms", - doc={"date": DATE_YEAR_9999}, - expression={ - "$dateSubtract": { - "startDate": "$date", - "unit": "millisecond", - "amount": 253_402_300_799_999, - } - }, - expected=DATE_EPOCH, - msg="$dateSubtract should reach the epoch from the max date with a large ms amount", - ), - ExpressionTestCase( - "at_python_min_year", - doc={"date": datetime(1, 1, 1, 23, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "hour", "amount": 23}}, - expected=DATE_YEAR_1, - msg="$dateSubtract should subtract 23 hours at year 1", - ), -] - -# Property [Unit Equivalence]: a smaller unit times its multiple equals the larger unit. -DATESUBTRACT_UNIT_EQUIVALENCE_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "millisecond_1000_equals_second_1", - doc={"date": datetime(2000, 1, 1, 12, 0, 1, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "millisecond", "amount": 1000}}, - expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract of 1000 milliseconds should equal subtracting 1 second", - ), - ExpressionTestCase( - "millisecond_precision_1ms", - doc={"date": datetime(2000, 1, 1, 12, 0, 0, 1000, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "millisecond", "amount": 1}}, - expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract should decrement by exactly 1 millisecond", - ), - ExpressionTestCase( - "day_7_equals_week", - doc={"date": datetime(2000, 6, 8, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 7}}, - expected=datetime(2000, 6, 1, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract of 7 days should equal subtracting 1 week", - ), - ExpressionTestCase( - "month_12_equals_year", - doc={"date": datetime(2001, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 12}}, - expected=datetime(2000, 6, 15, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract of 12 months should equal subtracting 1 year", - ), - ExpressionTestCase( - "month_3_equals_quarter", - doc={"date": datetime(2000, 9, 15, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 3}}, - expected=datetime(2000, 6, 15, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract of 3 months should equal subtracting 1 quarter", - ), -] - -# Property [Timestamp Start Date]: a Timestamp or DatetimeMS start date is accepted and returns -# a Date. -DATESUBTRACT_TIMESTAMP_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "timestamp_startDate_day", - doc={"date": ts_from_args(2021, 1, 1, 12, 10, 5)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, - expected=datetime(2020, 12, 31, 12, 10, 5, tzinfo=timezone.utc), - msg="$dateSubtract should accept a Timestamp start date and return a Date", - ), - ExpressionTestCase( - "timestamp_startDate_second", - doc={"date": ts_from_args(2000, 1, 1, 12, 0, 1)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "second", "amount": 1}}, - expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract should subtract a second from a Timestamp start date", - ), - ExpressionTestCase( - "timestamp_epoch", - doc={"date": TS_EPOCH}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "second", "amount": -1}}, - expected=datetime(1970, 1, 1, 0, 0, 1, tzinfo=timezone.utc), - msg="$dateSubtract should accept an epoch Timestamp start date", - ), - ExpressionTestCase( - "date_ms_epoch_sub_day", - doc={"date": DATE_MS_EPOCH}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, - expected=datetime(1969, 12, 31, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract should subtract a day from an epoch DatetimeMS start date", - ), - ExpressionTestCase( - "ts_max_s32_sub_second", - doc={"date": TS_MAX_SIGNED32}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "second", "amount": 1}}, - expected=datetime(2038, 1, 19, 3, 14, 6, tzinfo=timezone.utc), - msg="$dateSubtract should subtract a second from a max signed 32-bit Timestamp", - ), - ExpressionTestCase( - "ts_max_u32_sub_second", - doc={"date": TS_MAX_UNSIGNED32}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "second", "amount": 1}}, - expected=datetime(2106, 2, 7, 6, 28, 14, tzinfo=timezone.utc), - msg="$dateSubtract should subtract a second from a max unsigned 32-bit Timestamp", - ), -] - -# Property [ObjectId Start Date]: an ObjectId start date uses its embedded timestamp and -# returns a Date. -DATESUBTRACT_OBJECTID_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "objectid_startDate_year", - doc={"date": oid_from_args(2023, 7, 15, 22, 32, 25)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 1}}, - expected=datetime(2022, 7, 15, 22, 32, 25, tzinfo=timezone.utc), - msg="$dateSubtract should accept an ObjectId start date and return a Date", - ), - ExpressionTestCase( - "objectid_startDate_second", - doc={"date": oid_from_args(2022, 7, 15, 22, 32, 26)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "second", "amount": 1}}, - expected=datetime(2022, 7, 15, 22, 32, 25, tzinfo=timezone.utc), - msg="$dateSubtract should subtract a second from an ObjectId start date", - ), - ExpressionTestCase( - "objectid_startDate_millisecond", - doc={"date": oid_from_args(2022, 7, 15, 22, 32, 26)}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "millisecond", "amount": 500}}, - expected=datetime(2022, 7, 15, 22, 32, 25, 500000, tzinfo=timezone.utc), - msg="$dateSubtract should subtract sub-second precision from an ObjectId start date", - ), - ExpressionTestCase( - "objectid_epoch", - doc={"date": OID_EPOCH}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": -1}}, - expected=datetime(1970, 1, 2, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateSubtract should accept an epoch ObjectId start date", - ), - ExpressionTestCase( - "objectid_max_signed32", - doc={"date": OID_MAX_SIGNED32}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "second", "amount": 1}}, - expected=datetime(2038, 1, 19, 3, 14, 6, tzinfo=timezone.utc), - msg="$dateSubtract should subtract a second from a max signed 32-bit ObjectId", - ), - ExpressionTestCase( - "objectid_high_bit_pre_epoch", - doc={"date": OID_MIN_SIGNED32}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, - expected=datetime(1901, 12, 12, 20, 45, 52, tzinfo=timezone.utc), - msg="$dateSubtract should handle an ObjectId with the high timestamp bit set", - ), -] - -DATESUBTRACT_DATE_SUCCESS_TESTS = ( - DATESUBTRACT_LEAP_YEAR_TESTS - + DATESUBTRACT_CENTURY_TESTS - + DATESUBTRACT_MONTH_CLAMP_TESTS - + DATESUBTRACT_BOUNDARY_TESTS - + DATESUBTRACT_HISTORICAL_TESTS - + DATESUBTRACT_EPOCH_TESTS - + DATESUBTRACT_DATE_LIMIT_TESTS - + DATESUBTRACT_UNIT_EQUIVALENCE_TESTS - + DATESUBTRACT_TIMESTAMP_TESTS - + DATESUBTRACT_OBJECTID_TESTS -) - -# Property [Overflow]: an amount that pushes the result beyond the representable date range -# is rejected. -DATESUBTRACT_OVERFLOW_ERROR_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "large_positive_month_overflow", - doc={"date": datetime(2020, 12, 31, 12, 10, 5, tzinfo=timezone.utc)}, - expression={ - "$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 30_000_000_000} - }, - error_code=DATEADD_INVALID_LARGE_VALUE_ERROR, - msg="$dateSubtract should reject a month amount that underflows the date range", - ), -] - -DATESUBTRACT_DATE_TESTS: list[ExpressionTestCase] = ( - DATESUBTRACT_DATE_SUCCESS_TESTS + DATESUBTRACT_OVERFLOW_ERROR_TESTS -) - - -@pytest.mark.parametrize("test_case", pytest_params(DATESUBTRACT_DATE_TESTS)) -def test_dateSubtract_dates(collection, test_case: ExpressionTestCase): - """Test $dateSubtract date edge cases.""" - result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) - assert_expression_result( - result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg - ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_errors.py similarity index 60% rename from documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_types.py rename to documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_errors.py index 0067d29af..a5ef68659 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_types.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_errors.py @@ -1,4 +1,4 @@ -"""$dateSubtract type validation: null/missing, invalid types, unit strings, argument shape.""" +"""$dateSubtract rejection cases: invalid operand types/values, timezone, overflow, and shape.""" from datetime import datetime, timezone @@ -14,135 +14,97 @@ ) from documentdb_tests.framework.error_codes import ( DATEADD_INVALID_AMOUNT_ERROR, + DATEADD_INVALID_LARGE_VALUE_ERROR, DATEADD_INVALID_STARTDATE_ERROR, DATEADD_MISSING_FIELD_ERROR, DATEADD_UNKNOWN_FIELD_ERROR, FAILED_TO_PARSE_ERROR, INVALID_DATE_UNIT_ERROR, + INVALID_TIMEZONE_ERROR, + INVALID_TIMEZONE_TYPE_ERROR, ) from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import ( DECIMAL128_INFINITY, DECIMAL128_NAN, DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_ONE_AND_HALF, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEAR_MIN, FLOAT_INFINITY, FLOAT_NAN, FLOAT_NEGATIVE_INFINITY, - MISSING, ) -# Property [Null Handling]: a null literal for startDate, amount, or unit returns null. -DATESUBTRACT_NULL_TESTS: list[ExpressionTestCase] = [ +# Property [Amount Non-Integral]: a non-integral numeric amount is rejected. +DATESUBTRACT_AMOUNT_ERROR_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( - "null_startDate", - doc={"date": None}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, - expected=None, - msg="$dateSubtract should return null for a null startDate", - ), - ExpressionTestCase( - "null_amount", - expression={ - "$dateSubtract": { - "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), - "unit": "day", - "amount": None, - } - }, - expected=None, - msg="$dateSubtract should return null for a null amount", - ), - ExpressionTestCase( - "null_unit", - expression={ - "$dateSubtract": { - "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), - "unit": None, - "amount": 1, - } - }, - expected=None, - msg="$dateSubtract should return null for a null unit", - ), - ExpressionTestCase( - "null_startDate_zero_amount", - doc={"date": None}, - expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 0}}, - expected=None, - msg="$dateSubtract should return null for a null startDate even with a zero amount", + "amount_non_integral_double_1_5", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1.5}}, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateSubtract should reject a non-integral double amount", ), ExpressionTestCase( - "all_null", - expression={"$dateSubtract": {"startDate": None, "unit": None, "amount": None}}, - expected=None, - msg="$dateSubtract should return null when all inputs are null", + "amount_non_integral_double_5_9", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 5.9}}, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateSubtract should reject a non-integral double amount close to an integer", ), -] - -# Property [Missing Field Reference]: a missing startDate, amount, unit, or timezone field -# reference returns null. -DATESUBTRACT_MISSING_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( - "missing_startDate", - expression={"$dateSubtract": {"startDate": MISSING, "unit": "day", "amount": 1}}, - expected=None, - msg="$dateSubtract should return null for a missing startDate field reference", + "amount_non_integral_double_negative", + doc={"date": datetime(2000, 1, 10, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": -5.9}}, + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateSubtract should reject a non-integral negative double amount", ), ExpressionTestCase( - "missing_amount", + "amount_non_integral_decimal128", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, expression={ "$dateSubtract": { - "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "startDate": "$date", "unit": "day", - "amount": MISSING, + "amount": DECIMAL128_ONE_AND_HALF, } }, - expected=None, - msg="$dateSubtract should return null for a missing amount field reference", + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateSubtract should reject a non-integral decimal128 amount", ), ExpressionTestCase( - "missing_unit", + "amount_decimal128_non_integral_34th_digit", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, expression={ "$dateSubtract": { - "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), - "unit": MISSING, - "amount": 1, + "startDate": "$date", + "unit": "day", + "amount": Decimal128("3.000000000000000000000000000000001"), } }, - expected=None, - msg="$dateSubtract should return null for a missing unit field reference", - ), - ExpressionTestCase( - "missing_startDate_zero_amount", - expression={"$dateSubtract": {"startDate": MISSING, "unit": "day", "amount": 0}}, - expected=None, - msg="$dateSubtract should return null for a missing startDate reference and zero amount", + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateSubtract should reject a decimal128 amount non-integral at the 34th digit", ), ExpressionTestCase( - "missing_timezone", + "amount_double_near_min", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, expression={ - "$dateSubtract": { - "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), - "unit": "day", - "amount": 1, - "timezone": MISSING, - } + "$dateSubtract": {"startDate": "$date", "unit": "day", "amount": DOUBLE_NEAR_MIN} }, - expected=None, - msg="$dateSubtract should return null for a missing timezone field reference", + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateSubtract should reject a near-minimum double amount as non-integral", ), ExpressionTestCase( - "missing_startDate_with_tz", + "amount_double_min_subnormal", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, expression={ - "$dateSubtract": {"startDate": MISSING, "unit": "day", "amount": 1, "timezone": "UTC"} + "$dateSubtract": {"startDate": "$date", "unit": "day", "amount": DOUBLE_MIN_SUBNORMAL} }, - expected=None, - msg="$dateSubtract should return null for a missing startDate even with a timezone", + error_code=DATEADD_INVALID_AMOUNT_ERROR, + msg="$dateSubtract should reject a minimum subnormal double amount as non-integral", ), ] -DATESUBTRACT_TYPE_SUCCESS_TESTS = DATESUBTRACT_NULL_TESTS + DATESUBTRACT_MISSING_TESTS - # Property [StartDate Type]: a non-date, non-Timestamp, non-ObjectId startDate is rejected. DATESUBTRACT_STARTDATE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( @@ -307,13 +269,94 @@ ] ] -DATESUBTRACT_TYPE_ERROR_TESTS = ( - DATESUBTRACT_STARTDATE_TYPE_ERROR_TESTS - + DATESUBTRACT_AMOUNT_TYPE_ERROR_TESTS - + DATESUBTRACT_AMOUNT_NONFINITE_ERROR_TESTS - + DATESUBTRACT_UNIT_TYPE_ERROR_TESTS - + DATESUBTRACT_UNIT_STRING_ERROR_TESTS -) +# Property [Invalid Timezone]: an unrecognized or malformed timezone string is rejected. +DATESUBTRACT_TIMEZONE_INVALID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"timezone_{tid}", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": {"startDate": "$date", "unit": "hour", "amount": 5, "timezone": tz} + }, + error_code=INVALID_TIMEZONE_ERROR, + msg=f"$dateSubtract should reject {desc}", + ) + for tid, tz, desc in [ + ("offset_3digit_hours_invalid", "+100:00", "a 3-digit hour offset"), + ("invalid", "Invalid/Timezone", "an unrecognized Olson timezone"), + ("empty_string", "", "an empty string timezone"), + ("olson_wrong_case_lowercase", "america/new_york", "an all-lowercase Olson name"), + ("olson_wrong_case_uppercase", "AMERICA/NEW_YORK", "an all-uppercase Olson name"), + ("olson_wrong_case_mixed", "america/New_York", "a mixed-case Olson name"), + ] +] + +# Property [Timezone Type]: a non-string timezone is rejected as an invalid type. +DATESUBTRACT_TIMEZONE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"timezone_{tid}", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": {"startDate": "$date", "unit": "hour", "amount": 5, "timezone": tz} + }, + error_code=INVALID_TIMEZONE_TYPE_ERROR, + msg=f"$dateSubtract should reject a {tid} timezone", + ) + for tid, tz in [ + ("int", 5), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", ["UTC"]), + ("object", {"tz": "UTC"}), + ("datetime", datetime(2000, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Overflow]: an amount that pushes the result beyond the representable date range +# is rejected. +DATESUBTRACT_OVERFLOW_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "large_positive_month_overflow", + doc={"date": datetime(2020, 12, 31, 12, 10, 5, tzinfo=timezone.utc)}, + expression={ + "$dateSubtract": {"startDate": "$date", "unit": "month", "amount": 30_000_000_000} + }, + error_code=DATEADD_INVALID_LARGE_VALUE_ERROR, + msg="$dateSubtract should reject a month amount that underflows the date range", + ), +] + +# Property [Array-Resolving Path]: a startDate field path that resolves to an array is rejected +# by the operator's startDate type contract. +DATESUBTRACT_ARRAY_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "composite_array_path", + doc={ + "a": [ + {"b": datetime(2021, 6, 15, tzinfo=timezone.utc)}, + {"b": datetime(2021, 7, 1, tzinfo=timezone.utc)}, + ] + }, + expression={"$dateSubtract": {"startDate": "$a.b", "unit": "day", "amount": 1}}, + error_code=DATEADD_INVALID_STARTDATE_ERROR, + msg="$dateSubtract should reject a composite array field path as startDate", + ), + ExpressionTestCase( + "single_element_array_path", + doc={"a": [{"b": datetime(2021, 6, 15, tzinfo=timezone.utc)}]}, + expression={"$dateSubtract": {"startDate": "$a.b", "unit": "day", "amount": 1}}, + error_code=DATEADD_INVALID_STARTDATE_ERROR, + msg="$dateSubtract should reject a single-element array field path as startDate", + ), +] # Property [Argument Shape]: a missing required field or an unknown field is rejected. DATESUBTRACT_ARGUMENT_TESTS: list[ExpressionTestCase] = [ @@ -366,14 +409,24 @@ ), ] -DATESUBTRACT_TYPE_TESTS: list[ExpressionTestCase] = ( - DATESUBTRACT_TYPE_SUCCESS_TESTS + DATESUBTRACT_TYPE_ERROR_TESTS + DATESUBTRACT_ARGUMENT_TESTS +DATESUBTRACT_ERROR_TESTS: list[ExpressionTestCase] = ( + DATESUBTRACT_AMOUNT_ERROR_TESTS + + DATESUBTRACT_STARTDATE_TYPE_ERROR_TESTS + + DATESUBTRACT_AMOUNT_TYPE_ERROR_TESTS + + DATESUBTRACT_AMOUNT_NONFINITE_ERROR_TESTS + + DATESUBTRACT_UNIT_TYPE_ERROR_TESTS + + DATESUBTRACT_UNIT_STRING_ERROR_TESTS + + DATESUBTRACT_TIMEZONE_INVALID_TESTS + + DATESUBTRACT_TIMEZONE_TYPE_ERROR_TESTS + + DATESUBTRACT_OVERFLOW_ERROR_TESTS + + DATESUBTRACT_ARRAY_PATH_TESTS + + DATESUBTRACT_ARGUMENT_TESTS ) -@pytest.mark.parametrize("test_case", pytest_params(DATESUBTRACT_TYPE_TESTS)) -def test_dateSubtract_types(collection, test_case: ExpressionTestCase): - """Test $dateSubtract type validation and argument handling.""" +@pytest.mark.parametrize("test_case", pytest_params(DATESUBTRACT_ERROR_TESTS)) +def test_dateSubtract_errors(collection, test_case: ExpressionTestCase): + """Test $dateSubtract rejects invalid operands, timezones, overflow, and argument shapes.""" result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_expressions.py index 798b8794c..dba41131a 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_expressions.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_expressions.py @@ -1,9 +1,8 @@ -"""$dateSubtract expression inputs, array-resolving field paths, and Date return type.""" +"""$dateSubtract operand evaluation: literal operands and field-reference resolution.""" from datetime import datetime, timezone import pytest -from bson import ObjectId, Timestamp from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( ExpressionTestCase, @@ -12,7 +11,6 @@ assert_expression_result, execute_expression_with_insert, ) -from documentdb_tests.framework.error_codes import DATEADD_INVALID_STARTDATE_ERROR from documentdb_tests.framework.parametrize import pytest_params # Property [Literal Input]: $dateSubtract evaluates literal operands. @@ -79,67 +77,14 @@ ), ] -# Property [Array-Resolving Path]: a startDate field path that resolves to an array is rejected -# by the operator's startDate type contract. -DATESUBTRACT_ARRAY_PATH_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "composite_array_path", - doc={ - "a": [ - {"b": datetime(2021, 6, 15, tzinfo=timezone.utc)}, - {"b": datetime(2021, 7, 1, tzinfo=timezone.utc)}, - ] - }, - expression={"$dateSubtract": {"startDate": "$a.b", "unit": "day", "amount": 1}}, - error_code=DATEADD_INVALID_STARTDATE_ERROR, - msg="$dateSubtract should reject a composite array field path as startDate", - ), - ExpressionTestCase( - "single_element_array_path", - doc={"a": [{"b": datetime(2021, 6, 15, tzinfo=timezone.utc)}]}, - expression={"$dateSubtract": {"startDate": "$a.b", "unit": "day", "amount": 1}}, - error_code=DATEADD_INVALID_STARTDATE_ERROR, - msg="$dateSubtract should reject a single-element array field path as startDate", - ), -] - -# Property [Return Type]: $dateSubtract returns a Date regardless of the start date's date-like -# type. -DATESUBTRACT_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "return_type_from_date", - doc={"date": datetime(2021, 1, 1, tzinfo=timezone.utc)}, - expression={"$type": {"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}}, - expected="date", - msg="$dateSubtract should return a Date from a Date start date", - ), - ExpressionTestCase( - "return_type_from_timestamp", - doc={"date": Timestamp(1609459200, 1)}, - expression={"$type": {"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}}, - expected="date", - msg="$dateSubtract should return a Date from a Timestamp start date", - ), - ExpressionTestCase( - "return_type_from_objectid", - doc={"date": ObjectId("600000000000000000000000")}, - expression={"$type": {"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}}, - expected="date", - msg="$dateSubtract should return a Date from an ObjectId start date", - ), -] - DATESUBTRACT_EXPRESSION_TESTS: list[ExpressionTestCase] = ( - DATESUBTRACT_LITERAL_TESTS - + DATESUBTRACT_FIELD_REF_TESTS - + DATESUBTRACT_ARRAY_PATH_TESTS - + DATESUBTRACT_RETURN_TYPE_TESTS + DATESUBTRACT_LITERAL_TESTS + DATESUBTRACT_FIELD_REF_TESTS ) @pytest.mark.parametrize("test_case", pytest_params(DATESUBTRACT_EXPRESSION_TESTS)) def test_dateSubtract_expressions(collection, test_case: ExpressionTestCase): - """Test $dateSubtract expression inputs and return type.""" + """Test $dateSubtract evaluates literal and field-reference operands.""" result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_input_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_input_types.py new file mode 100644 index 000000000..a8a6bca7e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_input_types.py @@ -0,0 +1,161 @@ +"""$dateSubtract date-like input types: Timestamp and ObjectId start dates always return a Date.""" + +from datetime import datetime, timezone + +import pytest +from bson import ObjectId, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( + oid_from_args, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_MS_EPOCH, + OID_EPOCH, + OID_MAX_SIGNED32, + OID_MIN_SIGNED32, + TS_EPOCH, + TS_MAX_SIGNED32, + TS_MAX_UNSIGNED32, +) + +# Property [Timestamp Start Date]: a Timestamp or DatetimeMS start date is accepted and returns +# a Date. +DATESUBTRACT_TIMESTAMP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "timestamp_startDate_day", + doc={"date": ts_from_args(2021, 1, 1, 12, 10, 5)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(2020, 12, 31, 12, 10, 5, tzinfo=timezone.utc), + msg="$dateSubtract should accept a Timestamp start date and return a Date", + ), + ExpressionTestCase( + "timestamp_startDate_second", + doc={"date": ts_from_args(2000, 1, 1, 12, 0, 1)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract a second from a Timestamp start date", + ), + ExpressionTestCase( + "timestamp_epoch", + doc={"date": TS_EPOCH}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "second", "amount": -1}}, + expected=datetime(1970, 1, 1, 0, 0, 1, tzinfo=timezone.utc), + msg="$dateSubtract should accept an epoch Timestamp start date", + ), + ExpressionTestCase( + "date_ms_epoch_sub_day", + doc={"date": DATE_MS_EPOCH}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1969, 12, 31, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should subtract a day from an epoch DatetimeMS start date", + ), + ExpressionTestCase( + "ts_max_s32_sub_second", + doc={"date": TS_MAX_SIGNED32}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2038, 1, 19, 3, 14, 6, tzinfo=timezone.utc), + msg="$dateSubtract should subtract a second from a max signed 32-bit Timestamp", + ), + ExpressionTestCase( + "ts_max_u32_sub_second", + doc={"date": TS_MAX_UNSIGNED32}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2106, 2, 7, 6, 28, 14, tzinfo=timezone.utc), + msg="$dateSubtract should subtract a second from a max unsigned 32-bit Timestamp", + ), +] + +# Property [ObjectId Start Date]: an ObjectId start date uses its embedded timestamp and +# returns a Date. +DATESUBTRACT_OBJECTID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "objectid_startDate_year", + doc={"date": oid_from_args(2023, 7, 15, 22, 32, 25)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "year", "amount": 1}}, + expected=datetime(2022, 7, 15, 22, 32, 25, tzinfo=timezone.utc), + msg="$dateSubtract should accept an ObjectId start date and return a Date", + ), + ExpressionTestCase( + "objectid_startDate_second", + doc={"date": oid_from_args(2022, 7, 15, 22, 32, 26)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2022, 7, 15, 22, 32, 25, tzinfo=timezone.utc), + msg="$dateSubtract should subtract a second from an ObjectId start date", + ), + ExpressionTestCase( + "objectid_startDate_millisecond", + doc={"date": oid_from_args(2022, 7, 15, 22, 32, 26)}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "millisecond", "amount": 500}}, + expected=datetime(2022, 7, 15, 22, 32, 25, 500000, tzinfo=timezone.utc), + msg="$dateSubtract should subtract sub-second precision from an ObjectId start date", + ), + ExpressionTestCase( + "objectid_epoch", + doc={"date": OID_EPOCH}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": -1}}, + expected=datetime(1970, 1, 2, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should accept an epoch ObjectId start date", + ), + ExpressionTestCase( + "objectid_max_signed32", + doc={"date": OID_MAX_SIGNED32}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "second", "amount": 1}}, + expected=datetime(2038, 1, 19, 3, 14, 6, tzinfo=timezone.utc), + msg="$dateSubtract should subtract a second from a max signed 32-bit ObjectId", + ), + ExpressionTestCase( + "objectid_high_bit_pre_epoch", + doc={"date": OID_MIN_SIGNED32}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=datetime(1901, 12, 12, 20, 45, 52, tzinfo=timezone.utc), + msg="$dateSubtract should handle an ObjectId with the high timestamp bit set", + ), +] + +# Property [Return Type]: $dateSubtract returns a Date regardless of the start date's date-like +# type. +DATESUBTRACT_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type_from_date", + doc={"date": datetime(2021, 1, 1, tzinfo=timezone.utc)}, + expression={"$type": {"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}}, + expected="date", + msg="$dateSubtract should return a Date from a Date start date", + ), + ExpressionTestCase( + "return_type_from_timestamp", + doc={"date": Timestamp(1609459200, 1)}, + expression={"$type": {"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}}, + expected="date", + msg="$dateSubtract should return a Date from a Timestamp start date", + ), + ExpressionTestCase( + "return_type_from_objectid", + doc={"date": ObjectId("600000000000000000000000")}, + expression={"$type": {"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}}, + expected="date", + msg="$dateSubtract should return a Date from an ObjectId start date", + ), +] + +DATESUBTRACT_INPUT_TYPE_TESTS: list[ExpressionTestCase] = ( + DATESUBTRACT_TIMESTAMP_TESTS + DATESUBTRACT_OBJECTID_TESTS + DATESUBTRACT_RETURN_TYPE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATESUBTRACT_INPUT_TYPE_TESTS)) +def test_dateSubtract_input_types(collection, test_case: ExpressionTestCase): + """Test $dateSubtract accepts date-like input types and always returns a Date.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_null.py new file mode 100644 index 000000000..4022f7ab4 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_null.py @@ -0,0 +1,140 @@ +"""$dateSubtract null and missing propagation: a null or missing operand returns null.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + MISSING, +) + +# Property [Null Handling]: a null literal for startDate, amount, or unit returns null. +DATESUBTRACT_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_startDate", + doc={"date": None}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 1}}, + expected=None, + msg="$dateSubtract should return null for a null startDate", + ), + ExpressionTestCase( + "null_amount", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": None, + } + }, + expected=None, + msg="$dateSubtract should return null for a null amount", + ), + ExpressionTestCase( + "null_unit", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": None, + "amount": 1, + } + }, + expected=None, + msg="$dateSubtract should return null for a null unit", + ), + ExpressionTestCase( + "null_startDate_zero_amount", + doc={"date": None}, + expression={"$dateSubtract": {"startDate": "$date", "unit": "day", "amount": 0}}, + expected=None, + msg="$dateSubtract should return null for a null startDate even with a zero amount", + ), + ExpressionTestCase( + "all_null", + expression={"$dateSubtract": {"startDate": None, "unit": None, "amount": None}}, + expected=None, + msg="$dateSubtract should return null when all inputs are null", + ), +] + +# Property [Missing Field Reference]: a missing startDate, amount, unit, or timezone field +# reference returns null. +DATESUBTRACT_MISSING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_startDate", + expression={"$dateSubtract": {"startDate": MISSING, "unit": "day", "amount": 1}}, + expected=None, + msg="$dateSubtract should return null for a missing startDate field reference", + ), + ExpressionTestCase( + "missing_amount", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": MISSING, + } + }, + expected=None, + msg="$dateSubtract should return null for a missing amount field reference", + ), + ExpressionTestCase( + "missing_unit", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": MISSING, + "amount": 1, + } + }, + expected=None, + msg="$dateSubtract should return null for a missing unit field reference", + ), + ExpressionTestCase( + "missing_startDate_zero_amount", + expression={"$dateSubtract": {"startDate": MISSING, "unit": "day", "amount": 0}}, + expected=None, + msg="$dateSubtract should return null for a missing startDate reference and zero amount", + ), + ExpressionTestCase( + "missing_timezone", + expression={ + "$dateSubtract": { + "startDate": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "amount": 1, + "timezone": MISSING, + } + }, + expected=None, + msg="$dateSubtract should return null for a missing timezone field reference", + ), + ExpressionTestCase( + "missing_startDate_with_tz", + expression={ + "$dateSubtract": {"startDate": MISSING, "unit": "day", "amount": 1, "timezone": "UTC"} + }, + expected=None, + msg="$dateSubtract should return null for a missing startDate even with a timezone", + ), +] + +DATESUBTRACT_NULL_MISSING_TESTS: list[ExpressionTestCase] = ( + DATESUBTRACT_NULL_TESTS + DATESUBTRACT_MISSING_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATESUBTRACT_NULL_MISSING_TESTS)) +def test_dateSubtract_null(collection, test_case: ExpressionTestCase): + """Test $dateSubtract returns null for null literals and missing field references.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_timezone.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_timezone.py index 5da11bd64..cb96e147b 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_timezone.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_timezone.py @@ -1,9 +1,8 @@ -"""$dateSubtract timezone: Olson IDs, UTC offsets, DST, null, and invalid values and types.""" +"""$dateSubtract timezone handling: Olson ids, UTC offsets, DST, and field references.""" 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.expressions.utils import ( ExpressionTestCase, @@ -12,10 +11,6 @@ assert_expression_result, execute_expression_with_insert, ) -from documentdb_tests.framework.error_codes import ( - INVALID_TIMEZONE_ERROR, - INVALID_TIMEZONE_TYPE_ERROR, -) from documentdb_tests.framework.parametrize import pytest_params # Property [Olson Timezone]: a valid Olson timezone id is accepted. @@ -305,7 +300,7 @@ ), ] -DATESUBTRACT_TIMEZONE_SUCCESS_TESTS = ( +DATESUBTRACT_TIMEZONE_TESTS: list[ExpressionTestCase] = ( DATESUBTRACT_TIMEZONE_OLSON_TESTS + DATESUBTRACT_TIMEZONE_OFFSET_TESTS + DATESUBTRACT_TIMEZONE_DST_TESTS @@ -313,69 +308,10 @@ + DATESUBTRACT_TIMEZONE_NULL_TESTS ) -# Property [Invalid Timezone]: an unrecognized or malformed timezone string is rejected. -DATESUBTRACT_TIMEZONE_INVALID_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - f"timezone_{tid}", - doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={ - "$dateSubtract": {"startDate": "$date", "unit": "hour", "amount": 5, "timezone": tz} - }, - error_code=INVALID_TIMEZONE_ERROR, - msg=f"$dateSubtract should reject {desc}", - ) - for tid, tz, desc in [ - ("offset_3digit_hours_invalid", "+100:00", "a 3-digit hour offset"), - ("invalid", "Invalid/Timezone", "an unrecognized Olson timezone"), - ("empty_string", "", "an empty string timezone"), - ("olson_wrong_case_lowercase", "america/new_york", "an all-lowercase Olson name"), - ("olson_wrong_case_uppercase", "AMERICA/NEW_YORK", "an all-uppercase Olson name"), - ("olson_wrong_case_mixed", "america/New_York", "a mixed-case Olson name"), - ] -] - -# Property [Timezone Type]: a non-string timezone is rejected as an invalid type. -DATESUBTRACT_TIMEZONE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - f"timezone_{tid}", - doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, - expression={ - "$dateSubtract": {"startDate": "$date", "unit": "hour", "amount": 5, "timezone": tz} - }, - error_code=INVALID_TIMEZONE_TYPE_ERROR, - msg=f"$dateSubtract should reject a {tid} timezone", - ) - for tid, tz in [ - ("int", 5), - ("int64", Int64(1)), - ("double", 1.0), - ("decimal128", Decimal128("1")), - ("boolean", True), - ("array", ["UTC"]), - ("object", {"tz": "UTC"}), - ("datetime", datetime(2000, 1, 1, tzinfo=timezone.utc)), - ("objectid", ObjectId("600000000000000000000000")), - ("timestamp", Timestamp(1, 1)), - ("binary", Binary(b"\x01\x02\x03")), - ("regex", Regex(".*")), - ("javascript", Code("function() {}")), - ("minkey", MinKey()), - ("maxkey", MaxKey()), - ] -] - -DATESUBTRACT_TIMEZONE_ERROR_TESTS = ( - DATESUBTRACT_TIMEZONE_INVALID_TESTS + DATESUBTRACT_TIMEZONE_TYPE_ERROR_TESTS -) - -DATESUBTRACT_TIMEZONE_TESTS = ( - DATESUBTRACT_TIMEZONE_SUCCESS_TESTS + DATESUBTRACT_TIMEZONE_ERROR_TESTS -) - @pytest.mark.parametrize("test_case", pytest_params(DATESUBTRACT_TIMEZONE_TESTS)) def test_dateSubtract_timezone(collection, test_case: ExpressionTestCase): - """Test $dateSubtract timezone handling.""" + """Test $dateSubtract applies the timezone operand for calendar-aware units.""" result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_arguments.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_arguments.py new file mode 100644 index 000000000..699ffdcb8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_arguments.py @@ -0,0 +1,102 @@ +"""$dateTrunc optional-argument acceptance: required plus optional fields and binSize types.""" + +from datetime import datetime, timezone + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Argument Handling]: $dateTrunc accepts the required date and unit plus the optional +# binSize, timezone, and startOfWeek parameters. +DATETRUNC_ARG_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arg_required_only", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour"}}, + expected=datetime(2021, 3, 20, 11, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate with the required fields only", + ), + ExpressionTestCase( + "arg_with_binSize", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "binSize": 2}}, + expected=datetime(2021, 3, 20, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a binSize", + ), + ExpressionTestCase( + "arg_with_timezone", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "UTC"}}, + expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a timezone", + ), + ExpressionTestCase( + "arg_with_startOfWeek", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "monday"}}, + expected=datetime(2021, 3, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a startOfWeek", + ), + ExpressionTestCase( + "arg_all_fields", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={ + "$dateTrunc": { + "date": "$date", + "unit": "week", + "binSize": 1, + "timezone": "UTC", + "startOfWeek": "monday", + } + }, + expected=datetime(2021, 3, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept all fields together", + ), +] + +# Property [BinSize Numeric Types]: integral int64, decimal128, and double binSize values are +# accepted. +DATETRUNC_BINSIZE_ACCEPT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "binSize_int64", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "binSize": Int64(2)}}, + expected=datetime(2021, 3, 20, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept an int64 binSize", + ), + ExpressionTestCase( + "binSize_decimal128", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "binSize": Decimal128("2")}}, + expected=datetime(2021, 3, 20, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a decimal128 binSize", + ), + ExpressionTestCase( + "binSize_double_integral", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "binSize": 2.0}}, + expected=datetime(2021, 3, 20, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept an integral double binSize", + ), +] + +DATETRUNC_ARGUMENT_TESTS: list[ExpressionTestCase] = ( + DATETRUNC_ARG_TESTS + DATETRUNC_BINSIZE_ACCEPT_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETRUNC_ARGUMENT_TESTS)) +def test_dateTrunc_arguments(collection, test_case: ExpressionTestCase): + """Test $dateTrunc accepts its optional arguments and numeric binSize types.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_basic.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_basic.py deleted file mode 100644 index d26756a70..000000000 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_basic.py +++ /dev/null @@ -1,885 +0,0 @@ -"""$dateTrunc argument handling, null/missing, and type/value validation of every parameter.""" - -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.expressions.utils import ( - ExpressionTestCase, -) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( - assert_expression_result, - execute_expression_with_insert, -) -from documentdb_tests.framework.error_codes import ( - DATETRUNC_BINSIZE_OVERFLOW_HOUR_ERROR, - DATETRUNC_BINSIZE_OVERFLOW_YEAR_ERROR, - DATETRUNC_INVALID_BINSIZE_ERROR, - DATETRUNC_INVALID_BINSIZE_VALUE_ERROR, - DATETRUNC_MISSING_DATE_ERROR, - DATETRUNC_MISSING_UNIT_ERROR, - DATETRUNC_NON_OBJECT_ERROR, - DATETRUNC_UNKNOWN_FIELD_ERROR, - FAILED_TO_PARSE_ERROR, - INVALID_DATE_UNIT_ERROR, - INVALID_DATE_VALUE_ERROR, - INVALID_STARTOFWEEK_ERROR, - INVALID_STARTOFWEEK_TYPE_ERROR, - INVALID_TIMEZONE_ERROR, - INVALID_TIMEZONE_TYPE_ERROR, -) -from documentdb_tests.framework.parametrize import pytest_params -from documentdb_tests.framework.test_constants import ( - DECIMAL128_HALF, - DECIMAL128_INFINITY, - DECIMAL128_NAN, - DECIMAL128_NEGATIVE_INFINITY, - FLOAT_INFINITY, - FLOAT_NAN, - MISSING, -) - -# Property [Argument Handling]: $dateTrunc accepts the required date and unit plus the optional -# binSize, timezone, and startOfWeek parameters. -DATETRUNC_ARG_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "arg_required_only", - doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "hour"}}, - expected=datetime(2021, 3, 20, 11, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate with the required fields only", - ), - ExpressionTestCase( - "arg_with_binSize", - doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "hour", "binSize": 2}}, - expected=datetime(2021, 3, 20, 10, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept a binSize", - ), - ExpressionTestCase( - "arg_with_timezone", - doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "UTC"}}, - expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept a timezone", - ), - ExpressionTestCase( - "arg_with_startOfWeek", - doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "monday"}}, - expected=datetime(2021, 3, 15, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept a startOfWeek", - ), - ExpressionTestCase( - "arg_all_fields", - doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, - expression={ - "$dateTrunc": { - "date": "$date", - "unit": "week", - "binSize": 1, - "timezone": "UTC", - "startOfWeek": "monday", - } - }, - expected=datetime(2021, 3, 15, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept all fields together", - ), -] - -# Property [BinSize Numeric Types]: integral int64, decimal128, and double binSize values are -# accepted. -DATETRUNC_BINSIZE_ACCEPT_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "binSize_int64", - doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "hour", "binSize": Int64(2)}}, - expected=datetime(2021, 3, 20, 10, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept an int64 binSize", - ), - ExpressionTestCase( - "binSize_decimal128", - doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "hour", "binSize": Decimal128("2")}}, - expected=datetime(2021, 3, 20, 10, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept a decimal128 binSize", - ), - ExpressionTestCase( - "binSize_double_integral", - doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "hour", "binSize": 2.0}}, - expected=datetime(2021, 3, 20, 10, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept an integral double binSize", - ), -] - -# Property [Timezone Extremes]: out-of-range two-digit offsets and additional named zones are -# accepted. -DATETRUNC_TIMEZONE_ACCEPT_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "tz_over60_minutes_positive", - doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "+05:70"}}, - expected=datetime(2021, 6, 15, 11, 50, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept a +05:70 offset with over-60 minutes", - ), - ExpressionTestCase( - "tz_over60_minutes_negative", - doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "-05:70"}}, - expected=datetime(2021, 6, 15, 11, 10, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept a -05:70 offset with over-60 minutes", - ), - ExpressionTestCase( - "tz_over24_hours_positive", - doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "+25:00"}}, - expected=datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept a +25:00 offset with over-24 hours", - ), - ExpressionTestCase( - "tz_over24_hours_negative", - doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "-25:00"}}, - expected=datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept a -25:00 offset with over-24 hours", - ), - ExpressionTestCase( - "tz_max_valid_positive", - doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "+99:99"}}, - expected=datetime(2021, 6, 15, 11, 21, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept the maximum two-digit +99:99 offset", - ), - ExpressionTestCase( - "tz_max_valid_negative", - doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "-99:99"}}, - expected=datetime(2021, 6, 15, 11, 39, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept the maximum two-digit -99:99 offset", - ), - ExpressionTestCase( - "tz_pacific_apia", - doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "Pacific/Apia"}}, - expected=datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept the Pacific/Apia named zone", - ), - ExpressionTestCase( - "tz_offset_45min", - doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "+05:45"}}, - expected=datetime(2021, 6, 15, 11, 15, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept a +05:45 offset with 45 minutes", - ), - ExpressionTestCase( - "tz_offset_half_hour_west", - doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "-02:30"}}, - expected=datetime(2021, 6, 15, 11, 30, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept a -02:30 half-hour offset", - ), - ExpressionTestCase( - "tz_offset_max_east", - doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "+14:00"}}, - expected=datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept the +14:00 maximum east offset", - ), - ExpressionTestCase( - "tz_offset_max_west", - doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "-11:00"}}, - expected=datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept the -11:00 maximum west offset", - ), -] - -# Property [StartOfWeek Case]: startOfWeek accepts full day names and three-letter abbreviations -# in any case. -DATETRUNC_STARTOFWEEK_ACCEPT_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "sow_mixed_case_Monday", - doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "Monday"}}, - expected=datetime(2021, 6, 14, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept a mixed-case Monday", - ), - ExpressionTestCase( - "sow_uppercase_MONDAY", - doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "MONDAY"}}, - expected=datetime(2021, 6, 14, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept an uppercase MONDAY", - ), - ExpressionTestCase( - "sow_abbrev_MON", - doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "MON"}}, - expected=datetime(2021, 6, 14, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept an uppercase abbreviation MON", - ), - ExpressionTestCase( - "sow_mixed_case_Friday", - doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "Friday"}}, - expected=datetime(2021, 6, 11, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept a mixed-case Friday", - ), - ExpressionTestCase( - "sow_uppercase_FRIDAY", - doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "FRIDAY"}}, - expected=datetime(2021, 6, 11, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept an uppercase FRIDAY", - ), - ExpressionTestCase( - "sow_abbrev_FRI", - doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "FRI"}}, - expected=datetime(2021, 6, 11, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept an uppercase abbreviation FRI", - ), - ExpressionTestCase( - "sow_uppercase_SUNDAY", - doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "SUNDAY"}}, - expected=datetime(2021, 6, 13, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept an uppercase SUNDAY", - ), - ExpressionTestCase( - "sow_abbrev_SUN", - doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "SUN"}}, - expected=datetime(2021, 6, 13, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept an uppercase abbreviation SUN", - ), -] - -# Property [StartOfWeek Relevance]: for a non-week unit a null startOfWeek is ignored when the date -# is a constant literal, but short-circuits the result to null when the date is a field reference. -DATETRUNC_STARTOFWEEK_RELEVANCE_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "sow_relevance_literal_day", - expression={ - "$dateTrunc": { - "date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc), - "unit": "day", - "startOfWeek": None, - } - }, - expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should ignore a null startOfWeek for a non-week unit when the date is a " - "constant", - ), - ExpressionTestCase( - "sow_relevance_field_ref_day", - doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "day", "startOfWeek": None}}, - expected=None, - msg="$dateTrunc should return null for a null startOfWeek on a non-week unit when the date " - "is a field reference", - ), -] - -# Property [Null Handling]: a null date, unit, binSize, timezone, or week startOfWeek returns null. -DATETRUNC_NULL_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "null_date", - doc={"date": None}, - expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, - expected=None, - msg="$dateTrunc should return null for a null date", - ), - ExpressionTestCase( - "null_unit", - expression={ - "$dateTrunc": {"date": datetime(2021, 1, 1, tzinfo=timezone.utc), "unit": None} - }, - expected=None, - msg="$dateTrunc should return null for a null unit", - ), - ExpressionTestCase( - "null_binSize", - expression={ - "$dateTrunc": { - "date": datetime(2021, 1, 1, tzinfo=timezone.utc), - "unit": "day", - "binSize": None, - } - }, - expected=None, - msg="$dateTrunc should return null for a null binSize", - ), - ExpressionTestCase( - "null_timezone", - expression={ - "$dateTrunc": { - "date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc), - "unit": "day", - "timezone": None, - } - }, - expected=None, - msg="$dateTrunc should return null for a null timezone", - ), - ExpressionTestCase( - "null_startOfWeek_week", - expression={ - "$dateTrunc": { - "date": datetime(2021, 1, 1, tzinfo=timezone.utc), - "unit": "week", - "startOfWeek": None, - } - }, - expected=None, - msg="$dateTrunc should return null for a null startOfWeek with the week unit", - ), -] - -# Property [Missing Field Reference]: a missing field reference for date or any optional parameter -# returns null. -DATETRUNC_MISSING_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "missing_date_ref", - expression={"$dateTrunc": {"date": MISSING, "unit": "day"}}, - expected=None, - msg="$dateTrunc should return null for a missing date field reference", - ), - ExpressionTestCase( - "missing_binSize_ref", - expression={ - "$dateTrunc": { - "date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc), - "unit": "hour", - "binSize": MISSING, - } - }, - expected=None, - msg="$dateTrunc should return null for a missing binSize field reference", - ), - ExpressionTestCase( - "missing_timezone_ref", - expression={ - "$dateTrunc": { - "date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc), - "unit": "day", - "timezone": MISSING, - } - }, - expected=None, - msg="$dateTrunc should return null for a missing timezone field reference", - ), - ExpressionTestCase( - "missing_startOfWeek_ref", - expression={ - "$dateTrunc": { - "date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc), - "unit": "week", - "startOfWeek": MISSING, - } - }, - expected=None, - msg="$dateTrunc should return null for a missing startOfWeek field reference", - ), -] - -DATETRUNC_SUCCESS_TESTS: list[ExpressionTestCase] = ( - DATETRUNC_ARG_TESTS - + DATETRUNC_BINSIZE_ACCEPT_TESTS - + DATETRUNC_TIMEZONE_ACCEPT_TESTS - + DATETRUNC_STARTOFWEEK_ACCEPT_TESTS - + DATETRUNC_STARTOFWEEK_RELEVANCE_TESTS - + DATETRUNC_NULL_TESTS - + DATETRUNC_MISSING_TESTS -) - -# Property [Argument Shape]: a missing required field, an empty object, an unknown field, or a -# non-object argument is rejected. -DATETRUNC_ARG_ERROR_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "arg_missing_date", - expression={"$dateTrunc": {"unit": "day"}}, - error_code=DATETRUNC_MISSING_DATE_ERROR, - msg="$dateTrunc should error when date is missing", - ), - ExpressionTestCase( - "arg_missing_unit", - expression={"$dateTrunc": {"date": datetime(2021, 1, 1, tzinfo=timezone.utc)}}, - error_code=DATETRUNC_MISSING_UNIT_ERROR, - msg="$dateTrunc should error when unit is missing", - ), - ExpressionTestCase( - "arg_empty_object", - expression={"$dateTrunc": {}}, - error_code=DATETRUNC_MISSING_DATE_ERROR, - msg="$dateTrunc should error for an empty argument object", - ), - ExpressionTestCase( - "arg_unknown_field", - expression={ - "$dateTrunc": { - "date": datetime(2021, 1, 1, tzinfo=timezone.utc), - "unit": "day", - "foo": 1, - } - }, - error_code=DATETRUNC_UNKNOWN_FIELD_ERROR, - msg="$dateTrunc should error for an unknown field", - ), - ExpressionTestCase( - "arg_non_object_string", - expression={"$dateTrunc": "string"}, - error_code=DATETRUNC_NON_OBJECT_ERROR, - msg="$dateTrunc should reject a string argument", - ), - ExpressionTestCase( - "arg_non_object_array", - expression={"$dateTrunc": [1, 2]}, - error_code=DATETRUNC_NON_OBJECT_ERROR, - msg="$dateTrunc should reject an array argument", - ), - ExpressionTestCase( - "arg_non_object_number", - expression={"$dateTrunc": 123}, - error_code=DATETRUNC_NON_OBJECT_ERROR, - msg="$dateTrunc should reject a numeric argument", - ), -] - -# Property [Date Type]: a non-date, non-ObjectId, non-Timestamp date value is rejected. -DATETRUNC_DATE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ - *[ - ExpressionTestCase( - f"date_{tid}", - doc={"date": val}, - expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, - error_code=INVALID_DATE_VALUE_ERROR, - msg=f"$dateTrunc should reject a {tid} date", - ) - for tid, val in [ - ("string", "2021"), - ("int", 123), - ("int64", Int64(123)), - ("double", 1.0), - ("decimal128", Decimal128("1")), - ("boolean", True), - ("array", [1, 2]), - ("empty_array", []), - ("object", {"a": 1}), - ("binary", Binary(b"\x01\x02\x03")), - ("regex", Regex(".*")), - ("javascript", Code("function() {}")), - ("minkey", MinKey()), - ("maxkey", MaxKey()), - ] - ], - ExpressionTestCase( - "date_boolean_false", - doc={"date": False}, - expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, - error_code=INVALID_DATE_VALUE_ERROR, - msg="$dateTrunc should reject a false boolean date", - ), - ExpressionTestCase( - "date_single_date_array", - doc={"date": [datetime(2021, 6, 15, tzinfo=timezone.utc)]}, - expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, - error_code=INVALID_DATE_VALUE_ERROR, - msg="$dateTrunc should reject a single-element array containing a date", - ), - ExpressionTestCase( - "date_decimal128_infinity", - doc={"date": DECIMAL128_INFINITY}, - expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, - error_code=INVALID_DATE_VALUE_ERROR, - msg="$dateTrunc should reject a Decimal128 Infinity date", - ), - ExpressionTestCase( - "date_decimal128_neg_infinity", - doc={"date": DECIMAL128_NEGATIVE_INFINITY}, - expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, - error_code=INVALID_DATE_VALUE_ERROR, - msg="$dateTrunc should reject a Decimal128 -Infinity date", - ), -] - -# Property [Unit Type]: a non-string unit is rejected as an invalid date unit. -DATETRUNC_UNIT_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - f"unit_{tid}", - expression={"$dateTrunc": {"date": datetime(2021, 1, 1, tzinfo=timezone.utc), "unit": val}}, - error_code=INVALID_DATE_UNIT_ERROR, - msg=f"$dateTrunc should reject a {tid} unit", - ) - for tid, val in [ - ("int", 1), - ("int64", Int64(1)), - ("double", 1.0), - ("decimal128", Decimal128("1")), - ("boolean", True), - ("array", ["day"]), - ("empty_array", []), - ("object", {"t": "day"}), - ("datetime", datetime(2021, 1, 1, tzinfo=timezone.utc)), - ("objectid", ObjectId("600000000000000000000000")), - ("timestamp", Timestamp(1, 1)), - ("binary", Binary(b"\x01\x02\x03")), - ("regex", Regex("day")), - ("javascript", Code("function() {}")), - ("minkey", MinKey()), - ("maxkey", MaxKey()), - ] -] - -# Property [Unit String]: an unrecognized unit string, including wrong case and plurals, is -# rejected at parse time. -DATETRUNC_UNIT_STRING_ERROR_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - f"unit_{tid}", - expression={"$dateTrunc": {"date": datetime(2021, 1, 1, tzinfo=timezone.utc), "unit": val}}, - error_code=FAILED_TO_PARSE_ERROR, - msg=f"$dateTrunc should reject the {desc}", - ) - for tid, val, desc in [ - ("invalid_string", "invalid", "unrecognized unit string"), - ("empty_string", "", "empty string unit"), - ("mixed_case_Year", "Year", "mixed-case unit Year"), - ("mixed_case_Day", "Day", "mixed-case unit Day"), - ("mixed_case_Hour", "Hour", "mixed-case unit Hour"), - ("mixed_case_Month", "Month", "mixed-case unit Month"), - ("mixed_case_Quarter", "Quarter", "mixed-case unit Quarter"), - ("mixed_case_Week", "Week", "mixed-case unit Week"), - ("mixed_case_Second", "Second", "mixed-case unit Second"), - ("mixed_case_Minute", "Minute", "mixed-case unit Minute"), - ("mixed_case_Millisecond", "Millisecond", "mixed-case unit Millisecond"), - ("uppercase_YEAR", "YEAR", "uppercase unit YEAR"), - ("uppercase_DAY", "DAY", "uppercase unit DAY"), - ("uppercase_HOUR", "HOUR", "uppercase unit HOUR"), - ("uppercase_MONTH", "MONTH", "uppercase unit MONTH"), - ("uppercase_MILLISECOND", "MILLISECOND", "uppercase unit MILLISECOND"), - ("uppercase_QUARTER", "QUARTER", "uppercase unit QUARTER"), - ("uppercase_WEEK", "WEEK", "uppercase unit WEEK"), - ("uppercase_SECOND", "SECOND", "uppercase unit SECOND"), - ("uppercase_MINUTE", "MINUTE", "uppercase unit MINUTE"), - ("plural_years", "years", "plural unit years"), - ("plural_months", "months", "plural unit months"), - ("plural_days", "days", "plural unit days"), - ("plural_hours", "hours", "plural unit hours"), - ("plural_minutes", "minutes", "plural unit minutes"), - ("plural_seconds", "seconds", "plural unit seconds"), - ("plural_milliseconds", "milliseconds", "plural unit milliseconds"), - ("plural_weeks", "weeks", "plural unit weeks"), - ("plural_quarters", "quarters", "plural unit quarters"), - ] -] - -# Property [BinSize Type]: a non-numeric binSize is rejected. -DATETRUNC_BINSIZE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - f"binSize_{tid}", - expression={ - "$dateTrunc": { - "date": datetime(2021, 1, 1, tzinfo=timezone.utc), - "unit": "day", - "binSize": val, - } - }, - error_code=DATETRUNC_INVALID_BINSIZE_ERROR, - msg=f"$dateTrunc should reject a {tid} binSize", - ) - for tid, val in [ - ("string", "2"), - ("boolean", True), - ("array", [1]), - ("empty_array", []), - ("object", {"a": 1}), - ("datetime", datetime(2021, 1, 1, tzinfo=timezone.utc)), - ("objectid", ObjectId("600000000000000000000000")), - ("timestamp", Timestamp(1, 1)), - ("binary", Binary(b"\x01\x02\x03")), - ("regex", Regex(".*")), - ("javascript", Code("function() {}")), - ("minkey", MinKey()), - ("maxkey", MaxKey()), - ] -] - -# Property [BinSize Non-Integral]: a fractional or non-finite numeric binSize is rejected. -DATETRUNC_BINSIZE_NONINTEGRAL_ERROR_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "binSize_fractional", - expression={ - "$dateTrunc": { - "date": datetime(2021, 1, 1, tzinfo=timezone.utc), - "unit": "day", - "binSize": 0.5, - } - }, - error_code=DATETRUNC_INVALID_BINSIZE_ERROR, - msg="$dateTrunc should reject a fractional binSize", - ), - ExpressionTestCase( - "binSize_nan", - expression={ - "$dateTrunc": { - "date": datetime(2021, 1, 1, tzinfo=timezone.utc), - "unit": "day", - "binSize": FLOAT_NAN, - } - }, - error_code=DATETRUNC_INVALID_BINSIZE_ERROR, - msg="$dateTrunc should reject a NaN binSize", - ), - ExpressionTestCase( - "binSize_infinity", - expression={ - "$dateTrunc": { - "date": datetime(2021, 1, 1, tzinfo=timezone.utc), - "unit": "day", - "binSize": FLOAT_INFINITY, - } - }, - error_code=DATETRUNC_INVALID_BINSIZE_ERROR, - msg="$dateTrunc should reject an infinite binSize", - ), - ExpressionTestCase( - "binSize_decimal128_fractional", - expression={ - "$dateTrunc": { - "date": datetime(2021, 1, 1, tzinfo=timezone.utc), - "unit": "day", - "binSize": DECIMAL128_HALF, - } - }, - error_code=DATETRUNC_INVALID_BINSIZE_ERROR, - msg="$dateTrunc should reject a fractional decimal128 binSize", - ), - ExpressionTestCase( - "binSize_decimal128_nan", - expression={ - "$dateTrunc": { - "date": datetime(2021, 1, 1, tzinfo=timezone.utc), - "unit": "day", - "binSize": DECIMAL128_NAN, - } - }, - error_code=DATETRUNC_INVALID_BINSIZE_ERROR, - msg="$dateTrunc should reject a NaN decimal128 binSize", - ), - ExpressionTestCase( - "binSize_decimal128_infinity", - expression={ - "$dateTrunc": { - "date": datetime(2021, 1, 1, tzinfo=timezone.utc), - "unit": "day", - "binSize": DECIMAL128_INFINITY, - } - }, - error_code=DATETRUNC_INVALID_BINSIZE_ERROR, - msg="$dateTrunc should reject an infinite decimal128 binSize", - ), -] - -# Property [BinSize Value]: a zero or negative binSize is rejected as an invalid value. -DATETRUNC_BINSIZE_VALUE_ERROR_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "binSize_zero", - expression={ - "$dateTrunc": { - "date": datetime(2021, 1, 1, tzinfo=timezone.utc), - "unit": "day", - "binSize": 0, - } - }, - error_code=DATETRUNC_INVALID_BINSIZE_VALUE_ERROR, - msg="$dateTrunc should reject a zero binSize", - ), - ExpressionTestCase( - "binSize_negative", - expression={ - "$dateTrunc": { - "date": datetime(2021, 1, 1, tzinfo=timezone.utc), - "unit": "day", - "binSize": -1, - } - }, - error_code=DATETRUNC_INVALID_BINSIZE_VALUE_ERROR, - msg="$dateTrunc should reject a negative binSize", - ), -] - -# Property [BinSize Overflow]: a binSize that overflows the date range for its unit is rejected. -DATETRUNC_BINSIZE_OVERFLOW_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "binSize_overflow_year", - expression={ - "$dateTrunc": { - "date": datetime(2021, 1, 1, tzinfo=timezone.utc), - "unit": "year", - "binSize": Int64(100_000_000_001), - } - }, - error_code=DATETRUNC_BINSIZE_OVERFLOW_YEAR_ERROR, - msg="$dateTrunc should reject a binSize that overflows the year unit", - ), - ExpressionTestCase( - "binSize_overflow_hour", - expression={ - "$dateTrunc": { - "date": datetime(2021, 1, 1, tzinfo=timezone.utc), - "unit": "hour", - "binSize": Int64(2_562_047_788_016_999), - } - }, - error_code=DATETRUNC_BINSIZE_OVERFLOW_HOUR_ERROR, - msg="$dateTrunc should reject a binSize that overflows the hour unit", - ), -] - -# Property [Timezone Type]: a non-string timezone is rejected. -DATETRUNC_TIMEZONE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - f"tz_{tid}", - expression={ - "$dateTrunc": { - "date": datetime(2021, 6, 15, tzinfo=timezone.utc), - "unit": "day", - "timezone": val, - } - }, - error_code=INVALID_TIMEZONE_TYPE_ERROR, - msg=f"$dateTrunc should reject a {tid} timezone", - ) - for tid, val in [ - ("int", 5), - ("int64", Int64(5)), - ("double", 5.0), - ("decimal128", Decimal128("5")), - ("boolean", True), - ("array", ["UTC"]), - ("empty_array", []), - ("object", {"tz": "UTC"}), - ("datetime", datetime(2021, 1, 1, tzinfo=timezone.utc)), - ("objectid", ObjectId("600000000000000000000000")), - ("timestamp", Timestamp(1, 1)), - ("binary", Binary(b"\x01\x02\x03")), - ("regex", Regex("UTC")), - ("javascript", Code("function() {}")), - ("minkey", MinKey()), - ("maxkey", MaxKey()), - ] -] - -# Property [Timezone String]: an unrecognized timezone string, including wrong-case Olson names and -# out-of-range offsets, is rejected. -DATETRUNC_TIMEZONE_STRING_ERROR_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - f"tz_{tid}", - expression={ - "$dateTrunc": { - "date": datetime(2021, 6, 15, tzinfo=timezone.utc), - "unit": "day", - "timezone": val, - } - }, - error_code=INVALID_TIMEZONE_ERROR, - msg=f"$dateTrunc should reject the {desc}", - ) - for tid, val, desc in [ - ("invalid_string", "NotATimezone", "unrecognized timezone string"), - ("empty_string", "", "empty timezone string"), - ("olson_lowercase", "america/new_york", "all-lowercase Olson name"), - ("olson_uppercase", "AMERICA/NEW_YORK", "all-uppercase Olson name"), - ("offset_3digit_hours", "+100:00", "three-digit hour offset"), - ] -] - -# Property [StartOfWeek Type]: a non-string startOfWeek is rejected. -DATETRUNC_STARTOFWEEK_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - f"sow_{tid}", - expression={ - "$dateTrunc": { - "date": datetime(2021, 3, 20, tzinfo=timezone.utc), - "unit": "week", - "startOfWeek": val, - } - }, - error_code=INVALID_STARTOFWEEK_TYPE_ERROR, - msg=f"$dateTrunc should reject a {tid} startOfWeek", - ) - for tid, val in [ - ("int", 1), - ("int64", Int64(1)), - ("double", 1.0), - ("decimal128", Decimal128("1")), - ("boolean", True), - ("array", ["monday"]), - ("empty_array", []), - ("object", {"day": "monday"}), - ("datetime", datetime(2021, 1, 1, tzinfo=timezone.utc)), - ("objectid", ObjectId("600000000000000000000000")), - ("timestamp", Timestamp(1, 1)), - ("binary", Binary(b"\x01\x02\x03")), - ("regex", Regex("monday")), - ("javascript", Code("function() {}")), - ("minkey", MinKey()), - ("maxkey", MaxKey()), - ] -] - -# Property [StartOfWeek String]: an unrecognized startOfWeek string is rejected. -DATETRUNC_STARTOFWEEK_STRING_ERROR_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "sow_invalid_string", - expression={ - "$dateTrunc": { - "date": datetime(2021, 3, 20, tzinfo=timezone.utc), - "unit": "week", - "startOfWeek": "notaday", - } - }, - error_code=INVALID_STARTOFWEEK_ERROR, - msg="$dateTrunc should reject an unrecognized startOfWeek string", - ), - ExpressionTestCase( - "sow_empty_string", - expression={ - "$dateTrunc": { - "date": datetime(2021, 3, 20, tzinfo=timezone.utc), - "unit": "week", - "startOfWeek": "", - } - }, - error_code=INVALID_STARTOFWEEK_ERROR, - msg="$dateTrunc should reject an empty startOfWeek string", - ), -] - -DATETRUNC_ERROR_TESTS: list[ExpressionTestCase] = ( - DATETRUNC_ARG_ERROR_TESTS - + DATETRUNC_DATE_TYPE_ERROR_TESTS - + DATETRUNC_UNIT_TYPE_ERROR_TESTS - + DATETRUNC_UNIT_STRING_ERROR_TESTS - + DATETRUNC_BINSIZE_TYPE_ERROR_TESTS - + DATETRUNC_BINSIZE_NONINTEGRAL_ERROR_TESTS - + DATETRUNC_BINSIZE_VALUE_ERROR_TESTS - + DATETRUNC_BINSIZE_OVERFLOW_TESTS - + DATETRUNC_TIMEZONE_TYPE_ERROR_TESTS - + DATETRUNC_TIMEZONE_STRING_ERROR_TESTS - + DATETRUNC_STARTOFWEEK_TYPE_ERROR_TESTS - + DATETRUNC_STARTOFWEEK_STRING_ERROR_TESTS -) - -DATETRUNC_BASIC_TESTS: list[ExpressionTestCase] = DATETRUNC_SUCCESS_TESTS + DATETRUNC_ERROR_TESTS - - -@pytest.mark.parametrize("test_case", pytest_params(DATETRUNC_BASIC_TESTS)) -def test_dateTrunc_basic(collection, test_case: ExpressionTestCase): - """Test $dateTrunc argument handling and validation.""" - result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) - assert_expression_result( - result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg - ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_binsize_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_binsize_errors.py new file mode 100644 index 000000000..9a6c09531 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_binsize_errors.py @@ -0,0 +1,207 @@ +"""$dateTrunc binSize rejection cases: non-numeric, non-integral, non-positive, and overflow.""" + +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.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + DATETRUNC_BINSIZE_OVERFLOW_HOUR_ERROR, + DATETRUNC_BINSIZE_OVERFLOW_YEAR_ERROR, + DATETRUNC_INVALID_BINSIZE_ERROR, + DATETRUNC_INVALID_BINSIZE_VALUE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_HALF, + DECIMAL128_INFINITY, + DECIMAL128_NAN, + FLOAT_INFINITY, + FLOAT_NAN, +) + +# Property [BinSize Type]: a non-numeric binSize is rejected. +DATETRUNC_BINSIZE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"binSize_{tid}", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": val, + } + }, + error_code=DATETRUNC_INVALID_BINSIZE_ERROR, + msg=f"$dateTrunc should reject a {tid} binSize", + ) + for tid, val in [ + ("string", "2"), + ("boolean", True), + ("array", [1]), + ("empty_array", []), + ("object", {"a": 1}), + ("datetime", datetime(2021, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [BinSize Non-Integral]: a fractional or non-finite numeric binSize is rejected. +DATETRUNC_BINSIZE_NONINTEGRAL_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "binSize_fractional", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": 0.5, + } + }, + error_code=DATETRUNC_INVALID_BINSIZE_ERROR, + msg="$dateTrunc should reject a fractional binSize", + ), + ExpressionTestCase( + "binSize_nan", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": FLOAT_NAN, + } + }, + error_code=DATETRUNC_INVALID_BINSIZE_ERROR, + msg="$dateTrunc should reject a NaN binSize", + ), + ExpressionTestCase( + "binSize_infinity", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": FLOAT_INFINITY, + } + }, + error_code=DATETRUNC_INVALID_BINSIZE_ERROR, + msg="$dateTrunc should reject an infinite binSize", + ), + ExpressionTestCase( + "binSize_decimal128_fractional", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": DECIMAL128_HALF, + } + }, + error_code=DATETRUNC_INVALID_BINSIZE_ERROR, + msg="$dateTrunc should reject a fractional decimal128 binSize", + ), + ExpressionTestCase( + "binSize_decimal128_nan", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": DECIMAL128_NAN, + } + }, + error_code=DATETRUNC_INVALID_BINSIZE_ERROR, + msg="$dateTrunc should reject a NaN decimal128 binSize", + ), + ExpressionTestCase( + "binSize_decimal128_infinity", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": DECIMAL128_INFINITY, + } + }, + error_code=DATETRUNC_INVALID_BINSIZE_ERROR, + msg="$dateTrunc should reject an infinite decimal128 binSize", + ), +] + +# Property [BinSize Value]: a zero or negative binSize is rejected as an invalid value. +DATETRUNC_BINSIZE_VALUE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "binSize_zero", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": 0, + } + }, + error_code=DATETRUNC_INVALID_BINSIZE_VALUE_ERROR, + msg="$dateTrunc should reject a zero binSize", + ), + ExpressionTestCase( + "binSize_negative", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": -1, + } + }, + error_code=DATETRUNC_INVALID_BINSIZE_VALUE_ERROR, + msg="$dateTrunc should reject a negative binSize", + ), +] + +# Property [BinSize Overflow]: a binSize that overflows the date range for its unit is rejected. +DATETRUNC_BINSIZE_OVERFLOW_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "binSize_overflow_year", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "year", + "binSize": Int64(100_000_000_001), + } + }, + error_code=DATETRUNC_BINSIZE_OVERFLOW_YEAR_ERROR, + msg="$dateTrunc should reject a binSize that overflows the year unit", + ), + ExpressionTestCase( + "binSize_overflow_hour", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "hour", + "binSize": Int64(2_562_047_788_016_999), + } + }, + error_code=DATETRUNC_BINSIZE_OVERFLOW_HOUR_ERROR, + msg="$dateTrunc should reject a binSize that overflows the hour unit", + ), +] + +DATETRUNC_BINSIZE_ERROR_TESTS: list[ExpressionTestCase] = ( + DATETRUNC_BINSIZE_TYPE_ERROR_TESTS + + DATETRUNC_BINSIZE_NONINTEGRAL_ERROR_TESTS + + DATETRUNC_BINSIZE_VALUE_ERROR_TESTS + + DATETRUNC_BINSIZE_OVERFLOW_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETRUNC_BINSIZE_ERROR_TESTS)) +def test_dateTrunc_binsize_errors(collection, test_case: ExpressionTestCase): + """Test $dateTrunc rejects invalid binSize types, values, and overflow.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_errors.py new file mode 100644 index 000000000..fc9aa6e7e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_errors.py @@ -0,0 +1,377 @@ +"""$dateTrunc rejection cases: invalid date, unit, timezone, startOfWeek, and argument shape.""" + +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.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + DATETRUNC_MISSING_DATE_ERROR, + DATETRUNC_MISSING_UNIT_ERROR, + DATETRUNC_NON_OBJECT_ERROR, + DATETRUNC_UNKNOWN_FIELD_ERROR, + FAILED_TO_PARSE_ERROR, + INVALID_DATE_UNIT_ERROR, + INVALID_DATE_VALUE_ERROR, + INVALID_STARTOFWEEK_ERROR, + INVALID_STARTOFWEEK_TYPE_ERROR, + INVALID_TIMEZONE_ERROR, + INVALID_TIMEZONE_TYPE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NEGATIVE_INFINITY, +) + +# Property [Argument Shape]: a missing required field, an empty object, an unknown field, or a +# non-object argument is rejected. +DATETRUNC_ARG_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arg_missing_date", + expression={"$dateTrunc": {"unit": "day"}}, + error_code=DATETRUNC_MISSING_DATE_ERROR, + msg="$dateTrunc should error when date is missing", + ), + ExpressionTestCase( + "arg_missing_unit", + expression={"$dateTrunc": {"date": datetime(2021, 1, 1, tzinfo=timezone.utc)}}, + error_code=DATETRUNC_MISSING_UNIT_ERROR, + msg="$dateTrunc should error when unit is missing", + ), + ExpressionTestCase( + "arg_empty_object", + expression={"$dateTrunc": {}}, + error_code=DATETRUNC_MISSING_DATE_ERROR, + msg="$dateTrunc should error for an empty argument object", + ), + ExpressionTestCase( + "arg_unknown_field", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "foo": 1, + } + }, + error_code=DATETRUNC_UNKNOWN_FIELD_ERROR, + msg="$dateTrunc should error for an unknown field", + ), + ExpressionTestCase( + "arg_non_object_string", + expression={"$dateTrunc": "string"}, + error_code=DATETRUNC_NON_OBJECT_ERROR, + msg="$dateTrunc should reject a string argument", + ), + ExpressionTestCase( + "arg_non_object_array", + expression={"$dateTrunc": [1, 2]}, + error_code=DATETRUNC_NON_OBJECT_ERROR, + msg="$dateTrunc should reject an array argument", + ), + ExpressionTestCase( + "arg_non_object_number", + expression={"$dateTrunc": 123}, + error_code=DATETRUNC_NON_OBJECT_ERROR, + msg="$dateTrunc should reject a numeric argument", + ), +] + +# Property [Date Type]: a non-date, non-ObjectId, non-Timestamp date value is rejected. +DATETRUNC_DATE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + *[ + ExpressionTestCase( + f"date_{tid}", + doc={"date": val}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + error_code=INVALID_DATE_VALUE_ERROR, + msg=f"$dateTrunc should reject a {tid} date", + ) + for tid, val in [ + ("string", "2021"), + ("int", 123), + ("int64", Int64(123)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", [1, 2]), + ("empty_array", []), + ("object", {"a": 1}), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + ExpressionTestCase( + "date_boolean_false", + doc={"date": False}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + error_code=INVALID_DATE_VALUE_ERROR, + msg="$dateTrunc should reject a false boolean date", + ), + ExpressionTestCase( + "date_single_date_array", + doc={"date": [datetime(2021, 6, 15, tzinfo=timezone.utc)]}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + error_code=INVALID_DATE_VALUE_ERROR, + msg="$dateTrunc should reject a single-element array containing a date", + ), + ExpressionTestCase( + "date_decimal128_infinity", + doc={"date": DECIMAL128_INFINITY}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + error_code=INVALID_DATE_VALUE_ERROR, + msg="$dateTrunc should reject a Decimal128 Infinity date", + ), + ExpressionTestCase( + "date_decimal128_neg_infinity", + doc={"date": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + error_code=INVALID_DATE_VALUE_ERROR, + msg="$dateTrunc should reject a Decimal128 -Infinity date", + ), +] + +# Property [Unit Type]: a non-string unit is rejected as an invalid date unit. +DATETRUNC_UNIT_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"unit_{tid}", + expression={"$dateTrunc": {"date": datetime(2021, 1, 1, tzinfo=timezone.utc), "unit": val}}, + error_code=INVALID_DATE_UNIT_ERROR, + msg=f"$dateTrunc should reject a {tid} unit", + ) + for tid, val in [ + ("int", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", ["day"]), + ("empty_array", []), + ("object", {"t": "day"}), + ("datetime", datetime(2021, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex("day")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Unit String]: an unrecognized unit string, including wrong case and plurals, is +# rejected at parse time. +DATETRUNC_UNIT_STRING_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"unit_{tid}", + expression={"$dateTrunc": {"date": datetime(2021, 1, 1, tzinfo=timezone.utc), "unit": val}}, + error_code=FAILED_TO_PARSE_ERROR, + msg=f"$dateTrunc should reject the {desc}", + ) + for tid, val, desc in [ + ("invalid_string", "invalid", "unrecognized unit string"), + ("empty_string", "", "empty string unit"), + ("mixed_case_Year", "Year", "mixed-case unit Year"), + ("mixed_case_Day", "Day", "mixed-case unit Day"), + ("mixed_case_Hour", "Hour", "mixed-case unit Hour"), + ("mixed_case_Month", "Month", "mixed-case unit Month"), + ("mixed_case_Quarter", "Quarter", "mixed-case unit Quarter"), + ("mixed_case_Week", "Week", "mixed-case unit Week"), + ("mixed_case_Second", "Second", "mixed-case unit Second"), + ("mixed_case_Minute", "Minute", "mixed-case unit Minute"), + ("mixed_case_Millisecond", "Millisecond", "mixed-case unit Millisecond"), + ("uppercase_YEAR", "YEAR", "uppercase unit YEAR"), + ("uppercase_DAY", "DAY", "uppercase unit DAY"), + ("uppercase_HOUR", "HOUR", "uppercase unit HOUR"), + ("uppercase_MONTH", "MONTH", "uppercase unit MONTH"), + ("uppercase_MILLISECOND", "MILLISECOND", "uppercase unit MILLISECOND"), + ("uppercase_QUARTER", "QUARTER", "uppercase unit QUARTER"), + ("uppercase_WEEK", "WEEK", "uppercase unit WEEK"), + ("uppercase_SECOND", "SECOND", "uppercase unit SECOND"), + ("uppercase_MINUTE", "MINUTE", "uppercase unit MINUTE"), + ("plural_years", "years", "plural unit years"), + ("plural_months", "months", "plural unit months"), + ("plural_days", "days", "plural unit days"), + ("plural_hours", "hours", "plural unit hours"), + ("plural_minutes", "minutes", "plural unit minutes"), + ("plural_seconds", "seconds", "plural unit seconds"), + ("plural_milliseconds", "milliseconds", "plural unit milliseconds"), + ("plural_weeks", "weeks", "plural unit weeks"), + ("plural_quarters", "quarters", "plural unit quarters"), + ] +] + +# Property [Array Path Rejection]: a field path resolving to an array is rejected as an invalid +# date value. +DATETRUNC_ARRAY_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "composite_array_path", + doc={ + "a": [ + {"b": datetime(2021, 6, 15, tzinfo=timezone.utc)}, + {"b": datetime(2021, 7, 1, tzinfo=timezone.utc)}, + ] + }, + expression={"$dateTrunc": {"date": "$a.b", "unit": "day"}}, + error_code=INVALID_DATE_VALUE_ERROR, + msg="$dateTrunc should reject a composite array field path", + ), + ExpressionTestCase( + "single_element_array_path", + doc={"a": [{"b": datetime(2021, 6, 15, 12, 30, 0, tzinfo=timezone.utc)}]}, + expression={"$dateTrunc": {"date": "$a.b", "unit": "day"}}, + error_code=INVALID_DATE_VALUE_ERROR, + msg="$dateTrunc should reject a single-element array field path", + ), +] + +# Property [Timezone Type]: a non-string timezone is rejected. +DATETRUNC_TIMEZONE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"tz_{tid}", + expression={ + "$dateTrunc": { + "date": datetime(2021, 6, 15, tzinfo=timezone.utc), + "unit": "day", + "timezone": val, + } + }, + error_code=INVALID_TIMEZONE_TYPE_ERROR, + msg=f"$dateTrunc should reject a {tid} timezone", + ) + for tid, val in [ + ("int", 5), + ("int64", Int64(5)), + ("double", 5.0), + ("decimal128", Decimal128("5")), + ("boolean", True), + ("array", ["UTC"]), + ("empty_array", []), + ("object", {"tz": "UTC"}), + ("datetime", datetime(2021, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex("UTC")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Timezone String]: an unrecognized timezone string, including wrong-case Olson names and +# out-of-range offsets, is rejected. +DATETRUNC_TIMEZONE_STRING_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"tz_{tid}", + expression={ + "$dateTrunc": { + "date": datetime(2021, 6, 15, tzinfo=timezone.utc), + "unit": "day", + "timezone": val, + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg=f"$dateTrunc should reject the {desc}", + ) + for tid, val, desc in [ + ("invalid_string", "NotATimezone", "unrecognized timezone string"), + ("empty_string", "", "empty timezone string"), + ("olson_lowercase", "america/new_york", "all-lowercase Olson name"), + ("olson_uppercase", "AMERICA/NEW_YORK", "all-uppercase Olson name"), + ("offset_3digit_hours", "+100:00", "three-digit hour offset"), + ] +] + +# Property [StartOfWeek Type]: a non-string startOfWeek is rejected. +DATETRUNC_STARTOFWEEK_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"sow_{tid}", + expression={ + "$dateTrunc": { + "date": datetime(2021, 3, 20, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": val, + } + }, + error_code=INVALID_STARTOFWEEK_TYPE_ERROR, + msg=f"$dateTrunc should reject a {tid} startOfWeek", + ) + for tid, val in [ + ("int", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("boolean", True), + ("array", ["monday"]), + ("empty_array", []), + ("object", {"day": "monday"}), + ("datetime", datetime(2021, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("600000000000000000000000")), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex("monday")), + ("javascript", Code("function() {}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [StartOfWeek String]: an unrecognized startOfWeek string is rejected. +DATETRUNC_STARTOFWEEK_STRING_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "sow_invalid_string", + expression={ + "$dateTrunc": { + "date": datetime(2021, 3, 20, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": "notaday", + } + }, + error_code=INVALID_STARTOFWEEK_ERROR, + msg="$dateTrunc should reject an unrecognized startOfWeek string", + ), + ExpressionTestCase( + "sow_empty_string", + expression={ + "$dateTrunc": { + "date": datetime(2021, 3, 20, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": "", + } + }, + error_code=INVALID_STARTOFWEEK_ERROR, + msg="$dateTrunc should reject an empty startOfWeek string", + ), +] + +DATETRUNC_ERROR_TESTS: list[ExpressionTestCase] = ( + DATETRUNC_ARG_ERROR_TESTS + + DATETRUNC_DATE_TYPE_ERROR_TESTS + + DATETRUNC_UNIT_TYPE_ERROR_TESTS + + DATETRUNC_UNIT_STRING_ERROR_TESTS + + DATETRUNC_ARRAY_PATH_TESTS + + DATETRUNC_TIMEZONE_TYPE_ERROR_TESTS + + DATETRUNC_TIMEZONE_STRING_ERROR_TESTS + + DATETRUNC_STARTOFWEEK_TYPE_ERROR_TESTS + + DATETRUNC_STARTOFWEEK_STRING_ERROR_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETRUNC_ERROR_TESTS)) +def test_dateTrunc_errors(collection, test_case: ExpressionTestCase): + """Test $dateTrunc rejects invalid date, unit, timezone, startOfWeek, and shapes.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_expressions.py index 5c879e7d5..3ac3f38d0 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_expressions.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_expressions.py @@ -1,4 +1,4 @@ -"""$dateTrunc literal and field-reference inputs, array-path rejection, and return type.""" +"""$dateTrunc operand evaluation: literal arguments and field-reference resolution.""" from datetime import datetime, timezone @@ -15,7 +15,6 @@ assert_expression_result, execute_expression_with_insert, ) -from documentdb_tests.framework.error_codes import INVALID_DATE_VALUE_ERROR from documentdb_tests.framework.parametrize import pytest_params # Property [Literal Input]: $dateTrunc evaluates inline literal arguments. @@ -98,73 +97,14 @@ ), ] -# Property [Array Path Rejection]: a field path resolving to an array is rejected as an invalid -# date value. -DATETRUNC_ARRAY_PATH_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "composite_array_path", - doc={ - "a": [ - {"b": datetime(2021, 6, 15, tzinfo=timezone.utc)}, - {"b": datetime(2021, 7, 1, tzinfo=timezone.utc)}, - ] - }, - expression={"$dateTrunc": {"date": "$a.b", "unit": "day"}}, - error_code=INVALID_DATE_VALUE_ERROR, - msg="$dateTrunc should reject a composite array field path", - ), - ExpressionTestCase( - "single_element_array_path", - doc={"a": [{"b": datetime(2021, 6, 15, 12, 30, 0, tzinfo=timezone.utc)}]}, - expression={"$dateTrunc": {"date": "$a.b", "unit": "day"}}, - error_code=INVALID_DATE_VALUE_ERROR, - msg="$dateTrunc should reject a single-element array field path", - ), -] - -# Property [Return Type]: $dateTrunc returns the date type regardless of the input date type. -DATETRUNC_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "return_type_date", - expression={ - "$type": { - "$dateTrunc": { - "date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc), - "unit": "day", - } - } - }, - expected="date", - msg="$dateTrunc should return the date type for a Date input", - ), - ExpressionTestCase( - "return_type_from_timestamp", - doc={"ts": ts_from_args(2021, 3, 20, 11, 30, 5)}, - expression={"$type": {"$dateTrunc": {"date": "$ts", "unit": "day"}}}, - expected="date", - msg="$dateTrunc should return the date type for a Timestamp input", - ), - ExpressionTestCase( - "return_type_from_objectid", - doc={"oid": oid_from_args(2021, 3, 20, 11, 30, 5)}, - expression={"$type": {"$dateTrunc": {"date": "$oid", "unit": "day"}}}, - expected="date", - msg="$dateTrunc should return the date type for an ObjectId input", - ), -] - DATETRUNC_EXPRESSION_TESTS: list[ExpressionTestCase] = ( - DATETRUNC_LITERAL_TESTS - + DATETRUNC_FIELD_REF_TESTS - + DATETRUNC_MISSING_REF_TESTS - + DATETRUNC_ARRAY_PATH_TESTS - + DATETRUNC_RETURN_TYPE_TESTS + DATETRUNC_LITERAL_TESTS + DATETRUNC_FIELD_REF_TESTS + DATETRUNC_MISSING_REF_TESTS ) @pytest.mark.parametrize("test_case", pytest_params(DATETRUNC_EXPRESSION_TESTS)) def test_dateTrunc_expressions(collection, test_case: ExpressionTestCase): - """Test $dateTrunc literal and field-reference handling.""" + """Test $dateTrunc evaluates literal and field-reference operands.""" result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_input_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_input_types.py new file mode 100644 index 000000000..9f6891794 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_input_types.py @@ -0,0 +1,193 @@ +"""$dateTrunc date-like input types: ObjectId, Timestamp, numeric boundaries, and return type.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( + oid_from_args, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_EPOCH, + DATE_MS_BEFORE_EPOCH, + DATE_MS_EPOCH, + OID_MAX_SIGNED32, + OID_MIN_SIGNED32, + TS_MAX_SIGNED32, + TS_MAX_UNSIGNED32, +) + +# Property [ObjectId And Timestamp Input]: ObjectId and Timestamp inputs are truncated by their +# embedded time. +DATETRUNC_OID_TS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "oid_trunc_day", + doc={"date": oid_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate an ObjectId to the day", + ), + ExpressionTestCase( + "oid_trunc_hour", + doc={"date": oid_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour"}}, + expected=datetime(2021, 3, 20, 11, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate an ObjectId to the hour", + ), + ExpressionTestCase( + "oid_trunc_month", + doc={"date": oid_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, + expected=datetime(2021, 3, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate an ObjectId to the month", + ), + ExpressionTestCase( + "oid_trunc_year", + doc={"date": oid_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, + expected=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate an ObjectId to the year", + ), + ExpressionTestCase( + "oid_with_tz", + doc={"date": oid_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "UTC"}}, + expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate an ObjectId with a timezone", + ), + ExpressionTestCase( + "ts_trunc_day", + doc={"date": ts_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a Timestamp to the day", + ), + ExpressionTestCase( + "ts_trunc_hour", + doc={"date": ts_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour"}}, + expected=datetime(2021, 3, 20, 11, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a Timestamp to the hour", + ), + ExpressionTestCase( + "ts_trunc_month", + doc={"date": ts_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, + expected=datetime(2021, 3, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a Timestamp to the month", + ), + ExpressionTestCase( + "ts_trunc_year", + doc={"date": ts_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, + expected=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a Timestamp to the year", + ), + ExpressionTestCase( + "ts_with_tz", + doc={"date": ts_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "UTC"}}, + expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a Timestamp with a timezone", + ), +] + +# Property [DatetimeMS And Numeric Boundaries]: DatetimeMS, max Timestamp, and signed-boundary +# ObjectId inputs truncate correctly. +DATETRUNC_NUMERIC_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_ms_epoch_trunc_day", + doc={"date": DATE_MS_EPOCH}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=DATE_EPOCH, + msg="$dateTrunc should truncate an epoch DatetimeMS to the day", + ), + ExpressionTestCase( + "date_ms_before_epoch_trunc_day", + doc={"date": DATE_MS_BEFORE_EPOCH}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=datetime(1969, 12, 31, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a pre-epoch DatetimeMS to the day", + ), + ExpressionTestCase( + "ts_max_s32_trunc_day", + doc={"date": TS_MAX_SIGNED32}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=datetime(2038, 1, 19, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a max signed 32-bit Timestamp to the day", + ), + ExpressionTestCase( + "ts_max_u32_trunc_month", + doc={"date": TS_MAX_UNSIGNED32}, + expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, + expected=datetime(2106, 2, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a max unsigned 32-bit Timestamp to the month", + ), + ExpressionTestCase( + "oid_max_signed32_trunc_day", + doc={"date": OID_MAX_SIGNED32}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=datetime(2038, 1, 19, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a max signed 32-bit ObjectId to the day", + ), + ExpressionTestCase( + "oid_high_bit_trunc_year", + doc={"date": OID_MIN_SIGNED32}, + expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, + expected=datetime(1901, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a high-bit ObjectId to the year", + ), +] + +# Property [Return Type]: $dateTrunc returns the date type regardless of the input date type. +DATETRUNC_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type_date", + expression={ + "$type": { + "$dateTrunc": { + "date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc), + "unit": "day", + } + } + }, + expected="date", + msg="$dateTrunc should return the date type for a Date input", + ), + ExpressionTestCase( + "return_type_from_timestamp", + doc={"ts": ts_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$type": {"$dateTrunc": {"date": "$ts", "unit": "day"}}}, + expected="date", + msg="$dateTrunc should return the date type for a Timestamp input", + ), + ExpressionTestCase( + "return_type_from_objectid", + doc={"oid": oid_from_args(2021, 3, 20, 11, 30, 5)}, + expression={"$type": {"$dateTrunc": {"date": "$oid", "unit": "day"}}}, + expected="date", + msg="$dateTrunc should return the date type for an ObjectId input", + ), +] + +DATETRUNC_INPUT_TYPE_TESTS: list[ExpressionTestCase] = ( + DATETRUNC_OID_TS_TESTS + DATETRUNC_NUMERIC_BOUNDARY_TESTS + DATETRUNC_RETURN_TYPE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETRUNC_INPUT_TYPE_TESTS)) +def test_dateTrunc_input_types(collection, test_case: ExpressionTestCase): + """Test $dateTrunc accepts date-like inputs and returns the date type.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_null.py new file mode 100644 index 000000000..dfc136a30 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_null.py @@ -0,0 +1,132 @@ +"""$dateTrunc null and missing propagation: a null or missing operand returns null.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + MISSING, +) + +# Property [Null Handling]: a null date, unit, binSize, timezone, or week startOfWeek returns null. +DATETRUNC_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_date", + doc={"date": None}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=None, + msg="$dateTrunc should return null for a null date", + ), + ExpressionTestCase( + "null_unit", + expression={ + "$dateTrunc": {"date": datetime(2021, 1, 1, tzinfo=timezone.utc), "unit": None} + }, + expected=None, + msg="$dateTrunc should return null for a null unit", + ), + ExpressionTestCase( + "null_binSize", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "day", + "binSize": None, + } + }, + expected=None, + msg="$dateTrunc should return null for a null binSize", + ), + ExpressionTestCase( + "null_timezone", + expression={ + "$dateTrunc": { + "date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "unit": "day", + "timezone": None, + } + }, + expected=None, + msg="$dateTrunc should return null for a null timezone", + ), + ExpressionTestCase( + "null_startOfWeek_week", + expression={ + "$dateTrunc": { + "date": datetime(2021, 1, 1, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": None, + } + }, + expected=None, + msg="$dateTrunc should return null for a null startOfWeek with the week unit", + ), +] + +# Property [Missing Field Reference]: a missing field reference for date or any optional parameter +# returns null. +DATETRUNC_MISSING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_date_ref", + expression={"$dateTrunc": {"date": MISSING, "unit": "day"}}, + expected=None, + msg="$dateTrunc should return null for a missing date field reference", + ), + ExpressionTestCase( + "missing_binSize_ref", + expression={ + "$dateTrunc": { + "date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc), + "unit": "hour", + "binSize": MISSING, + } + }, + expected=None, + msg="$dateTrunc should return null for a missing binSize field reference", + ), + ExpressionTestCase( + "missing_timezone_ref", + expression={ + "$dateTrunc": { + "date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc), + "unit": "day", + "timezone": MISSING, + } + }, + expected=None, + msg="$dateTrunc should return null for a missing timezone field reference", + ), + ExpressionTestCase( + "missing_startOfWeek_ref", + expression={ + "$dateTrunc": { + "date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc), + "unit": "week", + "startOfWeek": MISSING, + } + }, + expected=None, + msg="$dateTrunc should return null for a missing startOfWeek field reference", + ), +] + +DATETRUNC_NULL_MISSING_TESTS: list[ExpressionTestCase] = ( + DATETRUNC_NULL_TESTS + DATETRUNC_MISSING_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETRUNC_NULL_MISSING_TESTS)) +def test_dateTrunc_null(collection, test_case: ExpressionTestCase): + """Test $dateTrunc returns null for null literals and missing field references.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_range.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_range.py new file mode 100644 index 000000000..160ab0ac7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_range.py @@ -0,0 +1,146 @@ +"""$dateTrunc across the date range: epoch, distant, leap-year, and far-future dates.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( + oid_from_args, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_EPOCH, + DATE_YEAR_1900, +) + +# Property [Epoch And Distant Dates]: epoch, pre-epoch, distant past, and distant future dates +# truncate correctly, including from ObjectId and Timestamp inputs. +DATETRUNC_EPOCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "epoch", + doc={"date": datetime(1970, 1, 1, 12, 30, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=DATE_EPOCH, + msg="$dateTrunc should truncate an epoch-day date", + ), + ExpressionTestCase( + "pre_epoch", + doc={"date": datetime(1969, 6, 15, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, + expected=datetime(1969, 6, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a pre-epoch date", + ), + ExpressionTestCase( + "distant_past", + doc={"date": datetime(1900, 3, 15, 8, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, + expected=DATE_YEAR_1900, + msg="$dateTrunc should truncate a distant past date", + ), + ExpressionTestCase( + "distant_future", + doc={"date": datetime(2100, 7, 20, 15, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "quarter"}}, + expected=datetime(2100, 7, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a distant future date", + ), + ExpressionTestCase( + "oid_epoch", + doc={"date": oid_from_args(1970, 1, 1, 12, 30, 0)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=DATE_EPOCH, + msg="$dateTrunc should truncate an epoch ObjectId", + ), + ExpressionTestCase( + "ts_epoch", + doc={"date": ts_from_args(1970, 1, 1, 12, 30, 0)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=DATE_EPOCH, + msg="$dateTrunc should truncate an epoch Timestamp", + ), + ExpressionTestCase( + "oid_future", + doc={"date": oid_from_args(2035, 7, 20, 15, 0, 0)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, + expected=datetime(2035, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a future ObjectId", + ), + ExpressionTestCase( + "ts_future", + doc={"date": ts_from_args(2100, 7, 20, 15, 0, 0)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, + expected=datetime(2100, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a future Timestamp", + ), +] + +# Property [Leap Year]: leap-day dates truncate correctly, including century leap-year rules. +DATETRUNC_LEAP_YEAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "leap_day_trunc_day", + doc={"date": datetime(2020, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=datetime(2020, 2, 29, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a leap day to the day start", + ), + ExpressionTestCase( + "leap_day_trunc_month", + doc={"date": datetime(2020, 2, 29, 23, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, + expected=datetime(2020, 2, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a leap day to the month start", + ), + ExpressionTestCase( + "century_non_leap_1900_trunc_month", + doc={"date": datetime(1900, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, + expected=datetime(1900, 2, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a 1900 February date to the month start", + ), + ExpressionTestCase( + "century_leap_2000_trunc_month", + doc={"date": datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, + expected=datetime(2000, 2, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a 2000 leap day to the month start", + ), +] + +# Property [Far Future]: dates in year 9999 truncate correctly. +DATETRUNC_FAR_FUTURE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "far_future_year", + doc={"date": datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, + expected=datetime(9999, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a year 9999 date to the year start", + ), + ExpressionTestCase( + "far_future_quarter", + doc={"date": datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "quarter"}}, + expected=datetime(9999, 10, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate a year 9999 date to the Q4 start", + ), +] + +DATETRUNC_RANGE_TESTS: list[ExpressionTestCase] = ( + DATETRUNC_EPOCH_TESTS + DATETRUNC_LEAP_YEAR_TESTS + DATETRUNC_FAR_FUTURE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETRUNC_RANGE_TESTS)) +def test_dateTrunc_range(collection, test_case: ExpressionTestCase): + """Test $dateTrunc truncates correctly across epoch, leap-year, and far-future dates.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_timezone.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_timezone.py new file mode 100644 index 000000000..bee712885 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_timezone.py @@ -0,0 +1,221 @@ +"""$dateTrunc timezone handling: named zones, numeric offsets, and DST transitions.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Timezone Extremes]: out-of-range two-digit offsets and additional named zones are +# accepted. +DATETRUNC_TIMEZONE_ACCEPT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_over60_minutes_positive", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "+05:70"}}, + expected=datetime(2021, 6, 15, 11, 50, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a +05:70 offset with over-60 minutes", + ), + ExpressionTestCase( + "tz_over60_minutes_negative", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "-05:70"}}, + expected=datetime(2021, 6, 15, 11, 10, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a -05:70 offset with over-60 minutes", + ), + ExpressionTestCase( + "tz_over24_hours_positive", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "+25:00"}}, + expected=datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a +25:00 offset with over-24 hours", + ), + ExpressionTestCase( + "tz_over24_hours_negative", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "-25:00"}}, + expected=datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a -25:00 offset with over-24 hours", + ), + ExpressionTestCase( + "tz_max_valid_positive", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "+99:99"}}, + expected=datetime(2021, 6, 15, 11, 21, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept the maximum two-digit +99:99 offset", + ), + ExpressionTestCase( + "tz_max_valid_negative", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "-99:99"}}, + expected=datetime(2021, 6, 15, 11, 39, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept the maximum two-digit -99:99 offset", + ), + ExpressionTestCase( + "tz_pacific_apia", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "Pacific/Apia"}}, + expected=datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept the Pacific/Apia named zone", + ), + ExpressionTestCase( + "tz_offset_45min", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "+05:45"}}, + expected=datetime(2021, 6, 15, 11, 15, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a +05:45 offset with 45 minutes", + ), + ExpressionTestCase( + "tz_offset_half_hour_west", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "-02:30"}}, + expected=datetime(2021, 6, 15, 11, 30, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a -02:30 half-hour offset", + ), + ExpressionTestCase( + "tz_offset_max_east", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "+14:00"}}, + expected=datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept the +14:00 maximum east offset", + ), + ExpressionTestCase( + "tz_offset_max_west", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "-11:00"}}, + expected=datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept the -11:00 maximum west offset", + ), +] + +# Property [Timezone Offset]: named zones and numeric offsets shift the truncation boundary. +DATETRUNC_TIMEZONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_shifts_day", + doc={"date": datetime(2021, 3, 20, 2, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "-05:00"}}, + expected=datetime(2021, 3, 19, 5, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should shift the day boundary with a negative offset", + ), + ExpressionTestCase( + "tz_gmt", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "GMT"}}, + expected=datetime(2021, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept the GMT timezone", + ), + ExpressionTestCase( + "tz_utc", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "UTC"}}, + expected=datetime(2021, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept the UTC timezone", + ), + ExpressionTestCase( + "tz_offset_half_hour_east", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "+05:30"}}, + expected=datetime(2021, 6, 14, 18, 30, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate with a +05:30 offset", + ), + ExpressionTestCase( + "tz_zero_offset", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "+00:00"}}, + expected=datetime(2021, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should treat a +00:00 offset as UTC", + ), + ExpressionTestCase( + "tz_offset_no_colon", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "-0500"}}, + expected=datetime(2021, 6, 15, 5, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a no-colon offset format", + ), + ExpressionTestCase( + "tz_offset_hour_only", + doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "+03"}}, + expected=datetime(2021, 6, 14, 21, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept an hour-only offset format", + ), + ExpressionTestCase( + "tz_kolkata_day", + doc={"date": datetime(2021, 6, 15, 20, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "Asia/Kolkata"}}, + expected=datetime(2021, 6, 15, 18, 30, 0, tzinfo=timezone.utc), + msg="$dateTrunc should handle a half-hour named zone for day truncation", + ), +] + +# Property [DST Day Truncation]: day truncation in a DST zone accounts for the transition. +DATETRUNC_DST_DAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dst_spring_forward_day", + doc={"date": datetime(2021, 3, 14, 7, 30, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "America/New_York"}}, + expected=datetime(2021, 3, 14, 5, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should handle DST spring-forward day truncation", + ), + ExpressionTestCase( + "dst_fall_back_day", + doc={"date": datetime(2021, 11, 7, 6, 30, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "America/New_York"}}, + expected=datetime(2021, 11, 7, 4, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should handle DST fall-back day truncation", + ), +] + +# Property [DST Sub-Day Units]: hour and minute truncation are unaffected by DST transitions. +DATETRUNC_DST_SUBDAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dst_spring_hour_trunc", + doc={"date": datetime(2021, 3, 14, 7, 30, 0, tzinfo=timezone.utc)}, + expression={ + "$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "America/New_York"} + }, + expected=datetime(2021, 3, 14, 7, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the hour across DST spring-forward without adjustment", + ), + ExpressionTestCase( + "dst_spring_minute_trunc", + doc={"date": datetime(2021, 3, 14, 7, 0, 30, tzinfo=timezone.utc)}, + expression={ + "$dateTrunc": {"date": "$date", "unit": "minute", "timezone": "America/New_York"} + }, + expected=datetime(2021, 3, 14, 7, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the minute across DST spring-forward without adjustment", + ), + ExpressionTestCase( + "dst_fall_back_hour_trunc", + doc={"date": datetime(2021, 11, 7, 6, 30, 0, tzinfo=timezone.utc)}, + expression={ + "$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "America/New_York"} + }, + expected=datetime(2021, 11, 7, 6, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the hour across DST fall-back without adjustment", + ), +] + +DATETRUNC_TIMEZONE_ALL_TESTS: list[ExpressionTestCase] = ( + DATETRUNC_TIMEZONE_ACCEPT_TESTS + + DATETRUNC_TIMEZONE_TESTS + + DATETRUNC_DST_DAY_TESTS + + DATETRUNC_DST_SUBDAY_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETRUNC_TIMEZONE_ALL_TESTS)) +def test_dateTrunc_timezone(collection, test_case: ExpressionTestCase): + """Test $dateTrunc shifts the truncation boundary by timezone and DST.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_dates.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_truncation.py similarity index 67% rename from documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_dates.py rename to documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_truncation.py index 59668096e..c5e23d500 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_dates.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_truncation.py @@ -1,4 +1,4 @@ -"""$dateTrunc truncation results by unit, binSize multiples, bin alignment, and week start.""" +"""$dateTrunc truncation semantics: unit periods, bin multiples, alignment, and idempotence.""" from datetime import datetime, timezone @@ -168,104 +168,96 @@ ), ] -# Property [Week Truncation]: the week unit truncates to the configured start of week, defaulting -# to Sunday. -DATETRUNC_WEEK_TESTS: list[ExpressionTestCase] = [ +# Property [Boundary Idempotence]: a date already at a unit boundary is returned unchanged. +DATETRUNC_BOUNDARY_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( - "week_default_sun", - doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "week"}}, - expected=datetime(2021, 3, 14, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should default the week start to Sunday", - ), - ExpressionTestCase( - "week_monday", - doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "monday"}}, - expected=datetime(2021, 3, 15, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate the week to Monday", + "year_at_boundary", + doc={"date": datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, + expected=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should return the date unchanged at a year boundary", ), ExpressionTestCase( - "week_friday", - doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "friday"}}, - expected=datetime(2021, 3, 19, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate the week to Friday", + "month_at_boundary", + doc={"date": datetime(2021, 6, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, + expected=datetime(2021, 6, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should return the date unchanged at a month boundary", ), ExpressionTestCase( - "week_sunday", - doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "sunday"}}, - expected=datetime(2021, 6, 13, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate the week to Sunday", + "day_at_boundary", + doc={"date": datetime(2021, 6, 15, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=datetime(2021, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should return the date unchanged at a day boundary", ), ExpressionTestCase( - "week_tuesday", - doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "tuesday"}}, - expected=datetime(2021, 6, 15, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate the week to Tuesday", + "hour_at_boundary", + doc={"date": datetime(2021, 6, 15, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "hour"}}, + expected=datetime(2021, 6, 15, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should return the date unchanged at an hour boundary", ), ExpressionTestCase( - "week_wednesday", - doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "wednesday"}}, - expected=datetime(2021, 6, 16, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate the week to Wednesday", + "minute_at_boundary", + doc={"date": datetime(2021, 6, 15, 10, 30, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "minute"}}, + expected=datetime(2021, 6, 15, 10, 30, 0, tzinfo=timezone.utc), + msg="$dateTrunc should return the date unchanged at a minute boundary", ), ExpressionTestCase( - "week_thursday", - doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "thursday"}}, - expected=datetime(2021, 6, 10, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate the week to Thursday", + "second_at_boundary", + doc={"date": datetime(2021, 6, 15, 10, 30, 45, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "second"}}, + expected=datetime(2021, 6, 15, 10, 30, 45, tzinfo=timezone.utc), + msg="$dateTrunc should return the date unchanged at a second boundary", ), +] + +# Property [End Of Period]: a date at the end of a period truncates to that period's start. +DATETRUNC_END_OF_PERIOD_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( - "week_saturday", - doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "saturday"}}, - expected=datetime(2021, 6, 12, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate the week to Saturday", + "year_end", + doc={"date": datetime(2021, 12, 31, 23, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, + expected=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the last millisecond of a year to the year start", ), ExpressionTestCase( - "week_mon_abbrev", - doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "mon"}}, - expected=datetime(2021, 6, 14, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept the mon abbreviation for startOfWeek", + "quarter_end_q1", + doc={"date": datetime(2021, 3, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "quarter"}}, + expected=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the end of Q1 to the Q1 start", ), ExpressionTestCase( - "week_monday_mixed_case", - doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "Monday"}}, - expected=datetime(2021, 6, 14, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept a mixed-case Monday for startOfWeek", + "quarter_start_q2", + doc={"date": datetime(2021, 4, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "quarter"}}, + expected=datetime(2021, 4, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should return the Q2 start at the exact boundary", ), -] - -# Property [StartOfWeek Ignored]: startOfWeek has no effect for a non-week unit. -DATETRUNC_SOW_IGNORED_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( - "sow_ignored_month", - doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "month", "startOfWeek": "friday"}}, - expected=datetime(2021, 3, 1, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should ignore startOfWeek for the month unit", + "day_end", + doc={"date": datetime(2021, 6, 15, 23, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, + expected=datetime(2021, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the last millisecond of a day to the day start", ), ] -DATETRUNC_DATE_TESTS: list[ExpressionTestCase] = ( +DATETRUNC_TRUNCATION_TESTS: list[ExpressionTestCase] = ( DATETRUNC_UNIT_TESTS + DATETRUNC_BINSIZE_TESTS + DATETRUNC_BIN_ALIGNMENT_TESTS - + DATETRUNC_WEEK_TESTS - + DATETRUNC_SOW_IGNORED_TESTS + + DATETRUNC_BOUNDARY_TESTS + + DATETRUNC_END_OF_PERIOD_TESTS ) -@pytest.mark.parametrize("test_case", pytest_params(DATETRUNC_DATE_TESTS)) -def test_dateTrunc_dates(collection, test_case: ExpressionTestCase): - """Test $dateTrunc truncation results.""" +@pytest.mark.parametrize("test_case", pytest_params(DATETRUNC_TRUNCATION_TESTS)) +def test_dateTrunc_truncation(collection, test_case: ExpressionTestCase): + """Test $dateTrunc returns the start of the period, honoring binSize and alignment.""" result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) assert_expression_result( result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_types.py deleted file mode 100644 index 0df637af2..000000000 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_types.py +++ /dev/null @@ -1,470 +0,0 @@ -"""$dateTrunc with ObjectId/Timestamp inputs, timezone offsets, DST, boundaries, and extremes.""" - -from datetime import datetime, timezone - -import pytest - -from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( - ExpressionTestCase, -) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( - oid_from_args, - ts_from_args, -) -from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( - assert_expression_result, - execute_expression_with_insert, -) -from documentdb_tests.framework.parametrize import pytest_params -from documentdb_tests.framework.test_constants import ( - DATE_EPOCH, - DATE_MS_BEFORE_EPOCH, - DATE_MS_EPOCH, - DATE_YEAR_1900, - OID_MAX_SIGNED32, - OID_MIN_SIGNED32, - TS_MAX_SIGNED32, - TS_MAX_UNSIGNED32, -) - -# Property [ObjectId And Timestamp Input]: ObjectId and Timestamp inputs are truncated by their -# embedded time. -DATETRUNC_OID_TS_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "oid_trunc_day", - doc={"date": oid_from_args(2021, 3, 20, 11, 30, 5)}, - expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, - expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate an ObjectId to the day", - ), - ExpressionTestCase( - "oid_trunc_hour", - doc={"date": oid_from_args(2021, 3, 20, 11, 30, 5)}, - expression={"$dateTrunc": {"date": "$date", "unit": "hour"}}, - expected=datetime(2021, 3, 20, 11, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate an ObjectId to the hour", - ), - ExpressionTestCase( - "oid_trunc_month", - doc={"date": oid_from_args(2021, 3, 20, 11, 30, 5)}, - expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, - expected=datetime(2021, 3, 1, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate an ObjectId to the month", - ), - ExpressionTestCase( - "oid_trunc_year", - doc={"date": oid_from_args(2021, 3, 20, 11, 30, 5)}, - expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, - expected=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate an ObjectId to the year", - ), - ExpressionTestCase( - "oid_with_tz", - doc={"date": oid_from_args(2021, 3, 20, 11, 30, 5)}, - expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "UTC"}}, - expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate an ObjectId with a timezone", - ), - ExpressionTestCase( - "ts_trunc_day", - doc={"date": ts_from_args(2021, 3, 20, 11, 30, 5)}, - expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, - expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate a Timestamp to the day", - ), - ExpressionTestCase( - "ts_trunc_hour", - doc={"date": ts_from_args(2021, 3, 20, 11, 30, 5)}, - expression={"$dateTrunc": {"date": "$date", "unit": "hour"}}, - expected=datetime(2021, 3, 20, 11, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate a Timestamp to the hour", - ), - ExpressionTestCase( - "ts_trunc_month", - doc={"date": ts_from_args(2021, 3, 20, 11, 30, 5)}, - expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, - expected=datetime(2021, 3, 1, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate a Timestamp to the month", - ), - ExpressionTestCase( - "ts_trunc_year", - doc={"date": ts_from_args(2021, 3, 20, 11, 30, 5)}, - expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, - expected=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate a Timestamp to the year", - ), - ExpressionTestCase( - "ts_with_tz", - doc={"date": ts_from_args(2021, 3, 20, 11, 30, 5)}, - expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "UTC"}}, - expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate a Timestamp with a timezone", - ), -] - -# Property [Timezone Offset]: named zones and numeric offsets shift the truncation boundary. -DATETRUNC_TIMEZONE_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "tz_shifts_day", - doc={"date": datetime(2021, 3, 20, 2, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "-05:00"}}, - expected=datetime(2021, 3, 19, 5, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should shift the day boundary with a negative offset", - ), - ExpressionTestCase( - "tz_gmt", - doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "GMT"}}, - expected=datetime(2021, 6, 15, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept the GMT timezone", - ), - ExpressionTestCase( - "tz_utc", - doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "UTC"}}, - expected=datetime(2021, 6, 15, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept the UTC timezone", - ), - ExpressionTestCase( - "tz_offset_half_hour_east", - doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "+05:30"}}, - expected=datetime(2021, 6, 14, 18, 30, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate with a +05:30 offset", - ), - ExpressionTestCase( - "tz_zero_offset", - doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "+00:00"}}, - expected=datetime(2021, 6, 15, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should treat a +00:00 offset as UTC", - ), - ExpressionTestCase( - "tz_offset_no_colon", - doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "-0500"}}, - expected=datetime(2021, 6, 15, 5, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept a no-colon offset format", - ), - ExpressionTestCase( - "tz_offset_hour_only", - doc={"date": datetime(2021, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "+03"}}, - expected=datetime(2021, 6, 14, 21, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should accept an hour-only offset format", - ), - ExpressionTestCase( - "tz_kolkata_day", - doc={"date": datetime(2021, 6, 15, 20, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "Asia/Kolkata"}}, - expected=datetime(2021, 6, 15, 18, 30, 0, tzinfo=timezone.utc), - msg="$dateTrunc should handle a half-hour named zone for day truncation", - ), -] - -# Property [DST Day Truncation]: day truncation in a DST zone accounts for the transition. -DATETRUNC_DST_DAY_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "dst_spring_forward_day", - doc={"date": datetime(2021, 3, 14, 7, 30, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "America/New_York"}}, - expected=datetime(2021, 3, 14, 5, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should handle DST spring-forward day truncation", - ), - ExpressionTestCase( - "dst_fall_back_day", - doc={"date": datetime(2021, 11, 7, 6, 30, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "day", "timezone": "America/New_York"}}, - expected=datetime(2021, 11, 7, 4, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should handle DST fall-back day truncation", - ), -] - -# Property [DST Sub-Day Units]: hour and minute truncation are unaffected by DST transitions. -DATETRUNC_DST_SUBDAY_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "dst_spring_hour_trunc", - doc={"date": datetime(2021, 3, 14, 7, 30, 0, tzinfo=timezone.utc)}, - expression={ - "$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "America/New_York"} - }, - expected=datetime(2021, 3, 14, 7, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate the hour across DST spring-forward without adjustment", - ), - ExpressionTestCase( - "dst_spring_minute_trunc", - doc={"date": datetime(2021, 3, 14, 7, 0, 30, tzinfo=timezone.utc)}, - expression={ - "$dateTrunc": {"date": "$date", "unit": "minute", "timezone": "America/New_York"} - }, - expected=datetime(2021, 3, 14, 7, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate the minute across DST spring-forward without adjustment", - ), - ExpressionTestCase( - "dst_fall_back_hour_trunc", - doc={"date": datetime(2021, 11, 7, 6, 30, 0, tzinfo=timezone.utc)}, - expression={ - "$dateTrunc": {"date": "$date", "unit": "hour", "timezone": "America/New_York"} - }, - expected=datetime(2021, 11, 7, 6, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate the hour across DST fall-back without adjustment", - ), -] - -# Property [Boundary Idempotence]: a date already at a unit boundary is returned unchanged. -DATETRUNC_BOUNDARY_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "year_at_boundary", - doc={"date": datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, - expected=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should return the date unchanged at a year boundary", - ), - ExpressionTestCase( - "month_at_boundary", - doc={"date": datetime(2021, 6, 1, 0, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, - expected=datetime(2021, 6, 1, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should return the date unchanged at a month boundary", - ), - ExpressionTestCase( - "day_at_boundary", - doc={"date": datetime(2021, 6, 15, 0, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, - expected=datetime(2021, 6, 15, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should return the date unchanged at a day boundary", - ), - ExpressionTestCase( - "hour_at_boundary", - doc={"date": datetime(2021, 6, 15, 10, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "hour"}}, - expected=datetime(2021, 6, 15, 10, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should return the date unchanged at an hour boundary", - ), - ExpressionTestCase( - "minute_at_boundary", - doc={"date": datetime(2021, 6, 15, 10, 30, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "minute"}}, - expected=datetime(2021, 6, 15, 10, 30, 0, tzinfo=timezone.utc), - msg="$dateTrunc should return the date unchanged at a minute boundary", - ), - ExpressionTestCase( - "second_at_boundary", - doc={"date": datetime(2021, 6, 15, 10, 30, 45, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "second"}}, - expected=datetime(2021, 6, 15, 10, 30, 45, tzinfo=timezone.utc), - msg="$dateTrunc should return the date unchanged at a second boundary", - ), -] - -# Property [End Of Period]: a date at the end of a period truncates to that period's start. -DATETRUNC_END_OF_PERIOD_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "year_end", - doc={"date": datetime(2021, 12, 31, 23, 59, 59, 999000, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, - expected=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate the last millisecond of a year to the year start", - ), - ExpressionTestCase( - "quarter_end_q1", - doc={"date": datetime(2021, 3, 31, 23, 59, 59, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "quarter"}}, - expected=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate the end of Q1 to the Q1 start", - ), - ExpressionTestCase( - "quarter_start_q2", - doc={"date": datetime(2021, 4, 1, 0, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "quarter"}}, - expected=datetime(2021, 4, 1, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should return the Q2 start at the exact boundary", - ), - ExpressionTestCase( - "day_end", - doc={"date": datetime(2021, 6, 15, 23, 59, 59, 999000, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, - expected=datetime(2021, 6, 15, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate the last millisecond of a day to the day start", - ), -] - -# Property [Epoch And Distant Dates]: epoch, pre-epoch, distant past, and distant future dates -# truncate correctly, including from ObjectId and Timestamp inputs. -DATETRUNC_EPOCH_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "epoch", - doc={"date": datetime(1970, 1, 1, 12, 30, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, - expected=DATE_EPOCH, - msg="$dateTrunc should truncate an epoch-day date", - ), - ExpressionTestCase( - "pre_epoch", - doc={"date": datetime(1969, 6, 15, 10, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, - expected=datetime(1969, 6, 1, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate a pre-epoch date", - ), - ExpressionTestCase( - "distant_past", - doc={"date": datetime(1900, 3, 15, 8, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, - expected=DATE_YEAR_1900, - msg="$dateTrunc should truncate a distant past date", - ), - ExpressionTestCase( - "distant_future", - doc={"date": datetime(2100, 7, 20, 15, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "quarter"}}, - expected=datetime(2100, 7, 1, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate a distant future date", - ), - ExpressionTestCase( - "oid_epoch", - doc={"date": oid_from_args(1970, 1, 1, 12, 30, 0)}, - expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, - expected=DATE_EPOCH, - msg="$dateTrunc should truncate an epoch ObjectId", - ), - ExpressionTestCase( - "ts_epoch", - doc={"date": ts_from_args(1970, 1, 1, 12, 30, 0)}, - expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, - expected=DATE_EPOCH, - msg="$dateTrunc should truncate an epoch Timestamp", - ), - ExpressionTestCase( - "oid_future", - doc={"date": oid_from_args(2035, 7, 20, 15, 0, 0)}, - expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, - expected=datetime(2035, 1, 1, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate a future ObjectId", - ), - ExpressionTestCase( - "ts_future", - doc={"date": ts_from_args(2100, 7, 20, 15, 0, 0)}, - expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, - expected=datetime(2100, 1, 1, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate a future Timestamp", - ), -] - -# Property [Leap Year]: leap-day dates truncate correctly, including century leap-year rules. -DATETRUNC_LEAP_YEAR_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "leap_day_trunc_day", - doc={"date": datetime(2020, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, - expected=datetime(2020, 2, 29, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate a leap day to the day start", - ), - ExpressionTestCase( - "leap_day_trunc_month", - doc={"date": datetime(2020, 2, 29, 23, 59, 59, 999000, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, - expected=datetime(2020, 2, 1, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate a leap day to the month start", - ), - ExpressionTestCase( - "century_non_leap_1900_trunc_month", - doc={"date": datetime(1900, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, - expected=datetime(1900, 2, 1, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate a 1900 February date to the month start", - ), - ExpressionTestCase( - "century_leap_2000_trunc_month", - doc={"date": datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, - expected=datetime(2000, 2, 1, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate a 2000 leap day to the month start", - ), -] - -# Property [Far Future]: dates in year 9999 truncate correctly. -DATETRUNC_FAR_FUTURE_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "far_future_year", - doc={"date": datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, - expected=datetime(9999, 1, 1, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate a year 9999 date to the year start", - ), - ExpressionTestCase( - "far_future_quarter", - doc={"date": datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, - expression={"$dateTrunc": {"date": "$date", "unit": "quarter"}}, - expected=datetime(9999, 10, 1, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate a year 9999 date to the Q4 start", - ), -] - -# Property [DatetimeMS And Numeric Boundaries]: DatetimeMS, max Timestamp, and signed-boundary -# ObjectId inputs truncate correctly. -DATETRUNC_NUMERIC_BOUNDARY_TESTS: list[ExpressionTestCase] = [ - ExpressionTestCase( - "date_ms_epoch_trunc_day", - doc={"date": DATE_MS_EPOCH}, - expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, - expected=DATE_EPOCH, - msg="$dateTrunc should truncate an epoch DatetimeMS to the day", - ), - ExpressionTestCase( - "date_ms_before_epoch_trunc_day", - doc={"date": DATE_MS_BEFORE_EPOCH}, - expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, - expected=datetime(1969, 12, 31, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate a pre-epoch DatetimeMS to the day", - ), - ExpressionTestCase( - "ts_max_s32_trunc_day", - doc={"date": TS_MAX_SIGNED32}, - expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, - expected=datetime(2038, 1, 19, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate a max signed 32-bit Timestamp to the day", - ), - ExpressionTestCase( - "ts_max_u32_trunc_month", - doc={"date": TS_MAX_UNSIGNED32}, - expression={"$dateTrunc": {"date": "$date", "unit": "month"}}, - expected=datetime(2106, 2, 1, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate a max unsigned 32-bit Timestamp to the month", - ), - ExpressionTestCase( - "oid_max_signed32_trunc_day", - doc={"date": OID_MAX_SIGNED32}, - expression={"$dateTrunc": {"date": "$date", "unit": "day"}}, - expected=datetime(2038, 1, 19, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate a max signed 32-bit ObjectId to the day", - ), - ExpressionTestCase( - "oid_high_bit_trunc_year", - doc={"date": OID_MIN_SIGNED32}, - expression={"$dateTrunc": {"date": "$date", "unit": "year"}}, - expected=datetime(1901, 1, 1, 0, 0, 0, tzinfo=timezone.utc), - msg="$dateTrunc should truncate a high-bit ObjectId to the year", - ), -] - -DATETRUNC_TYPE_TESTS: list[ExpressionTestCase] = ( - DATETRUNC_OID_TS_TESTS - + DATETRUNC_TIMEZONE_TESTS - + DATETRUNC_DST_DAY_TESTS - + DATETRUNC_DST_SUBDAY_TESTS - + DATETRUNC_BOUNDARY_TESTS - + DATETRUNC_END_OF_PERIOD_TESTS - + DATETRUNC_EPOCH_TESTS - + DATETRUNC_LEAP_YEAR_TESTS - + DATETRUNC_FAR_FUTURE_TESTS - + DATETRUNC_NUMERIC_BOUNDARY_TESTS -) - - -@pytest.mark.parametrize("test_case", pytest_params(DATETRUNC_TYPE_TESTS)) -def test_dateTrunc_types(collection, test_case: ExpressionTestCase): - """Test $dateTrunc date-typed inputs, timezones, and boundaries.""" - result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) - assert_expression_result( - result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg - ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_week.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_week.py new file mode 100644 index 000000000..0ab4f3790 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_week.py @@ -0,0 +1,203 @@ +"""$dateTrunc week truncation: startOfWeek values, casing, relevance, and non-week gating.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [StartOfWeek Case]: startOfWeek accepts full day names and three-letter abbreviations +# in any case. +DATETRUNC_STARTOFWEEK_ACCEPT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "sow_mixed_case_Monday", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "Monday"}}, + expected=datetime(2021, 6, 14, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a mixed-case Monday", + ), + ExpressionTestCase( + "sow_uppercase_MONDAY", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "MONDAY"}}, + expected=datetime(2021, 6, 14, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept an uppercase MONDAY", + ), + ExpressionTestCase( + "sow_abbrev_MON", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "MON"}}, + expected=datetime(2021, 6, 14, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept an uppercase abbreviation MON", + ), + ExpressionTestCase( + "sow_mixed_case_Friday", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "Friday"}}, + expected=datetime(2021, 6, 11, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a mixed-case Friday", + ), + ExpressionTestCase( + "sow_uppercase_FRIDAY", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "FRIDAY"}}, + expected=datetime(2021, 6, 11, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept an uppercase FRIDAY", + ), + ExpressionTestCase( + "sow_abbrev_FRI", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "FRI"}}, + expected=datetime(2021, 6, 11, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept an uppercase abbreviation FRI", + ), + ExpressionTestCase( + "sow_uppercase_SUNDAY", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "SUNDAY"}}, + expected=datetime(2021, 6, 13, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept an uppercase SUNDAY", + ), + ExpressionTestCase( + "sow_abbrev_SUN", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "SUN"}}, + expected=datetime(2021, 6, 13, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept an uppercase abbreviation SUN", + ), +] + +# Property [StartOfWeek Relevance]: for a non-week unit a null startOfWeek is ignored when the date +# is a constant literal, but short-circuits the result to null when the date is a field reference. +DATETRUNC_STARTOFWEEK_RELEVANCE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "sow_relevance_literal_day", + expression={ + "$dateTrunc": { + "date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc), + "unit": "day", + "startOfWeek": None, + } + }, + expected=datetime(2021, 3, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should ignore a null startOfWeek for a non-week unit when the date is a " + "constant", + ), + ExpressionTestCase( + "sow_relevance_field_ref_day", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "day", "startOfWeek": None}}, + expected=None, + msg="$dateTrunc should return null for a null startOfWeek on a non-week unit when the date " + "is a field reference", + ), +] + +# Property [Week Truncation]: the week unit truncates to the configured start of week, defaulting +# to Sunday. +DATETRUNC_WEEK_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "week_default_sun", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week"}}, + expected=datetime(2021, 3, 14, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should default the week start to Sunday", + ), + ExpressionTestCase( + "week_monday", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "monday"}}, + expected=datetime(2021, 3, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the week to Monday", + ), + ExpressionTestCase( + "week_friday", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "friday"}}, + expected=datetime(2021, 3, 19, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the week to Friday", + ), + ExpressionTestCase( + "week_sunday", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "sunday"}}, + expected=datetime(2021, 6, 13, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the week to Sunday", + ), + ExpressionTestCase( + "week_tuesday", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "tuesday"}}, + expected=datetime(2021, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the week to Tuesday", + ), + ExpressionTestCase( + "week_wednesday", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "wednesday"}}, + expected=datetime(2021, 6, 16, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the week to Wednesday", + ), + ExpressionTestCase( + "week_thursday", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "thursday"}}, + expected=datetime(2021, 6, 10, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the week to Thursday", + ), + ExpressionTestCase( + "week_saturday", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "saturday"}}, + expected=datetime(2021, 6, 12, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should truncate the week to Saturday", + ), + ExpressionTestCase( + "week_mon_abbrev", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "mon"}}, + expected=datetime(2021, 6, 14, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept the mon abbreviation for startOfWeek", + ), + ExpressionTestCase( + "week_monday_mixed_case", + doc={"date": datetime(2021, 6, 16, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "week", "startOfWeek": "Monday"}}, + expected=datetime(2021, 6, 14, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept a mixed-case Monday for startOfWeek", + ), +] + +# Property [StartOfWeek Ignored]: startOfWeek has no effect for a non-week unit. +DATETRUNC_SOW_IGNORED_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "sow_ignored_month", + doc={"date": datetime(2021, 3, 20, 11, 30, 5, tzinfo=timezone.utc)}, + expression={"$dateTrunc": {"date": "$date", "unit": "month", "startOfWeek": "friday"}}, + expected=datetime(2021, 3, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should ignore startOfWeek for the month unit", + ), +] + +DATETRUNC_WEEK_ALL_TESTS: list[ExpressionTestCase] = ( + DATETRUNC_STARTOFWEEK_ACCEPT_TESTS + + DATETRUNC_STARTOFWEEK_RELEVANCE_TESTS + + DATETRUNC_WEEK_TESTS + + DATETRUNC_SOW_IGNORED_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETRUNC_WEEK_ALL_TESTS)) +def test_dateTrunc_week(collection, test_case: ExpressionTestCase): + """Test $dateTrunc week truncation honors startOfWeek and ignores it otherwise.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + )