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_arithmetic.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_arithmetic.py new file mode 100644 index 000000000..3e5272bde --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_arithmetic.py @@ -0,0 +1,276 @@ +"""$dateAdd core arithmetic: unit application, sign, amount types, and unit equivalence.""" + +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 +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_ZERO, + 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 [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", + ), +] + +DATEADD_ARITHMETIC_TESTS: list[ExpressionTestCase] = ( + DATEADD_UNIT_TESTS + + DATEADD_NEGATIVE_TESTS + + DATEADD_ZERO_TESTS + + DATEADD_AMOUNT_TYPE_TESTS + + DATEADD_AMOUNT_BOUNDARY_TESTS + + DATEADD_UNIT_EQUIVALENCE_TESTS +) + + +@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_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_errors.py new file mode 100644 index 000000000..566125fe2 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_errors.py @@ -0,0 +1,416 @@ +"""$dateAdd rejection cases: invalid operand types/values, bad timezone, overflow, 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 ( + 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, +) + +# 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", + ), +] + +# 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"), + ] +] + +# 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] = [ + 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_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_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 new file mode 100644 index 000000000..6a8753256 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_expressions.py @@ -0,0 +1,89 @@ +"""$dateAdd operand evaluation: literal operands and field-reference resolution.""" + +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 [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", + ), +] + +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 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 new file mode 100644 index 000000000..cf51468db --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateAdd/test_dateAdd_timezone.py @@ -0,0 +1,318 @@ +"""$dateAdd timezone handling: Olson ids, UTC offsets, DST transitions, and field references.""" + +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 [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_TESTS: list[ExpressionTestCase] = ( + DATEADD_TIMEZONE_OLSON_TESTS + + DATEADD_TIMEZONE_OFFSET_TESTS + + DATEADD_TIMEZONE_DST_TESTS + + DATEADD_TIMEZONE_FIELD_REF_TESTS + + DATEADD_TIMEZONE_NULL_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEADD_TIMEZONE_TESTS)) +def test_dateAdd_timezone(collection, test_case: ExpressionTestCase): + """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/__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..5cae1776f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_basic.py @@ -0,0 +1,185 @@ +"""$dateDiff basic behavior: valid differences, result sign, and long 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.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + INT64_ZERO, +) + +# 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 [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 [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", + ), +] + +DATEDIFF_BASIC_TESTS: list[ExpressionTestCase] = ( + DATEDIFF_VALID_TESTS + DATEDIFF_SIGN_TESTS + DATEDIFF_RETURN_TYPE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_BASIC_TESTS)) +def test_dateDiff_basic(collection, test_case: ExpressionTestCase): + """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_counting.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_counting.py new file mode 100644 index 000000000..a14765805 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_counting.py @@ -0,0 +1,390 @@ +"""$dateDiff boundary counting: unit boundaries crossed, quarters, and leap-year day counts.""" + +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 [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 [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", + ), +] + +DATEDIFF_COUNTING_TESTS: list[ExpressionTestCase] = ( + DATEDIFF_BOUNDARY_TESTS + DATEDIFF_QUARTER_TESTS + DATEDIFF_LEAP_YEAR_TESTS +) + + +@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 new file mode 100644 index 000000000..f667b17d8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_expressions.py @@ -0,0 +1,91 @@ +"""$dateDiff operand evaluation: startDate, endDate, unit, and timezone from field references.""" + +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 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", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_FIELD_REF_TESTS)) +def test_dateDiff_expressions(collection, test_case: ExpressionTestCase): + """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_input_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_input_types.py new file mode 100644 index 000000000..4ae0d0a59 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_input_types.py @@ -0,0 +1,234 @@ +"""$dateDiff date-like operand types: ObjectId, Timestamp, and mixed sources.""" + +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 ( + INT64_ZERO, +) + +# 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", + ), +] + +DATEDIFF_INPUT_TYPE_TESTS: list[ExpressionTestCase] = ( + DATEDIFF_OBJECTID_TESTS + DATEDIFF_TIMESTAMP_TESTS + DATEDIFF_MIXED_SOURCE_TESTS +) + + +@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 new file mode 100644 index 000000000..d55e12200 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateDiff/test_dateDiff_timezone.py @@ -0,0 +1,292 @@ +"""$dateDiff timezone handling: offsets, DST, startOfWeek values, and unit gating.""" + +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 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_TESTS: list[ExpressionTestCase] = ( + DATEDIFF_TIMEZONE_VALID_TESTS + + DATEDIFF_TIMEZONE_BOUNDARY_TESTS + + DATEDIFF_TIMEZONE_DST_TESTS + + DATEDIFF_STARTOFWEEK_VALUE_TESTS + + DATEDIFF_TIMEZONE_NULL_TESTS + + DATEDIFF_STARTOFWEEK_GATING_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEDIFF_TIMEZONE_TESTS)) +def test_dateDiff_timezone(collection, test_case: ExpressionTestCase): + """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/__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_arithmetic.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_arithmetic.py new file mode 100644 index 000000000..e5b377832 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_arithmetic.py @@ -0,0 +1,290 @@ +"""$dateSubtract core arithmetic: unit application, sign, amount types, and unit equivalence.""" + +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 +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_ZERO, + 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 [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", + ), +] + +DATESUBTRACT_ARITHMETIC_TESTS: list[ExpressionTestCase] = ( + DATESUBTRACT_UNIT_TESTS + + DATESUBTRACT_NEGATIVE_TESTS + + DATESUBTRACT_ZERO_TESTS + + DATESUBTRACT_AMOUNT_TYPE_TESTS + + DATESUBTRACT_AMOUNT_BOUNDARY_TESTS + + DATESUBTRACT_UNIT_EQUIVALENCE_TESTS +) + + +@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_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_errors.py new file mode 100644 index 000000000..a5ef68659 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_errors.py @@ -0,0 +1,433 @@ +"""$dateSubtract rejection cases: invalid operand types/values, timezone, overflow, 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 ( + 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, +) + +# 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", + ), +] + +# 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"), + ] +] + +# 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] = [ + 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_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_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 new file mode 100644 index 000000000..dba41131a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_expressions.py @@ -0,0 +1,91 @@ +"""$dateSubtract operand evaluation: literal operands and field-reference resolution.""" + +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 [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", + ), +] + +DATESUBTRACT_EXPRESSION_TESTS: list[ExpressionTestCase] = ( + 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 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 new file mode 100644 index 000000000..cb96e147b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateSubtract/test_dateSubtract_timezone.py @@ -0,0 +1,318 @@ +"""$dateSubtract timezone handling: Olson ids, UTC offsets, DST, and field references.""" + +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 [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_TESTS: list[ExpressionTestCase] = ( + DATESUBTRACT_TIMEZONE_OLSON_TESTS + + DATESUBTRACT_TIMEZONE_OFFSET_TESTS + + DATESUBTRACT_TIMEZONE_DST_TESTS + + DATESUBTRACT_TIMEZONE_FIELD_REF_TESTS + + DATESUBTRACT_TIMEZONE_NULL_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATESUBTRACT_TIMEZONE_TESTS)) +def test_dateSubtract_timezone(collection, test_case: ExpressionTestCase): + """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/__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_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_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 new file mode 100644 index 000000000..3ac3f38d0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_expressions.py @@ -0,0 +1,111 @@ +"""$dateTrunc operand evaluation: literal arguments and field-reference resolution.""" + +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 + +# 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", + ), +] + +DATETRUNC_EXPRESSION_TESTS: list[ExpressionTestCase] = ( + 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 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_truncation.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_truncation.py new file mode 100644 index 000000000..c5e23d500 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateTrunc/test_dateTrunc_truncation.py @@ -0,0 +1,264 @@ +"""$dateTrunc truncation semantics: unit periods, bin multiples, alignment, and idempotence.""" + +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 [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", + ), +] + +DATETRUNC_TRUNCATION_TESTS: list[ExpressionTestCase] = ( + DATETRUNC_UNIT_TESTS + + DATETRUNC_BINSIZE_TESTS + + DATETRUNC_BIN_ALIGNMENT_TESTS + + DATETRUNC_BOUNDARY_TESTS + + DATETRUNC_END_OF_PERIOD_TESTS +) + + +@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_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 + ) 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 + )