From 384b6d222e485c15804f47a804cd66c432598ec5 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Thu, 9 Jul 2026 16:19:03 -0700 Subject: [PATCH 1/4] Add $dateFromParts expression tests This change adds tests for the $dateFromParts expression. It was originally authored by @mitchell-elholm. Closes #306 Co-authored-by: Mitchell Elholm Signed-off-by: Daniel Frankcom --- .../date/dateFromParts/__init__.py | 0 .../dateFromParts/test_dateFromParts_basic.py | 787 ++++++++++++++++++ .../test_dateFromParts_expressions.py | 100 +++ .../test_dateFromParts_timezone.py | 539 ++++++++++++ .../dateFromParts/test_dateFromParts_types.py | 517 ++++++++++++ 5 files changed, 1943 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromParts/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromParts/test_dateFromParts_basic.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromParts/test_dateFromParts_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromParts/test_dateFromParts_timezone.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromParts/test_dateFromParts_types.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromParts/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromParts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromParts/test_dateFromParts_basic.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromParts/test_dateFromParts_basic.py new file mode 100644 index 000000000..bc94679ef --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromParts/test_dateFromParts_basic.py @@ -0,0 +1,787 @@ +"""Tests for $dateFromParts arguments, null/missing, defaults, boundaries, and carry-over.""" + +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.error_codes import ( + DATEFROMPARTS_MISSING_YEAR_ERROR, + DATEFROMPARTS_MIXING_ERROR, + DATEFROMPARTS_MIXING_ISO_WITH_CALENDAR_ERROR, + DATEFROMPARTS_NON_OBJECT_ERROR, + DATEFROMPARTS_UNKNOWN_FIELD_ERROR, + DATEFROMPARTS_YEAR_OUT_OF_RANGE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_EPOCH, + DATE_LEAP_FEB29, + DATE_YEAR_1, + DATE_YEAR_9999, + MISSING, +) + +# Property [Argument Handling]: a date is built from either calendar fields or ISO week-date fields. +DATEFROMPARTS_ARG_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "year_only", + doc={"year": 2017}, + expression={"$dateFromParts": {"year": "$year"}}, + expected=datetime(2017, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should build a date from year alone", + ), + ExpressionTestCase( + "calendar_all", + doc={ + "year": 2017, + "month": 2, + "day": 8, + "hour": 12, + "minute": 30, + "second": 45, + "millisecond": 123, + }, + expression={ + "$dateFromParts": { + "year": "$year", + "month": "$month", + "day": "$day", + "hour": "$hour", + "minute": "$minute", + "second": "$second", + "millisecond": "$millisecond", + } + }, + expected=datetime(2017, 2, 8, 12, 30, 45, 123000, tzinfo=timezone.utc), + msg="$dateFromParts should build a date from all calendar fields", + ), + ExpressionTestCase( + "iso_year_only", + doc={"isoWeekYear": 2017}, + expression={"$dateFromParts": {"isoWeekYear": "$isoWeekYear"}}, + expected=datetime(2017, 1, 2, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should build a date from isoWeekYear alone", + ), + ExpressionTestCase( + "iso_all", + doc={"isoWeekYear": 2017, "isoWeek": 6, "isoDayOfWeek": 3, "hour": 12}, + expression={ + "$dateFromParts": { + "isoWeekYear": "$isoWeekYear", + "isoWeek": "$isoWeek", + "isoDayOfWeek": "$isoDayOfWeek", + "hour": "$hour", + } + }, + expected=datetime(2017, 2, 8, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should build a date from all ISO week-date fields", + ), +] + +# Property [Field System Mixing]: combining calendar fields with ISO week-date fields is rejected. +DATEFROMPARTS_MIXING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "mix_year_isoWeekYear", + doc={"year": 2017, "isoWeekYear": 2017}, + expression={"$dateFromParts": {"year": "$year", "isoWeekYear": "$isoWeekYear"}}, + error_code=DATEFROMPARTS_MIXING_ERROR, + msg="$dateFromParts should reject mixing year with isoWeekYear", + ), + ExpressionTestCase( + "mix_year_isoWeek", + doc={"year": 2017, "isoWeek": 6}, + expression={"$dateFromParts": {"year": "$year", "isoWeek": "$isoWeek"}}, + error_code=DATEFROMPARTS_MIXING_ERROR, + msg="$dateFromParts should reject mixing year with isoWeek", + ), + ExpressionTestCase( + "mix_isoWeekYear_month", + doc={"isoWeekYear": 2017, "month": 2}, + expression={"$dateFromParts": {"isoWeekYear": "$isoWeekYear", "month": "$month"}}, + error_code=DATEFROMPARTS_MIXING_ISO_WITH_CALENDAR_ERROR, + msg="$dateFromParts should reject mixing isoWeekYear with calendar month", + ), +] + +# Property [Argument Validation]: a missing year, unknown field, or non-object argument is rejected. +DATEFROMPARTS_ARG_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_both", + expression={"$dateFromParts": {"month": 2, "day": 8}}, + error_code=DATEFROMPARTS_MISSING_YEAR_ERROR, + msg="$dateFromParts should reject an argument with neither year nor isoWeekYear", + ), + ExpressionTestCase( + "empty_object", + expression={"$dateFromParts": {}}, + error_code=DATEFROMPARTS_MISSING_YEAR_ERROR, + msg="$dateFromParts should reject an empty object argument", + ), + ExpressionTestCase( + "unknown_field", + expression={"$dateFromParts": {"year": 2017, "foo": 1}}, + error_code=DATEFROMPARTS_UNKNOWN_FIELD_ERROR, + msg="$dateFromParts should reject an unknown field", + ), + ExpressionTestCase( + "non_object_str", + expression={"$dateFromParts": "string"}, + error_code=DATEFROMPARTS_NON_OBJECT_ERROR, + msg="$dateFromParts should reject a string argument", + ), + ExpressionTestCase( + "non_object_arr", + expression={"$dateFromParts": [1, 2]}, + error_code=DATEFROMPARTS_NON_OBJECT_ERROR, + msg="$dateFromParts should reject an array argument", + ), + ExpressionTestCase( + "non_object_num", + expression={"$dateFromParts": 123}, + error_code=DATEFROMPARTS_NON_OBJECT_ERROR, + msg="$dateFromParts should reject a numeric argument", + ), +] + +# Property [Null Handling]: a null value for any field returns null. +DATEFROMPARTS_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_year", + doc={"year": None}, + expression={"$dateFromParts": {"year": "$year"}}, + expected=None, + msg="$dateFromParts should return null for a null year", + ), + ExpressionTestCase( + "null_month", + doc={"year": 2017, "month": None}, + expression={"$dateFromParts": {"year": "$year", "month": "$month"}}, + expected=None, + msg="$dateFromParts should return null for a null month", + ), + ExpressionTestCase( + "null_day", + doc={"year": 2020, "day": None}, + expression={"$dateFromParts": {"year": "$year", "day": "$day"}}, + expected=None, + msg="$dateFromParts should return null for a null day", + ), + ExpressionTestCase( + "null_hour", + doc={"year": 2020, "hour": None}, + expression={"$dateFromParts": {"year": "$year", "hour": "$hour"}}, + expected=None, + msg="$dateFromParts should return null for a null hour", + ), + ExpressionTestCase( + "null_minute", + doc={"year": 2020, "minute": None}, + expression={"$dateFromParts": {"year": "$year", "minute": "$minute"}}, + expected=None, + msg="$dateFromParts should return null for a null minute", + ), + ExpressionTestCase( + "null_second", + doc={"year": 2020, "second": None}, + expression={"$dateFromParts": {"year": "$year", "second": "$second"}}, + expected=None, + msg="$dateFromParts should return null for a null second", + ), + ExpressionTestCase( + "null_millisecond", + doc={"year": 2020, "millisecond": None}, + expression={"$dateFromParts": {"year": "$year", "millisecond": "$millisecond"}}, + expected=None, + msg="$dateFromParts should return null for a null millisecond", + ), + ExpressionTestCase( + "null_isoWeekYear", + doc={"isoWeekYear": None}, + expression={"$dateFromParts": {"isoWeekYear": "$isoWeekYear"}}, + expected=None, + msg="$dateFromParts should return null for a null isoWeekYear", + ), +] + +# Property [Missing Field Reference]: a missing field reference for any field returns null. +DATEFROMPARTS_MISSING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_year_ref", + expression={"$dateFromParts": {"year": MISSING}}, + expected=None, + msg="$dateFromParts should return null for a missing year field reference", + ), + ExpressionTestCase( + "missing_month_ref", + expression={"$dateFromParts": {"year": 2020, "month": MISSING}}, + expected=None, + msg="$dateFromParts should return null for a missing month field reference", + ), + ExpressionTestCase( + "missing_day_ref", + expression={"$dateFromParts": {"year": 2020, "day": MISSING}}, + expected=None, + msg="$dateFromParts should return null for a missing day field reference", + ), + ExpressionTestCase( + "missing_hour_ref", + expression={"$dateFromParts": {"year": 2020, "hour": MISSING}}, + expected=None, + msg="$dateFromParts should return null for a missing hour field reference", + ), + ExpressionTestCase( + "missing_minute_ref", + expression={"$dateFromParts": {"year": 2020, "minute": MISSING}}, + expected=None, + msg="$dateFromParts should return null for a missing minute field reference", + ), + ExpressionTestCase( + "missing_second_ref", + expression={"$dateFromParts": {"year": 2020, "second": MISSING}}, + expected=None, + msg="$dateFromParts should return null for a missing second field reference", + ), + ExpressionTestCase( + "missing_millisecond_ref", + expression={"$dateFromParts": {"year": 2020, "millisecond": MISSING}}, + expected=None, + msg="$dateFromParts should return null for a missing millisecond field reference", + ), + ExpressionTestCase( + "missing_isoWeekYear_ref", + expression={"$dateFromParts": {"isoWeekYear": MISSING}}, + expected=None, + msg="$dateFromParts should return null for a missing isoWeekYear field reference", + ), + ExpressionTestCase( + "missing_isoWeek_ref", + expression={"$dateFromParts": {"isoWeekYear": 2020, "isoWeek": MISSING}}, + expected=None, + msg="$dateFromParts should return null for a missing isoWeek field reference", + ), + ExpressionTestCase( + "missing_isoDayOfWeek_ref", + expression={"$dateFromParts": {"isoWeekYear": 2020, "isoDayOfWeek": MISSING}}, + expected=None, + msg="$dateFromParts should return null for a missing isoDayOfWeek field reference", + ), +] + +# Property [Default Values]: omitted optional fields default to the start of their range. +DATEFROMPARTS_DEFAULT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "default_all_optional", + doc={"year": 2024}, + expression={"$dateFromParts": {"year": "$year"}}, + expected=datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should default all optional calendar fields to their minimum", + ), + ExpressionTestCase( + "default_month_day", + doc={"year": 2020, "month": 6}, + expression={"$dateFromParts": {"year": "$year", "month": "$month"}}, + expected=datetime(2020, 6, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should default day and time when only year and month are given", + ), + ExpressionTestCase( + "default_day", + doc={"year": 2020, "month": 6, "day": 15}, + expression={"$dateFromParts": {"year": "$year", "month": "$month", "day": "$day"}}, + expected=datetime(2020, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should default the time when only the date is given", + ), + ExpressionTestCase( + "default_hour", + doc={"year": 2020, "month": 6, "day": 15, "hour": 10}, + expression={ + "$dateFromParts": {"year": "$year", "month": "$month", "day": "$day", "hour": "$hour"} + }, + expected=datetime(2020, 6, 15, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should default minute, second, and millisecond", + ), + ExpressionTestCase( + "default_minute", + doc={"year": 2020, "month": 6, "day": 15, "hour": 10, "minute": 30}, + expression={ + "$dateFromParts": { + "year": "$year", + "month": "$month", + "day": "$day", + "hour": "$hour", + "minute": "$minute", + } + }, + expected=datetime(2020, 6, 15, 10, 30, 0, tzinfo=timezone.utc), + msg="$dateFromParts should default second and millisecond", + ), + ExpressionTestCase( + "default_second", + doc={"year": 2020, "month": 6, "day": 15, "hour": 10, "minute": 30, "second": 45}, + expression={ + "$dateFromParts": { + "year": "$year", + "month": "$month", + "day": "$day", + "hour": "$hour", + "minute": "$minute", + "second": "$second", + } + }, + expected=datetime(2020, 6, 15, 10, 30, 45, tzinfo=timezone.utc), + msg="$dateFromParts should default millisecond", + ), + ExpressionTestCase( + "iso_default_week_dow", + doc={"isoWeekYear": 2020}, + expression={"$dateFromParts": {"isoWeekYear": "$isoWeekYear"}}, + expected=datetime(2019, 12, 30, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should default isoWeek and isoDayOfWeek to 1", + ), + ExpressionTestCase( + "iso_default_dow", + doc={"isoWeekYear": 2020, "isoWeek": 10}, + expression={"$dateFromParts": {"isoWeekYear": "$isoWeekYear", "isoWeek": "$isoWeek"}}, + expected=datetime(2020, 3, 2, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should default isoDayOfWeek to 1", + ), +] + +# Property [Date Construction]: dates are built correctly across the epoch and distant past/future. +DATEFROMPARTS_CALENDAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "with_ms", + doc={"year": 2024, "month": 1, "day": 1, "millisecond": 500}, + expression={ + "$dateFromParts": { + "year": "$year", + "month": "$month", + "day": "$day", + "millisecond": "$millisecond", + } + }, + expected=datetime(2024, 1, 1, 0, 0, 0, 500000, tzinfo=timezone.utc), + msg="$dateFromParts should apply the millisecond component", + ), + ExpressionTestCase( + "epoch", + doc={"year": 1970, "month": 1, "day": 1}, + expression={"$dateFromParts": {"year": "$year", "month": "$month", "day": "$day"}}, + expected=DATE_EPOCH, + msg="$dateFromParts should build the Unix epoch", + ), + ExpressionTestCase( + "iso_epoch", + doc={"isoWeekYear": 1970, "isoWeek": 1, "isoDayOfWeek": 4}, + expression={ + "$dateFromParts": { + "isoWeekYear": "$isoWeekYear", + "isoWeek": "$isoWeek", + "isoDayOfWeek": "$isoDayOfWeek", + } + }, + expected=DATE_EPOCH, + msg="$dateFromParts should build the Unix epoch from ISO fields", + ), + ExpressionTestCase( + "pre_epoch", + doc={"year": 1969, "month": 12, "day": 31}, + expression={"$dateFromParts": {"year": "$year", "month": "$month", "day": "$day"}}, + expected=datetime(1969, 12, 31, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should build a pre-epoch date", + ), + ExpressionTestCase( + "distant_past", + doc={"year": 1900, "month": 6, "day": 15}, + expression={"$dateFromParts": {"year": "$year", "month": "$month", "day": "$day"}}, + expected=datetime(1900, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should build a distant past date", + ), + ExpressionTestCase( + "distant_future", + doc={"year": 2100, "month": 3, "day": 1}, + expression={"$dateFromParts": {"year": "$year", "month": "$month", "day": "$day"}}, + expected=datetime(2100, 3, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should build a distant future date", + ), +] + +# Property [Year Range]: a year within [1, 9999] is accepted; a year outside it is rejected. +DATEFROMPARTS_YEAR_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "year_min", + doc={"year": 1}, + expression={"$dateFromParts": {"year": "$year"}}, + expected=DATE_YEAR_1, + msg="$dateFromParts should accept the minimum year", + ), + ExpressionTestCase( + "year_max", + doc={"year": 9999}, + expression={"$dateFromParts": {"year": "$year"}}, + expected=datetime(9999, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept the maximum year", + ), + ExpressionTestCase( + "year_max_full", + doc={ + "year": 9999, + "month": 12, + "day": 31, + "hour": 23, + "minute": 59, + "second": 59, + "millisecond": 999, + }, + expression={ + "$dateFromParts": { + "year": "$year", + "month": "$month", + "day": "$day", + "hour": "$hour", + "minute": "$minute", + "second": "$second", + "millisecond": "$millisecond", + } + }, + expected=DATE_YEAR_9999, + msg="$dateFromParts should build the maximum representable date", + ), + ExpressionTestCase( + "year_zero", + doc={"year": 0}, + expression={"$dateFromParts": {"year": "$year"}}, + error_code=DATEFROMPARTS_YEAR_OUT_OF_RANGE_ERROR, + msg="$dateFromParts should reject year zero as out of range", + ), + ExpressionTestCase( + "year_10000", + doc={"year": 10000}, + expression={"$dateFromParts": {"year": "$year"}}, + error_code=DATEFROMPARTS_YEAR_OUT_OF_RANGE_ERROR, + msg="$dateFromParts should reject a year above the maximum", + ), + ExpressionTestCase( + "year_negative", + doc={"year": -1}, + expression={"$dateFromParts": {"year": "$year"}}, + error_code=DATEFROMPARTS_YEAR_OUT_OF_RANGE_ERROR, + msg="$dateFromParts should reject a negative year", + ), +] + +# Property [Out-of-Range Carry]: an out-of-range field value carries or borrows into adjacent +# fields. +DATEFROMPARTS_CARRY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "month_overflow", + doc={"year": 2017, "month": 14, "day": 1, "hour": 12}, + expression={ + "$dateFromParts": {"year": "$year", "month": "$month", "day": "$day", "hour": "$hour"} + }, + expected=datetime(2018, 2, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should carry a month above 12 into the next year", + ), + ExpressionTestCase( + "month_underflow", + doc={"year": 2017, "month": 0, "day": 1, "hour": 12}, + expression={ + "$dateFromParts": {"year": "$year", "month": "$month", "day": "$day", "hour": "$hour"} + }, + expected=datetime(2016, 12, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should borrow a month below 1 from the previous year", + ), + ExpressionTestCase( + "negative_month", + doc={"year": 2017, "month": -10}, + expression={"$dateFromParts": {"year": "$year", "month": "$month"}}, + expected=datetime(2016, 2, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should borrow a negative month from the previous year", + ), + ExpressionTestCase( + "day_overflow", + doc={"year": 2024, "month": 1, "day": 32}, + expression={"$dateFromParts": {"year": "$year", "month": "$month", "day": "$day"}}, + expected=datetime(2024, 2, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should carry a day past the month end into the next month", + ), + ExpressionTestCase( + "day_underflow", + doc={"year": 2024, "month": 3, "day": 0}, + expression={"$dateFromParts": {"year": "$year", "month": "$month", "day": "$day"}}, + expected=datetime(2024, 2, 29, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should borrow a day below 1 from the previous month", + ), + ExpressionTestCase( + "apr31_carry", + doc={"year": 2020, "month": 4, "day": 31}, + expression={"$dateFromParts": {"year": "$year", "month": "$month", "day": "$day"}}, + expected=datetime(2020, 5, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should carry day 31 of a 30-day month into the next month", + ), + ExpressionTestCase( + "year_boundary_dec31_to_jan1", + doc={"year": 2020, "month": 12, "day": 32}, + expression={"$dateFromParts": {"year": "$year", "month": "$month", "day": "$day"}}, + expected=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should carry across the year boundary from December", + ), + ExpressionTestCase( + "hour_overflow", + doc={"year": 2024, "month": 1, "day": 1, "hour": 25}, + expression={ + "$dateFromParts": {"year": "$year", "month": "$month", "day": "$day", "hour": "$hour"} + }, + expected=datetime(2024, 1, 2, 1, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should carry an hour above 23 into the next day", + ), + ExpressionTestCase( + "minute_overflow", + doc={"year": 2020, "month": 1, "day": 1, "hour": 0, "minute": 61}, + expression={ + "$dateFromParts": { + "year": "$year", + "month": "$month", + "day": "$day", + "hour": "$hour", + "minute": "$minute", + } + }, + expected=datetime(2020, 1, 1, 1, 1, 0, tzinfo=timezone.utc), + msg="$dateFromParts should carry a minute above 59 into the next hour", + ), + ExpressionTestCase( + "minute_underflow", + doc={"year": 2020, "month": 1, "day": 1, "hour": 0, "minute": -1}, + expression={ + "$dateFromParts": { + "year": "$year", + "month": "$month", + "day": "$day", + "hour": "$hour", + "minute": "$minute", + } + }, + expected=datetime(2019, 12, 31, 23, 59, 0, tzinfo=timezone.utc), + msg="$dateFromParts should borrow a negative minute from the previous hour", + ), + ExpressionTestCase( + "second_overflow", + doc={"year": 2020, "month": 1, "day": 1, "second": 61}, + expression={ + "$dateFromParts": { + "year": "$year", + "month": "$month", + "day": "$day", + "second": "$second", + } + }, + expected=datetime(2020, 1, 1, 0, 1, 1, tzinfo=timezone.utc), + msg="$dateFromParts should carry a second above 59 into the next minute", + ), + ExpressionTestCase( + "ms_overflow", + doc={"year": 2020, "month": 1, "day": 1, "millisecond": 1000}, + expression={ + "$dateFromParts": { + "year": "$year", + "month": "$month", + "day": "$day", + "millisecond": "$millisecond", + } + }, + expected=datetime(2020, 1, 1, 0, 0, 1, tzinfo=timezone.utc), + msg="$dateFromParts should carry a millisecond above 999 into the next second", + ), + ExpressionTestCase( + "ms_underflow", + doc={"year": 2020, "month": 1, "day": 1, "millisecond": -1}, + expression={ + "$dateFromParts": { + "year": "$year", + "month": "$month", + "day": "$day", + "millisecond": "$millisecond", + } + }, + expected=datetime(2019, 12, 31, 23, 59, 59, 999000, tzinfo=timezone.utc), + msg="$dateFromParts should borrow a negative millisecond from the previous second", + ), + ExpressionTestCase( + "cascade_carry", + doc={ + "year": 2020, + "month": 12, + "day": 31, + "hour": 23, + "minute": 59, + "second": 59, + "millisecond": 1000, + }, + expression={ + "$dateFromParts": { + "year": "$year", + "month": "$month", + "day": "$day", + "hour": "$hour", + "minute": "$minute", + "second": "$second", + "millisecond": "$millisecond", + } + }, + expected=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should cascade a carry across every field into the next year", + ), + ExpressionTestCase( + "day_366_leap", + doc={"year": 2020, "month": 1, "day": 366}, + expression={"$dateFromParts": {"year": "$year", "month": "$month", "day": "$day"}}, + expected=datetime(2020, 12, 31, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should place day 366 on December 31 of a leap year", + ), + ExpressionTestCase( + "day_366_non_leap", + doc={"year": 2021, "month": 1, "day": 366}, + expression={"$dateFromParts": {"year": "$year", "month": "$month", "day": "$day"}}, + expected=datetime(2022, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should carry day 366 into the next year for a non-leap year", + ), +] + +# Property [Leap Year]: February 29 is valid in leap years and carries into March otherwise. +DATEFROMPARTS_LEAP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "leap_feb29", + doc={"year": 2020, "month": 2, "day": 29}, + expression={"$dateFromParts": {"year": "$year", "month": "$month", "day": "$day"}}, + expected=datetime(2020, 2, 29, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept February 29 in a leap year", + ), + ExpressionTestCase( + "leap_feb29_2024", + doc={"year": 2024, "month": 2, "day": 29}, + expression={"$dateFromParts": {"year": "$year", "month": "$month", "day": "$day"}}, + expected=datetime(2024, 2, 29, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept February 29 in another leap year", + ), + ExpressionTestCase( + "non_leap_feb29_carry", + doc={"year": 2021, "month": 2, "day": 29}, + expression={"$dateFromParts": {"year": "$year", "month": "$month", "day": "$day"}}, + expected=datetime(2021, 3, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should carry February 29 into March for a non-leap year", + ), + ExpressionTestCase( + "leap_feb30_carry", + doc={"year": 2020, "month": 2, "day": 30}, + expression={"$dateFromParts": {"year": "$year", "month": "$month", "day": "$day"}}, + expected=datetime(2020, 3, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should carry February 30 into March in a leap year", + ), + ExpressionTestCase( + "century_non_leap_1900", + doc={"year": 1900, "month": 2, "day": 29}, + expression={"$dateFromParts": {"year": "$year", "month": "$month", "day": "$day"}}, + expected=datetime(1900, 3, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should treat 1900 as a non-leap year (divisible by 100, not 400)", + ), + ExpressionTestCase( + "century_non_leap_2100", + doc={"year": 2100, "month": 2, "day": 29}, + expression={"$dateFromParts": {"year": "$year", "month": "$month", "day": "$day"}}, + expected=datetime(2100, 3, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should treat 2100 as a non-leap year", + ), + ExpressionTestCase( + "century_leap_2000", + doc={"year": 2000, "month": 2, "day": 29}, + expression={"$dateFromParts": {"year": "$year", "month": "$month", "day": "$day"}}, + expected=DATE_LEAP_FEB29, + msg="$dateFromParts should treat 2000 as a leap year (divisible by 400)", + ), +] + +# Property [ISO Week Carry]: out-of-range ISO week and day-of-week values carry across boundaries. +DATEFROMPARTS_ISO_CARRY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "iso_week_53_valid", + doc={"isoWeekYear": 2020, "isoWeek": 53, "isoDayOfWeek": 1}, + expression={ + "$dateFromParts": { + "isoWeekYear": "$isoWeekYear", + "isoWeek": "$isoWeek", + "isoDayOfWeek": "$isoDayOfWeek", + } + }, + expected=datetime(2020, 12, 28, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept ISO week 53 in a 53-week year", + ), + ExpressionTestCase( + "iso_week_53_carry", + doc={"isoWeekYear": 2021, "isoWeek": 53, "isoDayOfWeek": 1}, + expression={ + "$dateFromParts": { + "isoWeekYear": "$isoWeekYear", + "isoWeek": "$isoWeek", + "isoDayOfWeek": "$isoDayOfWeek", + } + }, + expected=datetime(2022, 1, 3, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should carry ISO week 53 into the next year for a 52-week year", + ), + ExpressionTestCase( + "iso_dow_0_carry", + doc={"isoWeekYear": 2020, "isoWeek": 1, "isoDayOfWeek": 0}, + expression={ + "$dateFromParts": { + "isoWeekYear": "$isoWeekYear", + "isoWeek": "$isoWeek", + "isoDayOfWeek": "$isoDayOfWeek", + } + }, + expected=datetime(2019, 12, 29, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should borrow ISO day-of-week 0 from the previous week", + ), + ExpressionTestCase( + "iso_dow_8_carry", + doc={"isoWeekYear": 2020, "isoWeek": 1, "isoDayOfWeek": 8}, + expression={ + "$dateFromParts": { + "isoWeekYear": "$isoWeekYear", + "isoWeek": "$isoWeek", + "isoDayOfWeek": "$isoDayOfWeek", + } + }, + expected=datetime(2020, 1, 6, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should carry ISO day-of-week 8 into the next week", + ), +] + +DATEFROMPARTS_BASIC_TESTS: list[ExpressionTestCase] = ( + DATEFROMPARTS_ARG_TESTS + + DATEFROMPARTS_MIXING_TESTS + + DATEFROMPARTS_ARG_ERROR_TESTS + + DATEFROMPARTS_NULL_TESTS + + DATEFROMPARTS_MISSING_TESTS + + DATEFROMPARTS_DEFAULT_TESTS + + DATEFROMPARTS_CALENDAR_TESTS + + DATEFROMPARTS_YEAR_BOUNDARY_TESTS + + DATEFROMPARTS_CARRY_TESTS + + DATEFROMPARTS_LEAP_TESTS + + DATEFROMPARTS_ISO_CARRY_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEFROMPARTS_BASIC_TESTS)) +def test_dateFromParts_basic(collection, test_case: ExpressionTestCase): + """Test $dateFromParts basic operations.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromParts/test_dateFromParts_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromParts/test_dateFromParts_expressions.py new file mode 100644 index 000000000..6f8f90cef --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromParts/test_dateFromParts_expressions.py @@ -0,0 +1,100 @@ +"""Tests for $dateFromParts field references, expression inputs, 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.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import DATEFROMPARTS_INVALID_TYPE_ERROR +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Field References]: field values may be supplied through field-path references. +DATEFROMPARTS_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "field_ref_ymd", + doc={"y": 2024, "m": 6, "d": 15}, + expression={"$dateFromParts": {"year": "$y", "month": "$m", "day": "$d"}}, + expected=datetime(2024, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept calendar fields from field references", + ), + ExpressionTestCase( + "field_ref_iso", + doc={"wy": 2017, "w": 6, "dow": 3}, + expression={ + "$dateFromParts": {"isoWeekYear": "$wy", "isoWeek": "$w", "isoDayOfWeek": "$dow"} + }, + expected=datetime(2017, 2, 8, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept ISO fields from field references", + ), + ExpressionTestCase( + "nested_field", + doc={"doc": {"y": 2024, "m": 6, "d": 15}}, + expression={"$dateFromParts": {"year": "$doc.y", "month": "$doc.m", "day": "$doc.d"}}, + expected=datetime(2024, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept fields from a nested-object path", + ), + ExpressionTestCase( + "timezone_field_ref", + doc={"y": 2020, "tz": "+05:30"}, + expression={ + "$dateFromParts": {"year": "$y", "month": 1, "day": 1, "hour": 0, "timezone": "$tz"} + }, + expected=datetime(2019, 12, 31, 18, 30, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept a timezone from a field reference", + ), +] + +# Property [Array-Valued Path]: a field path resolving to an array is rejected. +DATEFROMPARTS_ARRAY_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "composite_array_path", + doc={"a": [{"b": 2021}, {"b": 2022}]}, + expression={"$dateFromParts": {"year": "$a.b"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a year path that resolves to an array", + ), +] + +# Property [Expression Inputs]: field values may be computed by sub-expressions. +DATEFROMPARTS_EXPRESSION_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "expression_as_input", + expression={ + "$dateFromParts": {"year": {"$add": [2000, 20]}, "month": {"$subtract": [12, 6]}} + }, + expected=datetime(2020, 6, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should evaluate sub-expressions for its fields", + ), +] + +# Property [Return Type]: the constructed value is a date. +DATEFROMPARTS_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type_date", + expression={"$type": {"$dateFromParts": {"year": 2021, "month": 1, "day": 1}}}, + expected="date", + msg="$dateFromParts should return a date", + ), +] + +DATEFROMPARTS_EXPRESSIONS_TESTS: list[ExpressionTestCase] = ( + DATEFROMPARTS_FIELD_REF_TESTS + + DATEFROMPARTS_ARRAY_PATH_TESTS + + DATEFROMPARTS_EXPRESSION_INPUT_TESTS + + DATEFROMPARTS_RETURN_TYPE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEFROMPARTS_EXPRESSIONS_TESTS)) +def test_dateFromParts_expressions(collection, test_case: ExpressionTestCase): + """Test $dateFromParts field references, expression inputs, and return type.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromParts/test_dateFromParts_timezone.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromParts/test_dateFromParts_timezone.py new file mode 100644 index 000000000..d403151d5 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromParts/test_dateFromParts_timezone.py @@ -0,0 +1,539 @@ +"""Tests for $dateFromParts timezone parsing, offsets, DST, and type validation.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + INVALID_TIMEZONE_ERROR, + INVALID_TIMEZONE_TYPE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import MISSING + +# Property [Valid Timezone]: a UTC-equivalent timezone leaves the constructed date unchanged. +DATEFROMPARTS_TZ_VALID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_gmt", + doc={"timezone": "GMT"}, + expression={"$dateFromParts": {"year": 2024, "timezone": "$timezone"}}, + expected=datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept the GMT timezone", + ), + ExpressionTestCase( + "tz_utc", + doc={"timezone": "UTC"}, + expression={"$dateFromParts": {"year": 2024, "timezone": "$timezone"}}, + expected=datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept the UTC timezone", + ), + ExpressionTestCase( + "tz_zero_offset", + doc={"timezone": "+00:00"}, + expression={"$dateFromParts": {"year": 2024, "timezone": "$timezone"}}, + expected=datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept a zero UTC offset", + ), +] + +# Property [Offset Application]: a timezone shifts the constructed local time back to UTC. +DATEFROMPARTS_TZ_OFFSET_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_ny", + doc={"timezone": "America/New_York"}, + expression={ + "$dateFromParts": { + "year": 2016, + "month": 12, + "day": 31, + "hour": 23, + "minute": 46, + "second": 12, + "timezone": "$timezone", + } + }, + expected=datetime(2017, 1, 1, 4, 46, 12, tzinfo=timezone.utc), + msg="$dateFromParts should shift an Olson timezone local time to UTC", + ), + ExpressionTestCase( + "tz_no_colon", + doc={"timezone": "-0500"}, + expression={ + "$dateFromParts": { + "year": 2020, + "month": 1, + "day": 1, + "hour": 12, + "timezone": "$timezone", + } + }, + expected=datetime(2020, 1, 1, 17, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept a compact offset without a colon", + ), + ExpressionTestCase( + "tz_hour_only", + doc={"timezone": "+03"}, + expression={ + "$dateFromParts": { + "year": 2020, + "month": 1, + "day": 1, + "hour": 12, + "timezone": "$timezone", + } + }, + expected=datetime(2020, 1, 1, 9, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept an hour-only offset", + ), + ExpressionTestCase( + "tz_half_hour", + doc={"timezone": "+05:30"}, + expression={ + "$dateFromParts": { + "year": 2020, + "month": 1, + "day": 1, + "hour": 0, + "timezone": "$timezone", + } + }, + expected=datetime(2019, 12, 31, 18, 30, 0, tzinfo=timezone.utc), + msg="$dateFromParts should apply a half-hour offset", + ), + ExpressionTestCase( + "tz_45min", + doc={"timezone": "+05:45"}, + expression={ + "$dateFromParts": { + "year": 2020, + "month": 1, + "day": 1, + "hour": 0, + "timezone": "$timezone", + } + }, + expected=datetime(2019, 12, 31, 18, 15, 0, tzinfo=timezone.utc), + msg="$dateFromParts should apply a 45-minute offset", + ), + ExpressionTestCase( + "tz_asia_kolkata", + doc={"timezone": "Asia/Kolkata"}, + expression={ + "$dateFromParts": { + "year": 2020, + "month": 1, + "day": 1, + "hour": 0, + "timezone": "$timezone", + } + }, + expected=datetime(2019, 12, 31, 18, 30, 0, tzinfo=timezone.utc), + msg="$dateFromParts should apply a half-hour Olson timezone", + ), + ExpressionTestCase( + "tz_asia_tokyo", + doc={"timezone": "Asia/Tokyo"}, + expression={ + "$dateFromParts": { + "year": 2020, + "month": 1, + "day": 1, + "hour": 0, + "timezone": "$timezone", + } + }, + expected=datetime(2019, 12, 31, 15, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should apply the Asia/Tokyo timezone", + ), + ExpressionTestCase( + "tz_pacific_apia", + doc={"timezone": "Pacific/Apia"}, + expression={ + "$dateFromParts": { + "year": 2020, + "month": 1, + "day": 1, + "hour": 12, + "timezone": "$timezone", + } + }, + expected=datetime(2019, 12, 31, 22, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should apply the Pacific/Apia timezone", + ), + ExpressionTestCase( + "tz_offset_max_east", + doc={"timezone": "+14:00"}, + expression={ + "$dateFromParts": { + "year": 2020, + "month": 1, + "day": 1, + "hour": 0, + "timezone": "$timezone", + } + }, + expected=datetime(2019, 12, 31, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should apply the maximum eastern offset", + ), + ExpressionTestCase( + "tz_offset_max_west", + doc={"timezone": "-11:00"}, + expression={ + "$dateFromParts": { + "year": 2020, + "month": 1, + "day": 1, + "hour": 0, + "timezone": "$timezone", + } + }, + expected=datetime(2020, 1, 1, 11, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should apply a large western offset", + ), + ExpressionTestCase( + "tz_offset_half_hour_west", + doc={"timezone": "-02:30"}, + expression={ + "$dateFromParts": { + "year": 2020, + "month": 1, + "day": 1, + "hour": 12, + "timezone": "$timezone", + } + }, + expected=datetime(2020, 1, 1, 14, 30, 0, tzinfo=timezone.utc), + msg="$dateFromParts should apply a half-hour western offset", + ), + ExpressionTestCase( + "tz_offset_minus13", + doc={"timezone": "-13:00"}, + expression={ + "$dateFromParts": { + "year": 2020, + "month": 1, + "day": 1, + "hour": 12, + "timezone": "$timezone", + } + }, + expected=datetime(2020, 1, 2, 1, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should apply an offset beyond -12:00", + ), + ExpressionTestCase( + "tz_offset_plus15", + doc={"timezone": "+15:00"}, + expression={ + "$dateFromParts": { + "year": 2020, + "month": 1, + "day": 1, + "hour": 12, + "timezone": "$timezone", + } + }, + expected=datetime(2019, 12, 31, 21, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should apply an offset beyond +14:00", + ), + ExpressionTestCase( + "tz_over60_minutes_positive", + doc={"timezone": "+05:70"}, + expression={ + "$dateFromParts": { + "year": 2020, + "month": 1, + "day": 1, + "hour": 12, + "timezone": "$timezone", + } + }, + expected=datetime(2020, 1, 1, 5, 50, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept an offset with more than 60 minutes", + ), + ExpressionTestCase( + "tz_over60_minutes_negative", + doc={"timezone": "-05:70"}, + expression={ + "$dateFromParts": { + "year": 2020, + "month": 1, + "day": 1, + "hour": 12, + "timezone": "$timezone", + } + }, + expected=datetime(2020, 1, 1, 18, 10, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept a negative offset with more than 60 minutes", + ), + ExpressionTestCase( + "tz_over24_hours_positive", + doc={"timezone": "+25:00"}, + expression={ + "$dateFromParts": { + "year": 2020, + "month": 1, + "day": 1, + "hour": 12, + "timezone": "$timezone", + } + }, + expected=datetime(2019, 12, 31, 11, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept an offset above 24 hours", + ), + ExpressionTestCase( + "tz_over24_hours_negative", + doc={"timezone": "-25:00"}, + expression={ + "$dateFromParts": { + "year": 2020, + "month": 1, + "day": 1, + "hour": 12, + "timezone": "$timezone", + } + }, + expected=datetime(2020, 1, 2, 13, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept a negative offset below -24 hours", + ), + ExpressionTestCase( + "tz_max_valid_positive", + doc={"timezone": "+99:99"}, + expression={ + "$dateFromParts": { + "year": 2020, + "month": 1, + "day": 1, + "hour": 12, + "timezone": "$timezone", + } + }, + expected=datetime(2019, 12, 28, 7, 21, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept the maximum two-digit offset", + ), + ExpressionTestCase( + "tz_max_valid_negative", + doc={"timezone": "-99:99"}, + expression={ + "$dateFromParts": { + "year": 2020, + "month": 1, + "day": 1, + "hour": 12, + "timezone": "$timezone", + } + }, + expected=datetime(2020, 1, 5, 16, 39, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept the maximum negative two-digit offset", + ), + ExpressionTestCase( + "tz_europe_london", + doc={"timezone": "Europe/London"}, + expression={ + "$dateFromParts": { + "year": 2020, + "month": 1, + "day": 1, + "hour": 12, + "timezone": "$timezone", + } + }, + expected=datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should apply the Europe/London winter offset", + ), + ExpressionTestCase( + "tz_est_abbreviation", + doc={"timezone": "EST"}, + expression={ + "$dateFromParts": { + "year": 2020, + "month": 1, + "day": 1, + "hour": 12, + "timezone": "$timezone", + } + }, + expected=datetime(2020, 1, 1, 17, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept the EST three-letter Olson identifier", + ), +] + +# Property [DST Application]: an Olson timezone applies the correct offset for the date's season. +DATEFROMPARTS_TZ_DST_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_dst_summer", + doc={"timezone": "America/New_York"}, + expression={ + "$dateFromParts": { + "year": 2020, + "month": 7, + "day": 1, + "hour": 12, + "timezone": "$timezone", + } + }, + expected=datetime(2020, 7, 1, 16, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should apply the EDT offset in summer", + ), + ExpressionTestCase( + "tz_dst_winter", + doc={"timezone": "America/New_York"}, + expression={ + "$dateFromParts": { + "year": 2020, + "month": 1, + "day": 1, + "hour": 12, + "timezone": "$timezone", + } + }, + expected=datetime(2020, 1, 1, 17, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should apply the EST offset in winter", + ), + ExpressionTestCase( + "tz_europe_london_bst", + doc={"timezone": "Europe/London"}, + expression={ + "$dateFromParts": { + "year": 2020, + "month": 7, + "day": 1, + "hour": 12, + "timezone": "$timezone", + } + }, + expected=datetime(2020, 7, 1, 11, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should apply the BST offset in summer for Europe/London", + ), +] + +# Property [Null and Missing Timezone]: a null or missing timezone returns null. +DATEFROMPARTS_TZ_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_null", + doc={"timezone": None}, + expression={"$dateFromParts": {"year": 2024, "timezone": "$timezone"}}, + expected=None, + msg="$dateFromParts should return null for a null timezone", + ), + ExpressionTestCase( + "tz_missing_ref", + expression={"$dateFromParts": {"year": 2020, "timezone": MISSING}}, + expected=None, + msg="$dateFromParts should return null for a missing timezone field reference", + ), +] + +# Property [Invalid Timezone String]: an unrecognized timezone string is rejected. +DATEFROMPARTS_TZ_INVALID_STRING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_invalid_str", + doc={"timezone": "NotATimezone"}, + expression={"$dateFromParts": {"year": 2024, "timezone": "$timezone"}}, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dateFromParts should reject an unrecognized timezone string", + ), + ExpressionTestCase( + "tz_empty", + doc={"timezone": ""}, + expression={"$dateFromParts": {"year": 2024, "timezone": "$timezone"}}, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dateFromParts should reject an empty timezone string", + ), + ExpressionTestCase( + "tz_olson_lowercase", + doc={"timezone": "america/new_york"}, + expression={"$dateFromParts": {"year": 2024, "timezone": "$timezone"}}, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dateFromParts should reject an all-lowercase Olson name", + ), + ExpressionTestCase( + "tz_olson_uppercase", + doc={"timezone": "AMERICA/NEW_YORK"}, + expression={"$dateFromParts": {"year": 2024, "timezone": "$timezone"}}, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dateFromParts should reject an all-uppercase Olson name", + ), + ExpressionTestCase( + "tz_3digit_hours", + doc={"timezone": "+100:00"}, + expression={"$dateFromParts": {"year": 2024, "timezone": "$timezone"}}, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dateFromParts should reject a three-digit hour offset", + ), + ExpressionTestCase( + "tz_edt_abbreviation", + doc={"timezone": "EDT"}, + expression={ + "$dateFromParts": { + "year": 2020, + "month": 7, + "day": 1, + "hour": 12, + "timezone": "$timezone", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dateFromParts should reject EDT, which is not a valid Olson identifier", + ), + ExpressionTestCase( + "tz_nonexistent_olson", + doc={"timezone": "America/Nowhere"}, + expression={"$dateFromParts": {"year": 2024, "timezone": "$timezone"}}, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dateFromParts should reject a non-existent Olson timezone", + ), +] + +# Property [Timezone Type Rejection]: a non-string timezone produces a type error. +DATEFROMPARTS_TZ_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"tz_type_{tid}", + doc={"timezone": val}, + expression={"$dateFromParts": {"year": 2024, "timezone": "$timezone"}}, + error_code=INVALID_TIMEZONE_TYPE_ERROR, + msg=f"$dateFromParts should reject a {tid} timezone as a non-string", + ) + for tid, val in [ + ("int32", 5), + ("int64", Int64(5)), + ("double", 5.0), + ("decimal128", Decimal128("5")), + ("bool", True), + ("array", ["UTC"]), + ("object", {"tz": "UTC"}), + ("objectid", ObjectId("000000000000000000000000")), + ("datetime", datetime(2020, 1, 1, tzinfo=timezone.utc)), + ("regex", Regex(".*")), + ("binary", Binary(b"\x01")), + ("code", Code("function(){}")), + ("timestamp", Timestamp(0, 1)), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +DATEFROMPARTS_TIMEZONE_TESTS: list[ExpressionTestCase] = ( + DATEFROMPARTS_TZ_VALID_TESTS + + DATEFROMPARTS_TZ_OFFSET_TESTS + + DATEFROMPARTS_TZ_DST_TESTS + + DATEFROMPARTS_TZ_NULL_TESTS + + DATEFROMPARTS_TZ_INVALID_STRING_TESTS + + DATEFROMPARTS_TZ_TYPE_ERROR_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEFROMPARTS_TIMEZONE_TESTS)) +def test_dateFromParts_timezone(collection, test_case: ExpressionTestCase): + """Test $dateFromParts timezone handling.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromParts/test_dateFromParts_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromParts/test_dateFromParts_types.py new file mode 100644 index 000000000..37ce34697 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromParts/test_dateFromParts_types.py @@ -0,0 +1,517 @@ +"""Tests for $dateFromParts type validation of the year and optional numeric fields.""" + +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 ( + DATEFROMPARTS_INVALID_TYPE_ERROR, + DATEFROMPARTS_YEAR_OUT_OF_RANGE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Property [Year Type Acceptance]: year accepts any integer-valued numeric type. +DATEFROMPARTS_YEAR_TYPE_ACCEPT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_year", + doc={"year": 2017}, + expression={"$dateFromParts": {"year": "$year"}}, + expected=datetime(2017, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept an int32 year", + ), + ExpressionTestCase( + "int64_year", + doc={"year": Int64(2017)}, + expression={"$dateFromParts": {"year": "$year"}}, + expected=datetime(2017, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept an int64 year", + ), + ExpressionTestCase( + "double_year", + doc={"year": 2017.0}, + expression={"$dateFromParts": {"year": "$year"}}, + expected=datetime(2017, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept an integer-valued double year", + ), + ExpressionTestCase( + "decimal128_year", + doc={"year": Decimal128("2017")}, + expression={"$dateFromParts": {"year": "$year"}}, + expected=datetime(2017, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept a decimal128 year", + ), +] + +# Property [Year Type Rejection]: a non-numeric year is rejected. +DATEFROMPARTS_YEAR_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + *[ + ExpressionTestCase( + f"year_type_{tid}", + doc={"year": val}, + expression={"$dateFromParts": {"year": "$year"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg=f"$dateFromParts should reject a {tid} year", + ) + for tid, val in [ + ("string", "2017"), + ("bool", True), + ("array", [2017]), + ("object", {"y": 2017}), + ("datetime", datetime(2017, 1, 1, tzinfo=timezone.utc)), + ("regex", Regex(".*")), + ("binary", Binary(b"\x01")), + ("code", Code("function(){}")), + ("timestamp", Timestamp(0, 1)), + ("objectid", ObjectId("000000000000000000000000")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + ExpressionTestCase( + "single_element_array_year", + doc={"year": [2020]}, + expression={"$dateFromParts": {"year": "$year"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a single-element array year without unwrapping it", + ), +] + +# Property [Invalid Numeric Year]: a non-integral, NaN, or infinite year is rejected. +DATEFROMPARTS_YEAR_NUMERIC_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "non_integral_double_year", + doc={"year": 2017.9}, + expression={"$dateFromParts": {"year": "$year"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a non-integral double year", + ), + ExpressionTestCase( + "nan_year", + doc={"year": FLOAT_NAN}, + expression={"$dateFromParts": {"year": "$year"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a NaN double year", + ), + ExpressionTestCase( + "infinity_year", + doc={"year": FLOAT_INFINITY}, + expression={"$dateFromParts": {"year": "$year"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject an infinite double year", + ), + ExpressionTestCase( + "neg_infinity_year", + doc={"year": FLOAT_NEGATIVE_INFINITY}, + expression={"$dateFromParts": {"year": "$year"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a negative-infinite double year", + ), + ExpressionTestCase( + "decimal128_nan_year", + doc={"year": DECIMAL128_NAN}, + expression={"$dateFromParts": {"year": "$year"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a NaN decimal128 year", + ), + ExpressionTestCase( + "decimal128_infinity_year", + doc={"year": DECIMAL128_INFINITY}, + expression={"$dateFromParts": {"year": "$year"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject an infinite decimal128 year", + ), + ExpressionTestCase( + "decimal128_neg_infinity_year", + doc={"year": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$dateFromParts": {"year": "$year"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a negative-infinite decimal128 year", + ), +] + +# Property [Negative Zero Year]: a negative-zero year is rejected as out of range. +DATEFROMPARTS_YEAR_NEG_ZERO_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "double_negative_zero_year", + doc={"year": DOUBLE_NEGATIVE_ZERO}, + expression={"$dateFromParts": {"year": "$year"}}, + error_code=DATEFROMPARTS_YEAR_OUT_OF_RANGE_ERROR, + msg="$dateFromParts should reject a double negative-zero year as out of range", + ), + ExpressionTestCase( + "decimal128_negative_zero_year", + doc={"year": DECIMAL128_NEGATIVE_ZERO}, + expression={"$dateFromParts": {"year": "$year"}}, + error_code=DATEFROMPARTS_YEAR_OUT_OF_RANGE_ERROR, + msg="$dateFromParts should reject a decimal128 negative-zero year as out of range", + ), +] + +# Property [Optional Field Type Acceptance]: optional fields accept any integer-valued numeric type. +DATEFROMPARTS_OPTIONAL_TYPE_ACCEPT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int64_month", + doc={"year": 2020, "month": Int64(6)}, + expression={"$dateFromParts": {"year": "$year", "month": "$month"}}, + expected=datetime(2020, 6, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept an int64 month", + ), + ExpressionTestCase( + "int64_day", + doc={"year": 2020, "day": Int64(15)}, + expression={"$dateFromParts": {"year": "$year", "day": "$day"}}, + expected=datetime(2020, 1, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept an int64 day", + ), + ExpressionTestCase( + "int64_hour", + doc={"year": 2020, "hour": Int64(10)}, + expression={"$dateFromParts": {"year": "$year", "hour": "$hour"}}, + expected=datetime(2020, 1, 1, 10, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept an int64 hour", + ), + ExpressionTestCase( + "int64_minute", + doc={"year": 2020, "minute": Int64(30)}, + expression={"$dateFromParts": {"year": "$year", "minute": "$minute"}}, + expected=datetime(2020, 1, 1, 0, 30, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept an int64 minute", + ), + ExpressionTestCase( + "int64_second", + doc={"year": 2020, "second": Int64(45)}, + expression={"$dateFromParts": {"year": "$year", "second": "$second"}}, + expected=datetime(2020, 1, 1, 0, 0, 45, tzinfo=timezone.utc), + msg="$dateFromParts should accept an int64 second", + ), + ExpressionTestCase( + "int64_millisecond", + doc={"year": 2020, "millisecond": Int64(500)}, + expression={"$dateFromParts": {"year": "$year", "millisecond": "$millisecond"}}, + expected=datetime(2020, 1, 1, 0, 0, 0, 500000, tzinfo=timezone.utc), + msg="$dateFromParts should accept an int64 millisecond", + ), + ExpressionTestCase( + "double_month", + doc={"year": 2020, "month": 6.0}, + expression={"$dateFromParts": {"year": "$year", "month": "$month"}}, + expected=datetime(2020, 6, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept an integer-valued double month", + ), + ExpressionTestCase( + "decimal128_month", + doc={"year": 2020, "month": Decimal128("6")}, + expression={"$dateFromParts": {"year": "$year", "month": "$month"}}, + expected=datetime(2020, 6, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept a decimal128 month", + ), + ExpressionTestCase( + "int64_isoWeekYear", + doc={"isoWeekYear": Int64(2020)}, + expression={"$dateFromParts": {"isoWeekYear": "$isoWeekYear"}}, + expected=datetime(2019, 12, 30, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept an int64 isoWeekYear", + ), + ExpressionTestCase( + "int64_isoWeek", + doc={"isoWeekYear": 2020, "isoWeek": Int64(10)}, + expression={"$dateFromParts": {"isoWeekYear": "$isoWeekYear", "isoWeek": "$isoWeek"}}, + expected=datetime(2020, 3, 2, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept an int64 isoWeek", + ), + ExpressionTestCase( + "int64_isoDayOfWeek", + doc={"isoWeekYear": 2020, "isoWeek": 1, "isoDayOfWeek": Int64(3)}, + expression={ + "$dateFromParts": { + "isoWeekYear": "$isoWeekYear", + "isoWeek": "$isoWeek", + "isoDayOfWeek": "$isoDayOfWeek", + } + }, + expected=datetime(2020, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromParts should accept an int64 isoDayOfWeek", + ), +] + +# Property [Optional Field Type Rejection]: a non-numeric optional field is rejected. The month +# field is covered exhaustively; the other optional fields confirm the shared rejection is wired. +DATEFROMPARTS_OPTIONAL_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + *[ + ExpressionTestCase( + f"month_type_{tid}", + doc={"year": 2020, "month": val}, + expression={"$dateFromParts": {"year": "$year", "month": "$month"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg=f"$dateFromParts should reject a {tid} month", + ) + for tid, val in [ + ("string", "June"), + ("bool", True), + ("array", [6]), + ("object", {"m": 6}), + ("datetime", datetime(2020, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("000000000000000000000000")), + ("timestamp", Timestamp(0, 1)), + ("binary", Binary(b"\x01")), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + ExpressionTestCase( + "string_day", + doc={"year": 2020, "day": "15"}, + expression={"$dateFromParts": {"year": "$year", "day": "$day"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a string day", + ), + ExpressionTestCase( + "string_hour", + doc={"year": 2020, "hour": "10"}, + expression={"$dateFromParts": {"year": "$year", "hour": "$hour"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a string hour", + ), + ExpressionTestCase( + "string_minute", + doc={"year": 2020, "minute": "30"}, + expression={"$dateFromParts": {"year": "$year", "minute": "$minute"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a string minute", + ), + ExpressionTestCase( + "string_second", + doc={"year": 2020, "second": "45"}, + expression={"$dateFromParts": {"year": "$year", "second": "$second"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a string second", + ), + ExpressionTestCase( + "string_millisecond", + doc={"year": 2020, "millisecond": "500"}, + expression={"$dateFromParts": {"year": "$year", "millisecond": "$millisecond"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a string millisecond", + ), + ExpressionTestCase( + "bool_day", + doc={"year": 2020, "day": True}, + expression={"$dateFromParts": {"year": "$year", "day": "$day"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a bool day", + ), + ExpressionTestCase( + "bool_hour", + doc={"year": 2020, "hour": False}, + expression={"$dateFromParts": {"year": "$year", "hour": "$hour"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a bool hour", + ), + ExpressionTestCase( + "object_day", + doc={"year": 2020, "day": {"d": 15}}, + expression={"$dateFromParts": {"year": "$year", "day": "$day"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject an object day", + ), + ExpressionTestCase( + "string_isoWeekYear", + doc={"isoWeekYear": "2020"}, + expression={"$dateFromParts": {"isoWeekYear": "$isoWeekYear"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a string isoWeekYear", + ), + ExpressionTestCase( + "bool_isoWeekYear", + doc={"isoWeekYear": True}, + expression={"$dateFromParts": {"isoWeekYear": "$isoWeekYear"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a bool isoWeekYear", + ), + ExpressionTestCase( + "string_isoWeek", + doc={"isoWeekYear": 2020, "isoWeek": "10"}, + expression={"$dateFromParts": {"isoWeekYear": "$isoWeekYear", "isoWeek": "$isoWeek"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a string isoWeek", + ), + ExpressionTestCase( + "string_isoDayOfWeek", + doc={"isoWeekYear": 2020, "isoDayOfWeek": "3"}, + expression={ + "$dateFromParts": {"isoWeekYear": "$isoWeekYear", "isoDayOfWeek": "$isoDayOfWeek"} + }, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a string isoDayOfWeek", + ), +] + +# Property [Non-Integral Optional Field]: a non-integral double optional field is rejected. +DATEFROMPARTS_OPTIONAL_NON_INTEGRAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "non_integral_double_month", + doc={"year": 2020, "month": 6.5}, + expression={"$dateFromParts": {"year": "$year", "month": "$month"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a non-integral double month", + ), + ExpressionTestCase( + "non_integral_double_day", + doc={"year": 2020, "day": 15.5}, + expression={"$dateFromParts": {"year": "$year", "day": "$day"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a non-integral double day", + ), + ExpressionTestCase( + "non_integral_double_hour", + doc={"year": 2020, "hour": 10.5}, + expression={"$dateFromParts": {"year": "$year", "hour": "$hour"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a non-integral double hour", + ), + ExpressionTestCase( + "non_integral_double_minute", + doc={"year": 2020, "minute": 30.5}, + expression={"$dateFromParts": {"year": "$year", "minute": "$minute"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a non-integral double minute", + ), + ExpressionTestCase( + "non_integral_double_second", + doc={"year": 2020, "second": 45.5}, + expression={"$dateFromParts": {"year": "$year", "second": "$second"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a non-integral double second", + ), + ExpressionTestCase( + "non_integral_double_millisecond", + doc={"year": 2020, "millisecond": 500.5}, + expression={"$dateFromParts": {"year": "$year", "millisecond": "$millisecond"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a non-integral double millisecond", + ), + ExpressionTestCase( + "non_integral_double_isoWeekYear", + doc={"isoWeekYear": 2020.5}, + expression={"$dateFromParts": {"isoWeekYear": "$isoWeekYear"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a non-integral double isoWeekYear", + ), + ExpressionTestCase( + "non_integral_double_isoWeek", + doc={"isoWeekYear": 2020, "isoWeek": 10.5}, + expression={"$dateFromParts": {"isoWeekYear": "$isoWeekYear", "isoWeek": "$isoWeek"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a non-integral double isoWeek", + ), + ExpressionTestCase( + "non_integral_double_isoDayOfWeek", + doc={"isoWeekYear": 2020, "isoDayOfWeek": 3.5}, + expression={ + "$dateFromParts": {"isoWeekYear": "$isoWeekYear", "isoDayOfWeek": "$isoDayOfWeek"} + }, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a non-integral double isoDayOfWeek", + ), +] + +# Property [Non-Finite Optional Field]: a NaN or infinite double or decimal128 optional field is +# rejected. +DATEFROMPARTS_OPTIONAL_NON_FINITE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "double_infinity_month", + doc={"year": 2020, "month": FLOAT_INFINITY}, + expression={"$dateFromParts": {"year": "$year", "month": "$month"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject an infinite double month", + ), + ExpressionTestCase( + "double_neg_infinity_month", + doc={"year": 2020, "month": FLOAT_NEGATIVE_INFINITY}, + expression={"$dateFromParts": {"year": "$year", "month": "$month"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a negative-infinite double month", + ), + ExpressionTestCase( + "double_nan_month", + doc={"year": 2020, "month": FLOAT_NAN}, + expression={"$dateFromParts": {"year": "$year", "month": "$month"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a NaN double month", + ), + ExpressionTestCase( + "decimal128_infinity_month", + doc={"year": 2020, "month": DECIMAL128_INFINITY}, + expression={"$dateFromParts": {"year": "$year", "month": "$month"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject an infinite decimal128 month", + ), + ExpressionTestCase( + "decimal128_neg_infinity_month", + doc={"year": 2020, "month": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$dateFromParts": {"year": "$year", "month": "$month"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a negative-infinite decimal128 month", + ), + ExpressionTestCase( + "decimal128_nan_month", + doc={"year": 2020, "month": DECIMAL128_NAN}, + expression={"$dateFromParts": {"year": "$year", "month": "$month"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a NaN decimal128 month", + ), + ExpressionTestCase( + "decimal128_infinity_isoWeekYear", + doc={"isoWeekYear": DECIMAL128_INFINITY}, + expression={"$dateFromParts": {"isoWeekYear": "$isoWeekYear"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject an infinite decimal128 isoWeekYear", + ), + ExpressionTestCase( + "decimal128_neg_infinity_isoWeekYear", + doc={"isoWeekYear": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$dateFromParts": {"isoWeekYear": "$isoWeekYear"}}, + error_code=DATEFROMPARTS_INVALID_TYPE_ERROR, + msg="$dateFromParts should reject a negative-infinite decimal128 isoWeekYear", + ), +] + +DATEFROMPARTS_TYPE_TESTS: list[ExpressionTestCase] = ( + DATEFROMPARTS_YEAR_TYPE_ACCEPT_TESTS + + DATEFROMPARTS_YEAR_TYPE_ERROR_TESTS + + DATEFROMPARTS_YEAR_NUMERIC_ERROR_TESTS + + DATEFROMPARTS_YEAR_NEG_ZERO_TESTS + + DATEFROMPARTS_OPTIONAL_TYPE_ACCEPT_TESTS + + DATEFROMPARTS_OPTIONAL_TYPE_ERROR_TESTS + + DATEFROMPARTS_OPTIONAL_NON_INTEGRAL_TESTS + + DATEFROMPARTS_OPTIONAL_NON_FINITE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEFROMPARTS_TYPE_TESTS)) +def test_dateFromParts_types(collection, test_case: ExpressionTestCase): + """Test $dateFromParts type validation.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) From b6f8d810ee10d869c66d25f0077bb80fc7ebe7c2 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Fri, 10 Jul 2026 10:40:53 -0700 Subject: [PATCH 2/4] Add $dateFromString expression tests This change adds tests for the $dateFromString expression, including cross-operator combination tests that exercise $dateFromString inside other date operators and $toDate/$convert equivalence. It was originally authored by @mitchell-elholm. Closes #307 Co-authored-by: Mitchell Elholm Signed-off-by: Daniel Frankcom --- .../date/dateFromString/__init__.py | 0 .../test_dateFromString_basic.py | 563 ++++++++++++++++++ .../test_dateFromString_expressions.py | 144 +++++ .../test_dateFromString_timezone.py | 436 ++++++++++++++ .../test_dateFromString_types.py | 140 +++++ ...ns_combination_date_construct_operators.py | 124 ++++ 6 files changed, 1407 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromString/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromString/test_dateFromString_basic.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromString/test_dateFromString_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromString/test_dateFromString_timezone.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromString/test_dateFromString_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_date_construct_operators.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromString/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromString/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromString/test_dateFromString_basic.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromString/test_dateFromString_basic.py new file mode 100644 index 000000000..7e3e02863 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromString/test_dateFromString_basic.py @@ -0,0 +1,563 @@ +"""Tests for $dateFromString argument handling, null/onNull, onError, formats, and parsing.""" + +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.error_codes import ( + CONVERSION_FAILURE_ERROR, + DATEFROMSTRING_MISSING_DATESTRING_ERROR, + DATEFROMSTRING_NON_OBJECT_ERROR, + DATEFROMSTRING_UNKNOWN_FIELD_ERROR, + DATETOSTRING_INVALID_FORMAT_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_EPOCH, + DATE_LEAP_FEB29, + DATE_YEAR_1900, + DATE_YEAR_9999, + MISSING, +) + +# Property [Argument Handling]: a date is parsed from a dateString, optionally with format and +# timezone. +DATEFROMSTRING_ARG_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dateString_only", + doc={"dateString": "2017-02-08T12:10:40.787"}, + expression={"$dateFromString": {"dateString": "$dateString"}}, + expected=datetime(2017, 2, 8, 12, 10, 40, 787000, tzinfo=timezone.utc), + msg="$dateFromString should parse an ISO dateString on its own", + ), + ExpressionTestCase( + "with_format", + doc={"dateString": "06-15-2018"}, + expression={"$dateFromString": {"dateString": "$dateString", "format": "%m-%d-%Y"}}, + expected=datetime(2018, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should parse a dateString with a custom format", + ), + ExpressionTestCase( + "with_tz", + doc={"dateString": "2017-02-08T12:10:40.787"}, + expression={ + "$dateFromString": { + "dateString": "$dateString", + "timezone": "America/New_York", + } + }, + expected=datetime(2017, 2, 8, 17, 10, 40, 787000, tzinfo=timezone.utc), + msg="$dateFromString should apply the timezone argument to a naive dateString", + ), + ExpressionTestCase( + "all_fields", + doc={"dateString": "06-15-2018"}, + expression={ + "$dateFromString": { + "dateString": "$dateString", + "format": "%m-%d-%Y", + "timezone": "UTC", + "onError": "bad", + "onNull": "missing", + } + }, + expected=datetime(2018, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should parse when every argument is provided", + ), +] + +# Property [Argument Validation]: a missing dateString, unknown field, or non-object argument is +# rejected. +DATEFROMSTRING_ARG_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_dateString", + expression={"$dateFromString": {"format": "%Y"}}, + error_code=DATEFROMSTRING_MISSING_DATESTRING_ERROR, + msg="$dateFromString should reject an argument with no dateString field", + ), + ExpressionTestCase( + "empty_object", + expression={"$dateFromString": {}}, + error_code=DATEFROMSTRING_MISSING_DATESTRING_ERROR, + msg="$dateFromString should reject an empty object argument", + ), + ExpressionTestCase( + "unknown_field", + expression={"$dateFromString": {"dateString": "2017-02-08", "foo": 1}}, + error_code=DATEFROMSTRING_UNKNOWN_FIELD_ERROR, + msg="$dateFromString should reject an unknown field", + ), + ExpressionTestCase( + "non_object_str", + expression={"$dateFromString": "string"}, + error_code=DATEFROMSTRING_NON_OBJECT_ERROR, + msg="$dateFromString should reject a string argument", + ), + ExpressionTestCase( + "non_object_arr", + expression={"$dateFromString": [1, 2]}, + error_code=DATEFROMSTRING_NON_OBJECT_ERROR, + msg="$dateFromString should reject an array argument", + ), + ExpressionTestCase( + "non_object_num", + expression={"$dateFromString": 123}, + error_code=DATEFROMSTRING_NON_OBJECT_ERROR, + msg="$dateFromString should reject a numeric argument", + ), +] + +# Property [Null and onNull]: a null or missing dateString yields null unless onNull supplies a +# value. +DATEFROMSTRING_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_no_onNull", + doc={"dateString": None}, + expression={"$dateFromString": {"dateString": "$dateString"}}, + expected=None, + msg="$dateFromString should return null for a null dateString with no onNull", + ), + ExpressionTestCase( + "null_onNull_str", + doc={"dateString": None}, + expression={"$dateFromString": {"dateString": "$dateString", "onNull": "N/A"}}, + expected="N/A", + msg="$dateFromString should return the onNull value for a null dateString", + ), + ExpressionTestCase( + "null_onNull_date", + doc={"dateString": None}, + expression={"$dateFromString": {"dateString": "$dateString", "onNull": DATE_EPOCH}}, + expected=DATE_EPOCH, + msg="$dateFromString should return a date onNull value for a null dateString", + ), + ExpressionTestCase( + "missing_ref", + expression={"$dateFromString": {"dateString": MISSING}}, + expected=None, + msg="$dateFromString should return null when the dateString field is missing", + ), + ExpressionTestCase( + "missing_ref_onNull", + expression={"$dateFromString": {"dateString": MISSING, "onNull": "missing"}}, + expected="missing", + msg="$dateFromString should return the onNull value when the dateString field is missing", + ), +] + +# Property [onError]: an unparsable dateString yields the onError value, or errors when onError is +# absent. +DATEFROMSTRING_ONERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "unparsable_no_onError", + doc={"dateString": "not-a-date"}, + expression={"$dateFromString": {"dateString": "$dateString"}}, + error_code=CONVERSION_FAILURE_ERROR, + msg="$dateFromString should error on an unparsable dateString with no onError", + ), + ExpressionTestCase( + "unparsable_onError_str", + doc={"dateString": "not-a-date"}, + expression={"$dateFromString": {"dateString": "$dateString", "onError": "bad"}}, + expected="bad", + msg="$dateFromString should return the onError value for an unparsable dateString", + ), + ExpressionTestCase( + "unparsable_onError_null", + doc={"dateString": "not-a-date"}, + expression={"$dateFromString": {"dateString": "$dateString", "onError": None}}, + expected=None, + msg="$dateFromString should return a null onError value for an unparsable dateString", + ), + ExpressionTestCase( + "valid_with_onError", + doc={"dateString": "2017-02-08"}, + expression={"$dateFromString": {"dateString": "$dateString", "onError": "bad"}}, + expected=datetime(2017, 2, 8, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should ignore onError when the dateString parses", + ), + # onNull takes precedence over onError when the dateString is null. + ExpressionTestCase( + "null_with_both_onError_onNull", + doc={"dateString": None}, + expression={ + "$dateFromString": { + "dateString": "$dateString", + "onError": "error_val", + "onNull": "null_val", + } + }, + expected="null_val", + msg="$dateFromString should prefer onNull over onError for a null dateString", + ), +] + +# Property [Custom Format]: an explicit format string controls how the dateString is parsed. +DATEFROMSTRING_FORMAT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "format_dmy", + doc={"dateString": "15-06-2018"}, + expression={"$dateFromString": {"dateString": "$dateString", "format": "%d-%m-%Y"}}, + expected=datetime(2018, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should parse a day-month-year format", + ), + ExpressionTestCase( + "format_mdy", + doc={"dateString": "06-15-2018"}, + expression={"$dateFromString": {"dateString": "$dateString", "format": "%m-%d-%Y"}}, + expected=datetime(2018, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should parse a month-day-year format", + ), + ExpressionTestCase( + "format_full", + doc={"dateString": "2018-06-15T10:30:45.123"}, + expression={ + "$dateFromString": { + "dateString": "$dateString", + "format": "%Y-%m-%dT%H:%M:%S.%L", + } + }, + expected=datetime(2018, 6, 15, 10, 30, 45, 123000, tzinfo=timezone.utc), + msg="$dateFromString should parse a full datetime format", + ), +] + +# Property [Format Specifiers]: each supported conversion specifier parses its component correctly. +DATEFROMSTRING_FORMAT_SPECIFIER_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "format_b_month", + doc={"dateString": "jan 15 2020"}, + expression={"$dateFromString": {"dateString": "$dateString", "format": "%b %d %Y"}}, + expected=datetime(2020, 1, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should parse an abbreviated month name with %b", + ), + ExpressionTestCase( + "format_B_month", + doc={"dateString": "january 15 2020"}, + expression={"$dateFromString": {"dateString": "$dateString", "format": "%B %d %Y"}}, + expected=datetime(2020, 1, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should parse a full month name with %B", + ), + ExpressionTestCase( + "format_slash", + doc={"dateString": "08/02/2017"}, + expression={"$dateFromString": {"dateString": "$dateString", "format": "%d/%m/%Y"}}, + expected=datetime(2017, 2, 8, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should parse slash-separated components", + ), + ExpressionTestCase( + "format_no_sep", + doc={"dateString": "20170208"}, + expression={"$dateFromString": {"dateString": "$dateString", "format": "%Y%m%d"}}, + expected=datetime(2017, 2, 8, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should parse components with no separators", + ), + ExpressionTestCase( + "format_G_iso_year", + doc={"dateString": "2024"}, + expression={"$dateFromString": {"dateString": "$dateString", "format": "%G"}}, + expected=datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should parse an ISO week-numbering year with %G", + ), + ExpressionTestCase( + "format_V_iso_week", + doc={"dateString": "2024-W24"}, + expression={"$dateFromString": {"dateString": "$dateString", "format": "%G-W%V"}}, + expected=datetime(2024, 6, 10, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should parse an ISO week number with %V", + ), + ExpressionTestCase( + "format_u_iso_day_of_week", + doc={"dateString": "2024-W24-6"}, + expression={"$dateFromString": {"dateString": "$dateString", "format": "%G-W%V-%u"}}, + expected=datetime(2024, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should parse an ISO day of week with %u", + ), + ExpressionTestCase( + "format_z_tz_offset", + doc={"dateString": "2024-06-15 +0530"}, + expression={"$dateFromString": {"dateString": "$dateString", "format": "%Y-%m-%d %z"}}, + expected=datetime(2024, 6, 14, 18, 30, 0, tzinfo=timezone.utc), + msg="$dateFromString should parse a timezone offset with %z", + ), + ExpressionTestCase( + "format_Z_tz_minutes", + doc={"dateString": "2024-06-15 +330"}, + expression={"$dateFromString": {"dateString": "$dateString", "format": "%Y-%m-%d %Z"}}, + expected=datetime(2024, 6, 14, 18, 30, 0, tzinfo=timezone.utc), + msg="$dateFromString should parse a timezone offset in minutes with %Z", + ), + ExpressionTestCase( + "format_j_day_of_year", + doc={"dateString": "2024 167"}, + expression={"$dateFromString": {"dateString": "$dateString", "format": "%Y %j"}}, + expected=datetime(2024, 6, 16, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should parse a day of year with %j", + ), + ExpressionTestCase( + "format_percent_literal", + doc={"dateString": "100% 2024-06-15"}, + expression={"$dateFromString": {"dateString": "$dateString", "format": "100%% %Y-%m-%d"}}, + expected=datetime(2024, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should treat %% as a literal percent", + ), + ExpressionTestCase( + "format_H_hour", + doc={"dateString": "2024-06-15 14"}, + expression={"$dateFromString": {"dateString": "$dateString", "format": "%Y-%m-%d %H"}}, + expected=datetime(2024, 6, 15, 14, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should parse an hour with %H", + ), + ExpressionTestCase( + "format_M_minute", + doc={"dateString": "2024-06-15 14:30"}, + expression={"$dateFromString": {"dateString": "$dateString", "format": "%Y-%m-%d %H:%M"}}, + expected=datetime(2024, 6, 15, 14, 30, 0, tzinfo=timezone.utc), + msg="$dateFromString should parse a minute with %M", + ), + ExpressionTestCase( + "format_S_second", + doc={"dateString": "2024-06-15 14:30:45"}, + expression={ + "$dateFromString": {"dateString": "$dateString", "format": "%Y-%m-%d %H:%M:%S"} + }, + expected=datetime(2024, 6, 15, 14, 30, 45, tzinfo=timezone.utc), + msg="$dateFromString should parse a second with %S", + ), + ExpressionTestCase( + "format_L_millisecond", + doc={"dateString": "2024-06-15 14:30:45.123"}, + expression={ + "$dateFromString": { + "dateString": "$dateString", + "format": "%Y-%m-%d %H:%M:%S.%L", + } + }, + expected=datetime(2024, 6, 15, 14, 30, 45, 123000, tzinfo=timezone.utc), + msg="$dateFromString should parse a millisecond with %L", + ), +] + +# Property [Invalid Format]: an unsupported specifier or a format that does not match the dateString +# errors. +DATEFROMSTRING_FORMAT_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "format_Q_unsupported", + doc={"format": "%Q"}, + expression={"$dateFromString": {"dateString": "2017-02-08", "format": "$format"}}, + error_code=DATETOSTRING_INVALID_FORMAT_ERROR, + msg="$dateFromString should reject the unsupported %Q format specifier", + ), + ExpressionTestCase( + "format_U_unsupported", + doc={"format": "%Y %U %w"}, + expression={"$dateFromString": {"dateString": "2024 23 6", "format": "$format"}}, + error_code=DATETOSTRING_INVALID_FORMAT_ERROR, + msg="$dateFromString should reject the unsupported %U format specifier", + ), + ExpressionTestCase( + "format_w_unsupported", + doc={"format": "%G-W%V-%w"}, + expression={"$dateFromString": {"dateString": "2024-W24-6", "format": "$format"}}, + error_code=DATETOSTRING_INVALID_FORMAT_ERROR, + msg="$dateFromString should reject the unsupported %w format specifier", + ), + ExpressionTestCase( + "format_mismatch", + doc={"format": "%m-%d-%Y"}, + expression={"$dateFromString": {"dateString": "2017-02-08", "format": "$format"}}, + error_code=CONVERSION_FAILURE_ERROR, + msg="$dateFromString should error when the dateString does not match the format", + ), +] + +# Property [Auto-Parsing]: with no format, common date representations are recognized automatically. +DATEFROMSTRING_AUTO_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "auto_iso_ms", + doc={"dateString": "2017-02-08T12:10:40.787"}, + expression={"$dateFromString": {"dateString": "$dateString"}}, + expected=datetime(2017, 2, 8, 12, 10, 40, 787000, tzinfo=timezone.utc), + msg="$dateFromString should auto-parse an ISO datetime with milliseconds", + ), + ExpressionTestCase( + "auto_iso_z", + doc={"dateString": "2017-02-08T12:10:40.787Z"}, + expression={"$dateFromString": {"dateString": "$dateString"}}, + expected=datetime(2017, 2, 8, 12, 10, 40, 787000, tzinfo=timezone.utc), + msg="$dateFromString should auto-parse an ISO datetime with a Z suffix", + ), + ExpressionTestCase( + "auto_date_only", + doc={"dateString": "2017-02-08"}, + expression={"$dateFromString": {"dateString": "$dateString"}}, + expected=datetime(2017, 2, 8, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should auto-parse a date-only string", + ), + ExpressionTestCase( + "auto_natural", + doc={"dateString": "oct 20 2020"}, + expression={"$dateFromString": {"dateString": "$dateString"}}, + expected=datetime(2020, 10, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should auto-parse a natural-language date", + ), +] + +# Property [Millisecond Precision]: fractional seconds are preserved to millisecond precision. +DATEFROMSTRING_MS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "ms_000", + doc={"dateString": "2017-02-08T12:10:40.000"}, + expression={"$dateFromString": {"dateString": "$dateString"}}, + expected=datetime(2017, 2, 8, 12, 10, 40, 0, tzinfo=timezone.utc), + msg="$dateFromString should preserve zero milliseconds", + ), + ExpressionTestCase( + "ms_999", + doc={"dateString": "2017-02-08T12:10:40.999"}, + expression={"$dateFromString": {"dateString": "$dateString"}}, + expected=datetime(2017, 2, 8, 12, 10, 40, 999000, tzinfo=timezone.utc), + msg="$dateFromString should preserve maximum milliseconds", + ), +] + +# Property [Unparsable dateString]: a syntactically invalid dateString is rejected. +DATEFROMSTRING_UNPARSABLE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "empty_dateString", + doc={"dateString": ""}, + expression={"$dateFromString": {"dateString": "$dateString"}}, + error_code=CONVERSION_FAILURE_ERROR, + msg="$dateFromString should reject an empty dateString", + ), + ExpressionTestCase( + "invalid_month", + doc={"dateString": "2017-13-01"}, + expression={"$dateFromString": {"dateString": "$dateString"}}, + error_code=CONVERSION_FAILURE_ERROR, + msg="$dateFromString should reject an out-of-range month", + ), + ExpressionTestCase( + "invalid_day", + doc={"dateString": "2017-02-30"}, + expression={"$dateFromString": {"dateString": "$dateString"}}, + error_code=CONVERSION_FAILURE_ERROR, + msg="$dateFromString should reject an out-of-range day", + ), +] + +# Property [Leap Year]: February 29 is accepted only in leap years. +DATEFROMSTRING_LEAP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "leap_year_valid", + doc={"dateString": "2020-02-29"}, + expression={"$dateFromString": {"dateString": "$dateString"}}, + expected=datetime(2020, 2, 29, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should accept February 29 in a leap year", + ), + ExpressionTestCase( + "leap_year_2024", + doc={"dateString": "2024-02-29"}, + expression={"$dateFromString": {"dateString": "$dateString"}}, + expected=datetime(2024, 2, 29, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should accept February 29 in another leap year", + ), + ExpressionTestCase( + "leap_year_invalid", + doc={"dateString": "2019-02-29"}, + expression={"$dateFromString": {"dateString": "$dateString"}}, + error_code=CONVERSION_FAILURE_ERROR, + msg="$dateFromString should reject February 29 in a non-leap year", + ), + ExpressionTestCase( + "leap_year_century", + doc={"dateString": "2000-02-29"}, + expression={"$dateFromString": {"dateString": "$dateString"}}, + expected=DATE_LEAP_FEB29, + msg="$dateFromString should accept February 29 in a leap century year", + ), + ExpressionTestCase( + "non_leap_century", + doc={"dateString": "1900-02-29"}, + expression={"$dateFromString": {"dateString": "$dateString"}}, + error_code=CONVERSION_FAILURE_ERROR, + msg="$dateFromString should reject February 29 in a non-leap century year", + ), +] + +# Property [Date Range]: dates across the epoch and into the distant past and future are parsed. +DATEFROMSTRING_RANGE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "epoch", + doc={"dateString": "1970-01-01"}, + expression={"$dateFromString": {"dateString": "$dateString"}}, + expected=DATE_EPOCH, + msg="$dateFromString should parse the Unix epoch", + ), + ExpressionTestCase( + "pre_epoch", + doc={"dateString": "1969-12-31"}, + expression={"$dateFromString": {"dateString": "$dateString"}}, + expected=datetime(1969, 12, 31, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should parse a pre-epoch date", + ), + ExpressionTestCase( + "distant_past", + doc={"dateString": "1900-01-01"}, + expression={"$dateFromString": {"dateString": "$dateString"}}, + expected=DATE_YEAR_1900, + msg="$dateFromString should parse a distant past date", + ), + ExpressionTestCase( + "distant_future", + doc={"dateString": "2100-12-31"}, + expression={"$dateFromString": {"dateString": "$dateString"}}, + expected=datetime(2100, 12, 31, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should parse a distant future date", + ), + ExpressionTestCase( + "natural_with_tz_offset", + doc={"dateString": "WED jan 31 12:05:28 +03:30 1996"}, + expression={"$dateFromString": {"dateString": "$dateString"}}, + expected=datetime(1996, 1, 31, 8, 35, 28, tzinfo=timezone.utc), + msg="$dateFromString should parse a natural-language date with a timezone offset", + ), + ExpressionTestCase( + "year_9999_max", + doc={"dateString": "9999-12-31T23:59:59.999Z"}, + expression={"$dateFromString": {"dateString": "$dateString"}}, + expected=DATE_YEAR_9999, + msg="$dateFromString should parse the maximum representable year", + ), +] + +DATEFROMSTRING_BASIC_TESTS: list[ExpressionTestCase] = ( + DATEFROMSTRING_ARG_TESTS + + DATEFROMSTRING_ARG_ERROR_TESTS + + DATEFROMSTRING_NULL_TESTS + + DATEFROMSTRING_ONERROR_TESTS + + DATEFROMSTRING_FORMAT_TESTS + + DATEFROMSTRING_FORMAT_SPECIFIER_TESTS + + DATEFROMSTRING_FORMAT_ERROR_TESTS + + DATEFROMSTRING_AUTO_TESTS + + DATEFROMSTRING_MS_TESTS + + DATEFROMSTRING_UNPARSABLE_TESTS + + DATEFROMSTRING_LEAP_TESTS + + DATEFROMSTRING_RANGE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEFROMSTRING_BASIC_TESTS)) +def test_dateFromString_basic(collection, test_case: ExpressionTestCase): + """Test $dateFromString basic operations.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromString/test_dateFromString_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromString/test_dateFromString_expressions.py new file mode 100644 index 000000000..daf61543f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromString/test_dateFromString_expressions.py @@ -0,0 +1,144 @@ +"""Tests for $dateFromString field references, expression inputs, array paths, 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.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import CONVERSION_FAILURE_ERROR +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DATE_EPOCH + +# Property [Field References]: each argument may be supplied through a field-path reference. +DATEFROMSTRING_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "field_ref_dateString", + doc={"ds": "2024-06-15"}, + expression={"$dateFromString": {"dateString": "$ds"}}, + expected=datetime(2024, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should accept a dateString from a field reference", + ), + ExpressionTestCase( + "field_ref_format", + doc={"ds": "06-15-2018", "fmt": "%m-%d-%Y"}, + expression={"$dateFromString": {"dateString": "$ds", "format": "$fmt"}}, + expected=datetime(2018, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should accept a format from a field reference", + ), + ExpressionTestCase( + "field_ref_timezone", + doc={"tz": "America/New_York"}, + expression={ + "$dateFromString": {"dateString": "2017-02-08T12:10:40.787", "timezone": "$tz"} + }, + expected=datetime(2017, 2, 8, 17, 10, 40, 787000, tzinfo=timezone.utc), + msg="$dateFromString should accept a timezone from a field reference", + ), + ExpressionTestCase( + "field_ref_onError", + doc={"fallback": "error_value"}, + expression={"$dateFromString": {"dateString": "bad", "onError": "$fallback"}}, + expected="error_value", + msg="$dateFromString should accept an onError value from a field reference", + ), + ExpressionTestCase( + "field_ref_onNull", + doc={"default": DATE_EPOCH}, + expression={"$dateFromString": {"dateString": None, "onNull": "$default"}}, + expected=DATE_EPOCH, + msg="$dateFromString should accept an onNull value from a field reference", + ), + ExpressionTestCase( + "nested_field", + doc={"doc": {"ds": "2024-06-15"}}, + expression={"$dateFromString": {"dateString": "$doc.ds"}}, + expected=datetime(2024, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should accept a dateString from a nested-object path", + ), + ExpressionTestCase( + "nested_field_path", + doc={"a": {"b": "2024-06-15"}}, + expression={"$dateFromString": {"dateString": "$a.b"}}, + expected=datetime(2024, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should accept a dateString from a distinct nested-object path", + ), +] + +# Property [Missing Field References]: a reference to an absent field resolves to null. +DATEFROMSTRING_MISSING_FIELD_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_dateString_onNull", + doc={"other": 1}, + expression={"$dateFromString": {"dateString": "$nonexistent", "onNull": "default"}}, + expected="default", + msg="$dateFromString should trigger onNull when the dateString field is absent", + ), + ExpressionTestCase( + "missing_timezone_null", + doc={"other": 1}, + expression={"$dateFromString": {"dateString": "2024-06-15", "timezone": "$tz"}}, + expected=None, + msg="$dateFromString should return null when the timezone field is absent", + ), + ExpressionTestCase( + "missing_format_null", + doc={"other": 1}, + expression={"$dateFromString": {"dateString": "2024-06-15", "format": "$fmt"}}, + expected=None, + msg="$dateFromString should return null when the format field is absent", + ), +] + +# Property [Expression Inputs]: an argument may be computed by a sub-expression. +DATEFROMSTRING_EXPRESSION_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "expression_as_dateString", + expression={"$dateFromString": {"dateString": {"$concat": ["2017", "-02-08"]}}}, + expected=datetime(2017, 2, 8, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should evaluate a sub-expression for its dateString", + ), +] + +# Property [Array-Valued Path]: a dateString path resolving to an array fails conversion. +DATEFROMSTRING_ARRAY_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "composite_array_path", + doc={"a": [{"b": "2024-01-01"}, {"b": "2024-06-15"}]}, + expression={"$dateFromString": {"dateString": "$a.b"}}, + error_code=CONVERSION_FAILURE_ERROR, + msg="$dateFromString should reject a dateString path that resolves to an array", + ), +] + +# Property [Return Type]: the parsed value is a date. +DATEFROMSTRING_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type_date", + expression={"$type": {"$dateFromString": {"dateString": "2024-06-15"}}}, + expected="date", + msg="$dateFromString should return a date", + ), +] + +DATEFROMSTRING_EXPRESSIONS_TESTS: list[ExpressionTestCase] = ( + DATEFROMSTRING_FIELD_REF_TESTS + + DATEFROMSTRING_MISSING_FIELD_TESTS + + DATEFROMSTRING_EXPRESSION_INPUT_TESTS + + DATEFROMSTRING_ARRAY_PATH_TESTS + + DATEFROMSTRING_RETURN_TYPE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEFROMSTRING_EXPRESSIONS_TESTS)) +def test_dateFromString_expressions(collection, test_case: ExpressionTestCase): + """Test $dateFromString field references, expression inputs, and return type.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromString/test_dateFromString_timezone.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromString/test_dateFromString_timezone.py new file mode 100644 index 000000000..2848f4b92 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromString/test_dateFromString_timezone.py @@ -0,0 +1,436 @@ +"""Tests for $dateFromString timezone argument handling, offsets, DST, and Z-suffix conflicts.""" + +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.error_codes import ( + CONVERSION_FAILURE_ERROR, + INVALID_TIMEZONE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Named Timezones]: Olson names and abbreviations shift the parsed instant by their +# offset. +DATEFROMSTRING_NAMED_TZ_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_ny", + doc={"timezone": "America/New_York"}, + expression={ + "$dateFromString": {"dateString": "2024-06-15T12:00:00", "timezone": "$timezone"} + }, + expected=datetime(2024, 6, 15, 16, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should apply the America/New_York offset", + ), + ExpressionTestCase( + "tz_gmt", + doc={"timezone": "GMT"}, + expression={ + "$dateFromString": {"dateString": "2024-06-15T12:00:00", "timezone": "$timezone"} + }, + expected=datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should treat GMT as a zero offset", + ), + ExpressionTestCase( + "tz_utc", + doc={"timezone": "UTC"}, + expression={ + "$dateFromString": {"dateString": "2024-06-15T12:00:00", "timezone": "$timezone"} + }, + expected=datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should treat UTC as a zero offset", + ), + ExpressionTestCase( + "tz_europe_london", + doc={"timezone": "Europe/London"}, + expression={ + "$dateFromString": {"dateString": "2020-01-01T12:00:00", "timezone": "$timezone"} + }, + expected=datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should apply the Europe/London winter offset", + ), + ExpressionTestCase( + "tz_europe_london_bst", + doc={"timezone": "Europe/London"}, + expression={ + "$dateFromString": {"dateString": "2020-07-01T12:00:00", "timezone": "$timezone"} + }, + expected=datetime(2020, 7, 1, 11, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should apply the Europe/London summer BST offset", + ), + ExpressionTestCase( + "tz_asia_tokyo", + doc={"timezone": "Asia/Tokyo"}, + expression={ + "$dateFromString": {"dateString": "2020-01-01T12:00:00", "timezone": "$timezone"} + }, + expected=datetime(2020, 1, 1, 3, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should apply the Asia/Tokyo offset", + ), + ExpressionTestCase( + "tz_asia_kolkata", + doc={"timezone": "Asia/Kolkata"}, + expression={ + "$dateFromString": {"dateString": "2020-01-01T12:00:00", "timezone": "$timezone"} + }, + expected=datetime(2020, 1, 1, 6, 30, 0, tzinfo=timezone.utc), + msg="$dateFromString should apply the Asia/Kolkata half-hour offset", + ), + ExpressionTestCase( + "tz_pacific_apia", + doc={"timezone": "Pacific/Apia"}, + expression={ + "$dateFromString": {"dateString": "2020-01-01T12:00:00", "timezone": "$timezone"} + }, + expected=datetime(2019, 12, 31, 22, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should apply the Pacific/Apia offset across a day boundary", + ), + ExpressionTestCase( + "tz_est_abbreviation", + doc={"timezone": "EST"}, + expression={ + "$dateFromString": {"dateString": "2020-01-01T12:00:00", "timezone": "$timezone"} + }, + expected=datetime(2020, 1, 1, 17, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should accept the EST three-letter abbreviation", + ), +] + +# Property [DST Transitions]: a named timezone uses its standard or daylight offset by date. +DATEFROMSTRING_DST_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_dst_summer", + doc={"timezone": "America/New_York"}, + expression={ + "$dateFromString": {"dateString": "2020-07-01T12:00:00", "timezone": "$timezone"} + }, + expected=datetime(2020, 7, 1, 16, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should use the EDT offset in summer", + ), + ExpressionTestCase( + "tz_dst_winter", + doc={"timezone": "America/New_York"}, + expression={ + "$dateFromString": {"dateString": "2020-01-01T12:00:00", "timezone": "$timezone"} + }, + expected=datetime(2020, 1, 1, 17, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should use the EST offset in winter", + ), +] + +# Property [Numeric Offsets]: numeric UTC offsets shift the parsed instant, in full and compact +# forms. +DATEFROMSTRING_OFFSET_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_offset_positive", + doc={"timezone": "+05:30"}, + expression={ + "$dateFromString": {"dateString": "2024-06-15T12:00:00", "timezone": "$timezone"} + }, + expected=datetime(2024, 6, 15, 6, 30, 0, tzinfo=timezone.utc), + msg="$dateFromString should apply a positive numeric offset", + ), + ExpressionTestCase( + "tz_offset_negative", + doc={"timezone": "-05:00"}, + expression={ + "$dateFromString": {"dateString": "2024-06-15T12:00:00", "timezone": "$timezone"} + }, + expected=datetime(2024, 6, 15, 17, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should apply a negative numeric offset", + ), + ExpressionTestCase( + "tz_zero", + doc={"timezone": "+00:00"}, + expression={ + "$dateFromString": {"dateString": "2024-06-15T12:00:00", "timezone": "$timezone"} + }, + expected=datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should treat +00:00 as no shift", + ), + ExpressionTestCase( + "tz_no_colon", + doc={"timezone": "-0530"}, + expression={ + "$dateFromString": {"dateString": "2017-02-08T12:00:00", "timezone": "$timezone"} + }, + expected=datetime(2017, 2, 8, 17, 30, 0, tzinfo=timezone.utc), + msg="$dateFromString should accept a colon-less numeric offset", + ), + ExpressionTestCase( + "tz_hour_only", + doc={"timezone": "+03"}, + expression={ + "$dateFromString": {"dateString": "2017-02-08T12:00:00", "timezone": "$timezone"} + }, + expected=datetime(2017, 2, 8, 9, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should accept an hour-only numeric offset", + ), + ExpressionTestCase( + "tz_45min", + doc={"timezone": "+05:45"}, + expression={ + "$dateFromString": {"dateString": "2020-01-01T12:00:00", "timezone": "$timezone"} + }, + expected=datetime(2020, 1, 1, 6, 15, 0, tzinfo=timezone.utc), + msg="$dateFromString should apply a 45-minute offset", + ), + ExpressionTestCase( + "tz_half_hour_west", + doc={"timezone": "-03:30"}, + expression={ + "$dateFromString": {"dateString": "2020-01-01T12:00:00", "timezone": "$timezone"} + }, + expected=datetime(2020, 1, 1, 15, 30, 0, tzinfo=timezone.utc), + msg="$dateFromString should apply a half-hour west offset", + ), +] + +# Property [Boundary Offsets]: offsets at and beyond the usual bounds are accepted and applied. +DATEFROMSTRING_OFFSET_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_offset_max_east", + doc={"timezone": "+14:00"}, + expression={ + "$dateFromString": {"dateString": "2020-01-01T12:00:00", "timezone": "$timezone"} + }, + expected=datetime(2019, 12, 31, 22, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should accept the +14:00 maximum east offset", + ), + ExpressionTestCase( + "tz_offset_max_west", + doc={"timezone": "-11:00"}, + expression={ + "$dateFromString": {"dateString": "2020-01-01T12:00:00", "timezone": "$timezone"} + }, + expected=datetime(2020, 1, 1, 23, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should accept the -11:00 maximum west offset", + ), + ExpressionTestCase( + "tz_offset_minus13", + doc={"timezone": "-13:00"}, + expression={ + "$dateFromString": {"dateString": "2020-01-01T12:00:00", "timezone": "$timezone"} + }, + expected=datetime(2020, 1, 2, 1, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should accept a -13:00 offset", + ), + ExpressionTestCase( + "tz_offset_plus15", + doc={"timezone": "+15:00"}, + expression={ + "$dateFromString": {"dateString": "2020-01-01T12:00:00", "timezone": "$timezone"} + }, + expected=datetime(2019, 12, 31, 21, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should accept a +15:00 offset", + ), + ExpressionTestCase( + "tz_over60_minutes_positive", + doc={"timezone": "+05:70"}, + expression={ + "$dateFromString": {"dateString": "2020-01-01T12:00:00", "timezone": "$timezone"} + }, + expected=datetime(2020, 1, 1, 5, 50, 0, tzinfo=timezone.utc), + msg="$dateFromString should accept a positive offset with over 60 minutes", + ), + ExpressionTestCase( + "tz_over60_minutes_negative", + doc={"timezone": "-05:70"}, + expression={ + "$dateFromString": {"dateString": "2020-01-01T12:00:00", "timezone": "$timezone"} + }, + expected=datetime(2020, 1, 1, 18, 10, 0, tzinfo=timezone.utc), + msg="$dateFromString should accept a negative offset with over 60 minutes", + ), + ExpressionTestCase( + "tz_over24_hours_positive", + doc={"timezone": "+25:00"}, + expression={ + "$dateFromString": {"dateString": "2020-01-01T12:00:00", "timezone": "$timezone"} + }, + expected=datetime(2019, 12, 31, 11, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should accept a positive offset over 24 hours", + ), + ExpressionTestCase( + "tz_over24_hours_negative", + doc={"timezone": "-25:00"}, + expression={ + "$dateFromString": {"dateString": "2020-01-01T12:00:00", "timezone": "$timezone"} + }, + expected=datetime(2020, 1, 2, 13, 0, 0, tzinfo=timezone.utc), + msg="$dateFromString should accept a negative offset over 24 hours", + ), + ExpressionTestCase( + "tz_max_valid_positive", + doc={"timezone": "+99:99"}, + expression={ + "$dateFromString": {"dateString": "2020-01-01T12:00:00", "timezone": "$timezone"} + }, + expected=datetime(2019, 12, 28, 7, 21, 0, tzinfo=timezone.utc), + msg="$dateFromString should accept the maximum two-digit positive offset", + ), + ExpressionTestCase( + "tz_max_valid_negative", + doc={"timezone": "-99:99"}, + expression={ + "$dateFromString": {"dateString": "2020-01-01T12:00:00", "timezone": "$timezone"} + }, + expected=datetime(2020, 1, 5, 16, 39, 0, tzinfo=timezone.utc), + msg="$dateFromString should accept the maximum two-digit negative offset", + ), + ExpressionTestCase( + "tz_3digit_hours_invalid", + doc={"timezone": "+100:00"}, + expression={"$dateFromString": {"dateString": "2024-06-15", "timezone": "$timezone"}}, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dateFromString should reject a three-digit hour offset", + ), +] + +# Property [Invalid Timezone String]: an unrecognized or wrongly-cased timezone string is rejected. +DATEFROMSTRING_INVALID_TZ_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_olson_wrong_case_lowercase", + doc={"timezone": "america/new_york"}, + expression={"$dateFromString": {"dateString": "2024-06-15", "timezone": "$timezone"}}, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dateFromString should reject an all-lowercase Olson name", + ), + ExpressionTestCase( + "tz_olson_wrong_case_uppercase", + doc={"timezone": "AMERICA/NEW_YORK"}, + expression={"$dateFromString": {"dateString": "2024-06-15", "timezone": "$timezone"}}, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dateFromString should reject an all-uppercase Olson name", + ), + ExpressionTestCase( + "tz_invalid_str", + doc={"timezone": "NotATimezone"}, + expression={"$dateFromString": {"dateString": "2024-06-15", "timezone": "$timezone"}}, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dateFromString should reject an unrecognized timezone string", + ), + ExpressionTestCase( + "tz_empty", + doc={"timezone": ""}, + expression={"$dateFromString": {"dateString": "2024-06-15", "timezone": "$timezone"}}, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dateFromString should reject an empty timezone string", + ), + ExpressionTestCase( + "tz_nonexistent_olson", + doc={"timezone": "America/Nowhere"}, + expression={"$dateFromString": {"dateString": "2024-06-15", "timezone": "$timezone"}}, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dateFromString should reject a non-existent Olson timezone", + ), + ExpressionTestCase( + "tz_z", + doc={"timezone": "Z"}, + expression={ + "$dateFromString": {"dateString": "2017-02-08T12:00:00", "timezone": "$timezone"} + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dateFromString should reject Z as a timezone identifier", + ), +] + +# Property [Null Timezone]: a null timezone yields null. +DATEFROMSTRING_NULL_TZ_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_null", + doc={"timezone": None}, + expression={"$dateFromString": {"dateString": "2024-06-15", "timezone": "$timezone"}}, + expected=None, + msg="$dateFromString should return null for a null timezone", + ), +] + +# Property [Z Suffix Conflict]: a Z-suffixed dateString conflicts with an explicit timezone +# argument. +DATEFROMSTRING_Z_CONFLICT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "z_no_tz_field", + doc={"dateString": "2017-02-08T12:10:40.787Z"}, + expression={"$dateFromString": {"dateString": "$dateString"}}, + expected=datetime(2017, 2, 8, 12, 10, 40, 787000, tzinfo=timezone.utc), + msg="$dateFromString should parse a Z-suffixed dateString without a timezone argument", + ), + ExpressionTestCase( + "z_with_tz_field", + doc={"dateString": "2017-02-08T12:10:40.787Z"}, + expression={ + "$dateFromString": { + "dateString": "$dateString", + "timezone": "America/New_York", + } + }, + error_code=CONVERSION_FAILURE_ERROR, + msg="$dateFromString should reject a Z-suffixed dateString with a timezone argument", + ), +] + +# Property [dateString Offset Interaction]: an offset inside the dateString combines with the +# timezone argument. +DATEFROMSTRING_DS_OFFSET_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_ny_adjust", + doc={"dateString": "2017-02-08T12:10:40.787"}, + expression={ + "$dateFromString": { + "dateString": "$dateString", + "timezone": "America/New_York", + } + }, + expected=datetime(2017, 2, 8, 17, 10, 40, 787000, tzinfo=timezone.utc), + msg="$dateFromString should shift a naive dateString by the named timezone", + ), + ExpressionTestCase( + "tz_0530_adjust", + doc={"dateString": "2017-02-09T03:35:02.055"}, + expression={"$dateFromString": {"dateString": "$dateString", "timezone": "+0530"}}, + expected=datetime(2017, 2, 8, 22, 5, 2, 55000, tzinfo=timezone.utc), + msg="$dateFromString should shift a naive dateString by a numeric offset", + ), + ExpressionTestCase( + "offset_in_dateString_with_tz", + doc={"dateString": "2017-02-08T12:10:40.787+05:00"}, + expression={"$dateFromString": {"dateString": "$dateString", "timezone": "UTC"}}, + expected=datetime(2017, 2, 8, 7, 10, 40, 787000, tzinfo=timezone.utc), + msg="$dateFromString should honor an offset embedded in the dateString", + ), + ExpressionTestCase( + "offset_in_dateString_no_tz", + doc={"dateString": "2017-02-08T12:10:40+05:00"}, + expression={"$dateFromString": {"dateString": "$dateString"}}, + expected=datetime(2017, 2, 8, 7, 10, 40, tzinfo=timezone.utc), + msg="$dateFromString should honor an embedded offset with no timezone argument", + ), +] + +DATEFROMSTRING_TIMEZONE_TESTS: list[ExpressionTestCase] = ( + DATEFROMSTRING_NAMED_TZ_TESTS + + DATEFROMSTRING_DST_TESTS + + DATEFROMSTRING_OFFSET_TESTS + + DATEFROMSTRING_OFFSET_BOUNDARY_TESTS + + DATEFROMSTRING_INVALID_TZ_TESTS + + DATEFROMSTRING_NULL_TZ_TESTS + + DATEFROMSTRING_Z_CONFLICT_TESTS + + DATEFROMSTRING_DS_OFFSET_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEFROMSTRING_TIMEZONE_TESTS)) +def test_dateFromString_timezone(collection, test_case: ExpressionTestCase): + """Test $dateFromString timezone handling.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromString/test_dateFromString_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromString/test_dateFromString_types.py new file mode 100644 index 000000000..871ee446c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateFromString/test_dateFromString_types.py @@ -0,0 +1,140 @@ +"""Tests for $dateFromString type validation of the dateString, format, and timezone fields.""" + +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 ( + CONVERSION_FAILURE_ERROR, + DATEFROMSTRING_INVALID_FORMAT_TYPE_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 [dateString Type Rejection]: any non-string, non-null dateString fails conversion. +DATEFROMSTRING_DATESTRING_TYPE_TESTS: list[ExpressionTestCase] = [ + *[ + ExpressionTestCase( + f"dateString_type_{tid}", + doc={"dateString": val}, + expression={"$dateFromString": {"dateString": "$dateString"}}, + error_code=CONVERSION_FAILURE_ERROR, + msg=f"$dateFromString should reject a {tid} dateString", + ) + for tid, val in [ + ("int32", 123), + ("int64", Int64(123)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("bool", True), + ("object", {"a": 1}), + ("array", [1, 2]), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("000000000000000000000000")), + ("timestamp", Timestamp(0, 1)), + ("binary", Binary(b"\x01")), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + ExpressionTestCase( + "dateString_decimal128_infinity", + doc={"dateString": DECIMAL128_INFINITY}, + expression={"$dateFromString": {"dateString": "$dateString"}}, + error_code=CONVERSION_FAILURE_ERROR, + msg="$dateFromString should reject a Decimal128 infinity dateString", + ), + ExpressionTestCase( + "dateString_decimal128_neg_infinity", + doc={"dateString": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$dateFromString": {"dateString": "$dateString"}}, + error_code=CONVERSION_FAILURE_ERROR, + msg="$dateFromString should reject a Decimal128 negative-infinity dateString", + ), +] + +# Property [format Type Rejection]: any non-string, non-null format is rejected as an invalid type. +DATEFROMSTRING_FORMAT_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"format_type_{tid}", + doc={"format": val}, + expression={"$dateFromString": {"dateString": "2024-01-01", "format": "$format"}}, + error_code=DATEFROMSTRING_INVALID_FORMAT_TYPE_ERROR, + msg=f"$dateFromString should reject a {tid} format", + ) + for tid, val in [ + ("int32", 123), + ("int64", Int64(123)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("bool", True), + ("object", {"f": "%Y"}), + ("array", ["%Y"]), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("000000000000000000000000")), + ("timestamp", Timestamp(0, 1)), + ("binary", Binary(b"\x01")), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [timezone Type Rejection]: any non-string, non-null timezone is rejected as an invalid +# type. +DATEFROMSTRING_TIMEZONE_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"timezone_type_{tid}", + doc={"timezone": val}, + expression={"$dateFromString": {"dateString": "2024-06-15", "timezone": "$timezone"}}, + error_code=INVALID_TIMEZONE_TYPE_ERROR, + msg=f"$dateFromString should reject a {tid} timezone", + ) + for tid, val in [ + ("int32", 5), + ("int64", Int64(5)), + ("double", 5.0), + ("decimal128", Decimal128("5")), + ("bool", True), + ("object", {"tz": "UTC"}), + ("array", ["UTC"]), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("000000000000000000000000")), + ("timestamp", Timestamp(0, 1)), + ("binary", Binary(b"\x01")), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +DATEFROMSTRING_TYPE_TESTS: list[ExpressionTestCase] = ( + DATEFROMSTRING_DATESTRING_TYPE_TESTS + + DATEFROMSTRING_FORMAT_TYPE_TESTS + + DATEFROMSTRING_TIMEZONE_TYPE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATEFROMSTRING_TYPE_TESTS)) +def test_dateFromString_types(collection, test_case: ExpressionTestCase): + """Test $dateFromString type validation.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_date_construct_operators.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_date_construct_operators.py new file mode 100644 index 000000000..f72026f8c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_date_construct_operators.py @@ -0,0 +1,124 @@ +"""Cross-operator combination tests for date construction and conversion operators. + +Tests $dateFromString inside other date operators, a $dateToString round-trip, +and $toDate/$convert equivalence.""" + +from datetime import datetime, timezone + +import pytest +from bson import Int64, ObjectId + +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 [Cross-Operator Composition]: date construction and conversion operators +# compose with one another and agree with equivalent conversions. +DATE_CONSTRUCT_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dateFromString_inside_dateAdd", + expression={ + "$dateAdd": { + "startDate": {"$dateFromString": {"dateString": "2024-06-15"}}, + "unit": "day", + "amount": 5, + } + }, + expected=datetime(2024, 6, 20, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateAdd should accept $dateFromString as its startDate", + ), + ExpressionTestCase( + "dateFromString_inside_dateSubtract", + expression={ + "$dateSubtract": { + "startDate": {"$dateFromString": {"dateString": "2024-06-15"}}, + "unit": "month", + "amount": 1, + } + }, + expected=datetime(2024, 5, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateSubtract should accept $dateFromString as its startDate", + ), + ExpressionTestCase( + "dateFromString_inside_dateTrunc", + expression={ + "$dateTrunc": { + "date": {"$dateFromString": {"dateString": "2024-06-15T10:30:00"}}, + "unit": "day", + } + }, + expected=datetime(2024, 6, 15, 0, 0, 0, tzinfo=timezone.utc), + msg="$dateTrunc should accept $dateFromString as its date", + ), + ExpressionTestCase( + "dateFromString_inside_dateDiff", + expression={ + "$dateDiff": { + "startDate": {"$dateFromString": {"dateString": "2024-01-01"}}, + "endDate": {"$dateFromString": {"dateString": "2024-06-15"}}, + "unit": "month", + } + }, + expected=Int64(5), + msg="$dateDiff should accept $dateFromString as both date inputs", + ), + ExpressionTestCase( + "dateToString_of_dateFromString_roundtrip", + expression={ + "$dateToString": { + "date": {"$dateFromString": {"dateString": "2024-06-15T10:30:45.123Z"}}, + "format": "%Y-%m-%dT%H:%M:%S.%LZ", + } + }, + expected="2024-06-15T10:30:45.123Z", + msg="$dateToString should round-trip a $dateFromString result", + ), + ExpressionTestCase( + "toDate_convert_equivalence_string", + expression={ + "$eq": [{"$toDate": "2024-06-15"}, {"$convert": {"input": "2024-06-15", "to": "date"}}] + }, + expected=True, + msg="$toDate should match $convert to date for a string input", + ), + ExpressionTestCase( + "toDate_convert_equivalence_long", + expression={ + "$eq": [ + {"$toDate": Int64(1718409600000)}, + {"$convert": {"input": Int64(1718409600000), "to": "date"}}, + ] + }, + expected=True, + msg="$toDate should match $convert to date for a long input", + ), + ExpressionTestCase( + "toDate_convert_equivalence_double", + expression={ + "$eq": [{"$toDate": 86400000.0}, {"$convert": {"input": 86400000.0, "to": "date"}}] + }, + expected=True, + msg="$toDate should match $convert to date for a double input", + ), + ExpressionTestCase( + "toDate_convert_equivalence_oid", + expression={ + "$eq": [ + {"$toDate": ObjectId("600000000000000000000000")}, + {"$convert": {"input": ObjectId("600000000000000000000000"), "to": "date"}}, + ] + }, + expected=True, + msg="$toDate should match $convert to date for an ObjectId input", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(DATE_CONSTRUCT_COMBINATION_TESTS)) +def test_date_construct_combination(collection, test_case: ExpressionTestCase): + """Test date construction and conversion operators composed together.""" + result = execute_expression(collection, test_case.expression) + assert_expression_result(result, expected=test_case.expected, msg=test_case.msg) From b782d18dc80342d9ef5e0cea5228ccf41eeca480 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Fri, 10 Jul 2026 11:46:52 -0700 Subject: [PATCH 3/4] Add $dateToParts expression tests This change adds tests for the $dateToParts expression. It was originally authored by @mitchell-elholm. Closes #309 Co-authored-by: Mitchell Elholm Signed-off-by: Daniel Frankcom --- .../expressions/date/dateToParts/__init__.py | 0 .../dateToParts/test_dateToParts_arguments.py | 179 +++++++++++ .../test_dateToParts_boundaries.py | 210 +++++++++++++ .../test_dateToParts_expressions.py | 171 +++++++++++ .../dateToParts/test_dateToParts_objectid.py | 148 ++++++++++ .../dateToParts/test_dateToParts_parts.py | 279 ++++++++++++++++++ .../dateToParts/test_dateToParts_timestamp.py | 148 ++++++++++ .../test_dateToParts_timezone_names.py | 187 ++++++++++++ .../test_dateToParts_timezone_numeric.py | 267 +++++++++++++++++ ..._dateToParts_timezone_offset_edge_cases.py | 221 ++++++++++++++ .../test_dateToParts_timezone_validation.py | 178 +++++++++++ .../dateToParts/test_dateToParts_types.py | 119 ++++++++ documentdb_tests/framework/error_codes.py | 1 + 13 files changed, 2108 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_arguments.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_boundaries.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_objectid.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_parts.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_timestamp.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_timezone_names.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_timezone_numeric.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_timezone_offset_edge_cases.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_timezone_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_types.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_arguments.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_arguments.py new file mode 100644 index 000000000..c2b82729a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_arguments.py @@ -0,0 +1,179 @@ +"""Tests for $dateToParts argument handling and null propagation.""" + +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.error_codes import ( + DATETOPARTS_MISSING_DATE_ERROR, + DATETOPARTS_NON_OBJECT_ERROR, + DATETOPARTS_UNKNOWN_FIELD_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import MISSING + +# Property [Argument Handling]: the argument is an object holding a required date and +# optional timezone and iso8601, and any other shape is rejected. +DATETOPARTS_ARG_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_only", + doc={"date": datetime(2017, 1, 1, 1, 29, 9, 123000, tzinfo=timezone.utc)}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 2017, + "month": 1, + "day": 1, + "hour": 1, + "minute": 29, + "second": 9, + "millisecond": 123, + }, + msg="$dateToParts should extract parts from a date alone", + ), + ExpressionTestCase( + "with_tz", + doc={"date": datetime(2017, 1, 1, 1, 29, 9, 123000, tzinfo=timezone.utc)}, + expression={ + "$dateToParts": { + "date": "$date", + "timezone": "America/New_York", + } + }, + expected={ + "year": 2016, + "month": 12, + "day": 31, + "hour": 20, + "minute": 29, + "second": 9, + "millisecond": 123, + }, + msg="$dateToParts should apply the timezone when extracting parts", + ), + ExpressionTestCase( + "iso_true", + doc={"date": datetime(2017, 1, 1, 1, 29, 9, 123000, tzinfo=timezone.utc)}, + expression={ + "$dateToParts": { + "date": "$date", + "iso8601": True, + } + }, + expected={ + "isoWeekYear": 2016, + "isoWeek": 52, + "isoDayOfWeek": 7, + "hour": 1, + "minute": 29, + "second": 9, + "millisecond": 123, + }, + msg="$dateToParts should return ISO week-date parts when iso8601 is true", + ), + ExpressionTestCase( + "all_fields", + doc={"date": datetime(2017, 1, 1, 1, 29, 9, 123000, tzinfo=timezone.utc)}, + expression={ + "$dateToParts": { + "date": "$date", + "timezone": "UTC", + "iso8601": True, + } + }, + expected={ + "isoWeekYear": 2016, + "isoWeek": 52, + "isoDayOfWeek": 7, + "hour": 1, + "minute": 29, + "second": 9, + "millisecond": 123, + }, + msg="$dateToParts should accept date, timezone, and iso8601 together", + ), + ExpressionTestCase( + "missing_date", + expression={"$dateToParts": {"timezone": "UTC"}}, + error_code=DATETOPARTS_MISSING_DATE_ERROR, + msg="$dateToParts should reject an argument that omits the date field", + ), + ExpressionTestCase( + "empty_object", + expression={"$dateToParts": {}}, + error_code=DATETOPARTS_MISSING_DATE_ERROR, + msg="$dateToParts should reject an empty object argument", + ), + ExpressionTestCase( + "unknown_field", + expression={"$dateToParts": {"date": datetime(2017, 1, 1, tzinfo=timezone.utc), "foo": 1}}, + error_code=DATETOPARTS_UNKNOWN_FIELD_ERROR, + msg="$dateToParts should reject an unknown field in the argument", + ), + ExpressionTestCase( + "non_object_str", + expression={"$dateToParts": "string"}, + error_code=DATETOPARTS_NON_OBJECT_ERROR, + msg="$dateToParts should reject a string argument", + ), + ExpressionTestCase( + "non_object_arr", + expression={"$dateToParts": [1, 2]}, + error_code=DATETOPARTS_NON_OBJECT_ERROR, + msg="$dateToParts should reject an array argument", + ), + ExpressionTestCase( + "non_object_num", + expression={"$dateToParts": 123}, + error_code=DATETOPARTS_NON_OBJECT_ERROR, + msg="$dateToParts should reject a numeric argument", + ), +] + +# Property [Null Propagation]: a null date, a missing date reference, or a null iso8601 yields null. +DATETOPARTS_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_date", + doc={"date": None}, + expression={"$dateToParts": {"date": "$date"}}, + expected=None, + msg="$dateToParts should return null for a null date", + ), + ExpressionTestCase( + "missing_date_ref", + expression={"$dateToParts": {"date": MISSING}}, + expected=None, + msg="$dateToParts should return null when the date references a missing field", + ), + ExpressionTestCase( + "iso8601_null", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateToParts": { + "date": "$date", + "iso8601": None, + } + }, + expected=None, + msg="$dateToParts should return null for a null iso8601", + ), +] + +DATETOPARTS_ARGUMENTS_TESTS: list[ExpressionTestCase] = ( + DATETOPARTS_ARG_TESTS + DATETOPARTS_NULL_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETOPARTS_ARGUMENTS_TESTS)) +def test_dateToParts_arguments(collection, test_case: ExpressionTestCase): + """Test $dateToParts argument handling and null propagation.""" + 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/dateToParts/test_dateToParts_boundaries.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_boundaries.py new file mode 100644 index 000000000..8cfda69f0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_boundaries.py @@ -0,0 +1,210 @@ +"""Tests for $dateToParts extraction across the date range and at epoch/32-bit boundaries.""" + +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_MS_BEFORE_EPOCH, + DATE_MS_EPOCH, + OID_MAX_SIGNED32, + OID_MAX_UNSIGNED32, + OID_MIN_SIGNED32, + TS_MAX_SIGNED32, + TS_MAX_UNSIGNED32, +) + +# Property [Date Range]: extraction works across the representable date range. +DATETOPARTS_RANGE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "epoch", + doc={"date": DATE_EPOCH}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 1970, + "month": 1, + "day": 1, + "hour": 0, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should extract parts at the epoch", + ), + ExpressionTestCase( + "pre_epoch", + doc={"date": datetime(1969, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 1969, + "month": 12, + "day": 31, + "hour": 23, + "minute": 59, + "second": 59, + "millisecond": 0, + }, + msg="$dateToParts should extract parts just before the epoch", + ), + ExpressionTestCase( + "distant_past", + doc={"date": datetime(1900, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 1900, + "month": 6, + "day": 15, + "hour": 12, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should extract parts from a distant-past date", + ), + ExpressionTestCase( + "distant_future", + doc={"date": datetime(2100, 3, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 2100, + "month": 3, + "day": 1, + "hour": 0, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should extract parts from a distant-future date", + ), +] + +# Property [Boundary Values]: millisecond-resolution and 32-bit epoch boundaries extract correctly. +DATETOPARTS_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_ms_epoch", + doc={"date": DATE_MS_EPOCH}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 1970, + "month": 1, + "day": 1, + "hour": 0, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should extract parts from a millisecond date at the epoch", + ), + ExpressionTestCase( + "date_ms_before_epoch", + doc={"date": DATE_MS_BEFORE_EPOCH}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 1969, + "month": 12, + "day": 31, + "hour": 23, + "minute": 59, + "second": 59, + "millisecond": 999, + }, + msg="$dateToParts should extract parts from a millisecond date just before the epoch", + ), + ExpressionTestCase( + "ts_boundary_max_s32", + doc={"date": TS_MAX_SIGNED32}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 2038, + "month": 1, + "day": 19, + "hour": 3, + "minute": 14, + "second": 7, + "millisecond": 0, + }, + msg="$dateToParts should extract parts from a Timestamp at the max signed 32-bit second", + ), + ExpressionTestCase( + "ts_boundary_max_u32", + doc={"date": TS_MAX_UNSIGNED32}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 2106, + "month": 2, + "day": 7, + "hour": 6, + "minute": 28, + "second": 15, + "millisecond": 0, + }, + msg="$dateToParts should extract parts from a Timestamp at the max unsigned 32-bit second", + ), + ExpressionTestCase( + "oid_boundary_max_s32", + doc={"date": OID_MAX_SIGNED32}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 2038, + "month": 1, + "day": 19, + "hour": 3, + "minute": 14, + "second": 7, + "millisecond": 0, + }, + msg="$dateToParts should extract parts from an ObjectId at the max signed 32-bit second", + ), + ExpressionTestCase( + "oid_boundary_min_s32", + doc={"date": OID_MIN_SIGNED32}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 1901, + "month": 12, + "day": 13, + "hour": 20, + "minute": 45, + "second": 52, + "millisecond": 0, + }, + msg="$dateToParts should extract parts from an ObjectId at the min signed 32-bit second", + ), + ExpressionTestCase( + "oid_boundary_max_u32", + doc={"date": OID_MAX_UNSIGNED32}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 1969, + "month": 12, + "day": 31, + "hour": 23, + "minute": 59, + "second": 59, + "millisecond": 0, + }, + msg="$dateToParts should extract parts from an ObjectId at the max unsigned 32-bit second", + ), +] + +DATETOPARTS_BOUNDARIES_TESTS: list[ExpressionTestCase] = ( + DATETOPARTS_RANGE_TESTS + DATETOPARTS_BOUNDARY_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETOPARTS_BOUNDARIES_TESTS)) +def test_dateToParts_boundaries(collection, test_case: ExpressionTestCase): + """Test $dateToParts range and boundary extraction.""" + 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/dateToParts/test_dateToParts_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_expressions.py new file mode 100644 index 000000000..6e9f40224 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_expressions.py @@ -0,0 +1,171 @@ +"""Tests for $dateToParts field references, expression inputs, and return type.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( + oid_from_args, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_DATE_ERROR +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Field References]: the date and timezone may be supplied through field-path references. +DATETOPARTS_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "field_ref", + doc={"d": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateToParts": {"date": "$d"}}, + expected={ + "year": 2024, + "month": 6, + "day": 15, + "hour": 12, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should accept a date from a field reference", + ), + ExpressionTestCase( + "nested_field", + doc={"doc": {"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}}, + expression={"$dateToParts": {"date": "$doc.date"}}, + expected={ + "year": 2024, + "month": 6, + "day": 15, + "hour": 12, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should accept a date from a nested-object path", + ), + ExpressionTestCase( + "missing_nested", + doc={"doc": {"x": 1}}, + expression={"$dateToParts": {"date": "$doc.missing"}}, + expected=None, + msg="$dateToParts should return null when a nested path is missing", + ), + ExpressionTestCase( + "objectid_ref", + doc={"oid": oid_from_args(2024, 6, 15, 12, 0, 0)}, + expression={"$dateToParts": {"date": "$oid"}}, + expected={ + "year": 2024, + "month": 6, + "day": 15, + "hour": 12, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should accept an ObjectId from a field reference", + ), + ExpressionTestCase( + "timestamp_ref", + doc={"ts": ts_from_args(2024, 6, 15, 12, 0, 0)}, + expression={"$dateToParts": {"date": "$ts"}}, + expected={ + "year": 2024, + "month": 6, + "day": 15, + "hour": 12, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should accept a Timestamp from a field reference", + ), + ExpressionTestCase( + "timezone_from_field", + doc={"date": datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), "tz": "America/New_York"}, + expression={"$dateToParts": {"date": "$date", "timezone": "$tz"}}, + expected={ + "year": 2024, + "month": 1, + "day": 1, + "hour": 7, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should accept a timezone from a field reference", + ), +] + +# Property [Array-Valued Path]: a date path resolving to an array is rejected. +DATETOPARTS_ARRAY_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "composite_array_path", + doc={ + "a": [ + {"b": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + {"b": datetime(2024, 7, 1, 0, 0, 0, tzinfo=timezone.utc)}, + ] + }, + expression={"$dateToParts": {"date": "$a.b"}}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dateToParts should reject a date path that resolves to an array", + ), +] + +# Property [Expression Inputs]: the date may be computed by a sub-expression. +DATETOPARTS_EXPRESSION_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "expression_as_date", + expression={ + "$dateToParts": {"date": {"$dateFromParts": {"year": 2024, "month": 6, "day": 15}}} + }, + expected={ + "year": 2024, + "month": 6, + "day": 15, + "hour": 0, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should evaluate a sub-expression for its date", + ), +] + +# Property [Return Type]: the extracted value is an object. +DATETOPARTS_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type", + expression={ + "$type": { + "$dateToParts": {"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)} + } + }, + expected="object", + msg="$dateToParts should return an object", + ), +] + +DATETOPARTS_EXPRESSIONS_TESTS: list[ExpressionTestCase] = ( + DATETOPARTS_FIELD_REF_TESTS + + DATETOPARTS_ARRAY_PATH_TESTS + + DATETOPARTS_EXPRESSION_INPUT_TESTS + + DATETOPARTS_RETURN_TYPE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETOPARTS_EXPRESSIONS_TESTS)) +def test_dateToParts_expressions(collection, test_case: ExpressionTestCase): + """Test $dateToParts field references, expression inputs, and return type.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_objectid.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_objectid.py new file mode 100644 index 000000000..bb15a0256 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_objectid.py @@ -0,0 +1,148 @@ +"""Tests for $dateToParts extraction from ObjectId inputs.""" + +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, +) +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 [ObjectId Inputs]: the embedded time of an ObjectId is used for extraction. +DATETOPARTS_OBJECTID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "oid_jun15", + doc={"date": oid_from_args(2024, 6, 15, 12, 0, 0)}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 2024, + "month": 6, + "day": 15, + "hour": 12, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should extract parts from an ObjectId", + ), + ExpressionTestCase( + "oid_jan1", + doc={"date": oid_from_args(2024, 1, 1, 0, 0, 0)}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 2024, + "month": 1, + "day": 1, + "hour": 0, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should extract parts from an ObjectId at the start of the year", + ), + ExpressionTestCase( + "oid_dec31", + doc={"date": oid_from_args(2024, 12, 31, 23, 59, 59)}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 2024, + "month": 12, + "day": 31, + "hour": 23, + "minute": 59, + "second": 59, + "millisecond": 0, + }, + msg="$dateToParts should extract parts from an ObjectId at the end of the year", + ), + ExpressionTestCase( + "oid_iso", + doc={"date": oid_from_args(2024, 6, 15, 12, 0, 0)}, + expression={"$dateToParts": {"date": "$date", "iso8601": True}}, + expected={ + "isoWeekYear": 2024, + "isoWeek": 24, + "isoDayOfWeek": 6, + "hour": 12, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should return ISO parts from an ObjectId", + ), + ExpressionTestCase( + "oid_with_tz", + doc={"date": oid_from_args(2024, 6, 15, 12, 0, 0)}, + expression={"$dateToParts": {"date": "$date", "timezone": "UTC"}}, + expected={ + "year": 2024, + "month": 6, + "day": 15, + "hour": 12, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should apply a timezone to an ObjectId", + ), + ExpressionTestCase( + "oid_epoch", + doc={"date": oid_from_args(1970, 1, 1, 0, 0, 0)}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 1970, + "month": 1, + "day": 1, + "hour": 0, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should extract parts from an ObjectId at the epoch", + ), + ExpressionTestCase( + "oid_future", + doc={"date": oid_from_args(2035, 3, 1, 0, 0, 0)}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 2035, + "month": 3, + "day": 1, + "hour": 0, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should extract parts from a future ObjectId", + ), + ExpressionTestCase( + "oid_ms_zero", + doc={"date": oid_from_args(2024, 6, 15, 12, 30, 45)}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 2024, + "month": 6, + "day": 15, + "hour": 12, + "minute": 30, + "second": 45, + "millisecond": 0, + }, + msg="$dateToParts should report zero milliseconds for an ObjectId", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(DATETOPARTS_OBJECTID_TESTS)) +def test_dateToParts_objectid(collection, test_case: ExpressionTestCase): + """Test $dateToParts extraction from ObjectId inputs.""" + 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/dateToParts/test_dateToParts_parts.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_parts.py new file mode 100644 index 000000000..05e9fa47e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_parts.py @@ -0,0 +1,279 @@ +"""Tests for $dateToParts standard, ISO, and millisecond part extraction.""" + +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 [Standard Parts]: calendar parts are extracted correctly across the calendar. +DATETOPARTS_STANDARD_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "midnight", + doc={"date": datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 2024, + "month": 1, + "day": 1, + "hour": 0, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should extract parts at midnight", + ), + ExpressionTestCase( + "end_of_year", + doc={"date": datetime(2024, 12, 31, 23, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 2024, + "month": 12, + "day": 31, + "hour": 23, + "minute": 59, + "second": 59, + "millisecond": 999, + }, + msg="$dateToParts should extract parts at the end of the year", + ), + ExpressionTestCase( + "leap_day", + doc={"date": datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 2000, + "month": 2, + "day": 29, + "hour": 12, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should extract parts on a leap day in a 400-divisible year", + ), + ExpressionTestCase( + "leap_day_2024", + doc={"date": datetime(2024, 2, 29, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 2024, + "month": 2, + "day": 29, + "hour": 0, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should extract parts on a leap day", + ), + ExpressionTestCase( + "non_leap_century_1900", + doc={"date": datetime(1900, 2, 28, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 1900, + "month": 2, + "day": 28, + "hour": 0, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should treat 1900 as a non-leap century year", + ), +] + +# Property [ISO Parts]: iso8601 selects between ISO week-date parts and calendar parts. +DATETOPARTS_ISO_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "iso_mid_year", + doc={"date": datetime(2024, 6, 15, 10, 30, 0, tzinfo=timezone.utc)}, + expression={ + "$dateToParts": { + "date": "$date", + "iso8601": True, + } + }, + expected={ + "isoWeekYear": 2024, + "isoWeek": 24, + "isoDayOfWeek": 6, + "hour": 10, + "minute": 30, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should return ISO parts for a mid-year date", + ), + ExpressionTestCase( + "iso_year_differs", + doc={"date": datetime(2016, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateToParts": { + "date": "$date", + "iso8601": True, + } + }, + expected={ + "isoWeekYear": 2015, + "isoWeek": 53, + "isoDayOfWeek": 5, + "hour": 0, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should return an ISO week-year that differs from the calendar year", + ), + ExpressionTestCase( + "iso_false_same", + doc={"date": datetime(2024, 6, 15, 10, 30, 45, 500000, tzinfo=timezone.utc)}, + expression={ + "$dateToParts": { + "date": "$date", + "iso8601": False, + } + }, + expected={ + "year": 2024, + "month": 6, + "day": 15, + "hour": 10, + "minute": 30, + "second": 45, + "millisecond": 500, + }, + msg="$dateToParts should return calendar parts when iso8601 is false", + ), + ExpressionTestCase( + "iso_2021_jan1", + doc={"date": datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateToParts": { + "date": "$date", + "iso8601": True, + } + }, + expected={ + "isoWeekYear": 2020, + "isoWeek": 53, + "isoDayOfWeek": 5, + "hour": 0, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should assign Jan 1 2021 to ISO week 53 of 2020", + ), + ExpressionTestCase( + "iso_2020_dec31", + doc={"date": datetime(2020, 12, 31, 0, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateToParts": { + "date": "$date", + "iso8601": True, + } + }, + expected={ + "isoWeekYear": 2020, + "isoWeek": 53, + "isoDayOfWeek": 4, + "hour": 0, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should assign Dec 31 2020 to ISO week 53 of 2020", + ), + ExpressionTestCase( + "iso_2015_jan1", + doc={"date": datetime(2015, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateToParts": { + "date": "$date", + "iso8601": True, + } + }, + expected={ + "isoWeekYear": 2015, + "isoWeek": 1, + "isoDayOfWeek": 4, + "hour": 0, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should assign Jan 1 2015 to ISO week 1 of 2015", + ), +] + +# Property [Millisecond Precision]: sub-second milliseconds are preserved. +DATETOPARTS_MS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "ms_zero", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 2024, + "month": 6, + "day": 15, + "hour": 12, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should report zero milliseconds", + ), + ExpressionTestCase( + "ms_999", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, 999000, tzinfo=timezone.utc)}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 2024, + "month": 6, + "day": 15, + "hour": 12, + "minute": 0, + "second": 0, + "millisecond": 999, + }, + msg="$dateToParts should report 999 milliseconds", + ), + ExpressionTestCase( + "ms_500", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, 500000, tzinfo=timezone.utc)}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 2024, + "month": 6, + "day": 15, + "hour": 12, + "minute": 0, + "second": 0, + "millisecond": 500, + }, + msg="$dateToParts should report 500 milliseconds", + ), +] + +DATETOPARTS_PARTS_TESTS: list[ExpressionTestCase] = ( + DATETOPARTS_STANDARD_TESTS + DATETOPARTS_ISO_TESTS + DATETOPARTS_MS_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETOPARTS_PARTS_TESTS)) +def test_dateToParts_parts(collection, test_case: ExpressionTestCase): + """Test $dateToParts part extraction.""" + 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/dateToParts/test_dateToParts_timestamp.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_timestamp.py new file mode 100644 index 000000000..b7cc3285a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_timestamp.py @@ -0,0 +1,148 @@ +"""Tests for $dateToParts extraction from Timestamp inputs.""" + +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 ( + 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 [Timestamp Inputs]: the embedded time of a Timestamp is used for extraction. +DATETOPARTS_TIMESTAMP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "ts_jun15", + doc={"date": ts_from_args(2024, 6, 15, 12, 0, 0)}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 2024, + "month": 6, + "day": 15, + "hour": 12, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should extract parts from a Timestamp", + ), + ExpressionTestCase( + "ts_jan1", + doc={"date": ts_from_args(2024, 1, 1, 0, 0, 0)}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 2024, + "month": 1, + "day": 1, + "hour": 0, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should extract parts from a Timestamp at the start of the year", + ), + ExpressionTestCase( + "ts_dec31", + doc={"date": ts_from_args(2024, 12, 31, 23, 59, 59)}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 2024, + "month": 12, + "day": 31, + "hour": 23, + "minute": 59, + "second": 59, + "millisecond": 0, + }, + msg="$dateToParts should extract parts from a Timestamp at the end of the year", + ), + ExpressionTestCase( + "ts_iso", + doc={"date": ts_from_args(2024, 6, 15, 12, 0, 0)}, + expression={"$dateToParts": {"date": "$date", "iso8601": True}}, + expected={ + "isoWeekYear": 2024, + "isoWeek": 24, + "isoDayOfWeek": 6, + "hour": 12, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should return ISO parts from a Timestamp", + ), + ExpressionTestCase( + "ts_with_tz", + doc={"date": ts_from_args(2024, 6, 15, 12, 0, 0)}, + expression={"$dateToParts": {"date": "$date", "timezone": "UTC"}}, + expected={ + "year": 2024, + "month": 6, + "day": 15, + "hour": 12, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should apply a timezone to a Timestamp", + ), + ExpressionTestCase( + "ts_epoch", + doc={"date": ts_from_args(1970, 1, 1, 0, 0, 0)}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 1970, + "month": 1, + "day": 1, + "hour": 0, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should extract parts from a Timestamp at the epoch", + ), + ExpressionTestCase( + "ts_future", + doc={"date": ts_from_args(2100, 3, 1, 0, 0, 0)}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 2100, + "month": 3, + "day": 1, + "hour": 0, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should extract parts from a future Timestamp", + ), + ExpressionTestCase( + "ts_ms_zero", + doc={"date": ts_from_args(2024, 6, 15, 12, 30, 45)}, + expression={"$dateToParts": {"date": "$date"}}, + expected={ + "year": 2024, + "month": 6, + "day": 15, + "hour": 12, + "minute": 30, + "second": 45, + "millisecond": 0, + }, + msg="$dateToParts should report zero milliseconds for a Timestamp", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(DATETOPARTS_TIMESTAMP_TESTS)) +def test_dateToParts_timestamp(collection, test_case: ExpressionTestCase): + """Test $dateToParts extraction from Timestamp inputs.""" + 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/dateToParts/test_dateToParts_timezone_names.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_timezone_names.py new file mode 100644 index 000000000..f19a9c6bf --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_timezone_names.py @@ -0,0 +1,187 @@ +"""Tests for $dateToParts named/Olson timezones, abbreviations, and DST.""" + +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 [Named Zones]: an Olson name or abbreviation applies the zone offset, including DST. +DATETOPARTS_TZ_NAMED_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_europe_london", + doc={"timezone": "Europe/London"}, + expression={ + "$dateToParts": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + expected={ + "year": 2020, + "month": 1, + "day": 1, + "hour": 12, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should accept the Europe/London Olson name", + ), + ExpressionTestCase( + "tz_europe_london_bst", + doc={"timezone": "Europe/London"}, + expression={ + "$dateToParts": { + "date": datetime(2020, 7, 1, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + expected={ + "year": 2020, + "month": 7, + "day": 1, + "hour": 13, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should apply the summer BST offset for Europe/London", + ), + ExpressionTestCase( + "tz_asia_tokyo", + doc={"timezone": "Asia/Tokyo"}, + expression={ + "$dateToParts": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + expected={ + "year": 2020, + "month": 1, + "day": 1, + "hour": 21, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should accept the Asia/Tokyo Olson name", + ), + ExpressionTestCase( + "tz_asia_kolkata", + doc={"timezone": "Asia/Kolkata"}, + expression={ + "$dateToParts": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + expected={ + "year": 2020, + "month": 1, + "day": 1, + "hour": 17, + "minute": 30, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should apply the Asia/Kolkata half-hour offset", + ), + ExpressionTestCase( + "tz_pacific_apia", + doc={"timezone": "Pacific/Apia"}, + expression={ + "$dateToParts": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + expected={ + "year": 2020, + "month": 1, + "day": 2, + "hour": 2, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should accept the Pacific/Apia Olson name", + ), + ExpressionTestCase( + "tz_est_abbreviation", + doc={"timezone": "EST"}, + expression={ + "$dateToParts": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + expected={ + "year": 2020, + "month": 1, + "day": 1, + "hour": 7, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should accept the EST three-letter abbreviation", + ), + ExpressionTestCase( + "tz_dst_summer", + doc={"timezone": "America/New_York"}, + expression={ + "$dateToParts": { + "date": datetime(2024, 7, 1, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + expected={ + "year": 2024, + "month": 7, + "day": 1, + "hour": 8, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should apply the summer DST offset", + ), + ExpressionTestCase( + "tz_dst_winter", + doc={"timezone": "America/New_York"}, + expression={ + "$dateToParts": { + "date": datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + expected={ + "year": 2024, + "month": 1, + "day": 1, + "hour": 7, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should apply the winter standard offset", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(DATETOPARTS_TZ_NAMED_TESTS)) +def test_dateToParts_timezone_names(collection, test_case: ExpressionTestCase): + """Test $dateToParts named timezones.""" + 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/dateToParts/test_dateToParts_timezone_numeric.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_timezone_numeric.py new file mode 100644 index 000000000..90a7b6fe3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_timezone_numeric.py @@ -0,0 +1,267 @@ +"""Tests for $dateToParts numeric UTC offset timezones.""" + +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 [Numeric Offsets]: a numeric UTC offset shifts the extracted parts by that offset. +DATETOPARTS_TZ_NUMERIC_OFFSET_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_gmt", + doc={"timezone": "GMT"}, + expression={ + "$dateToParts": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + expected={ + "year": 2024, + "month": 6, + "day": 15, + "hour": 12, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should accept the GMT timezone", + ), + ExpressionTestCase( + "tz_utc", + doc={"timezone": "UTC"}, + expression={ + "$dateToParts": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + expected={ + "year": 2024, + "month": 6, + "day": 15, + "hour": 12, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should accept the UTC timezone", + ), + ExpressionTestCase( + "tz_offset", + doc={"timezone": "+05:30"}, + expression={ + "$dateToParts": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + expected={ + "year": 2024, + "month": 6, + "day": 15, + "hour": 17, + "minute": 30, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should apply a positive half-hour offset", + ), + ExpressionTestCase( + "tz_zero", + doc={"timezone": "+00:00"}, + expression={ + "$dateToParts": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + expected={ + "year": 2024, + "month": 6, + "day": 15, + "hour": 12, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should apply a zero offset", + ), + ExpressionTestCase( + "tz_no_colon", + doc={"timezone": "-0500"}, + expression={ + "$dateToParts": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + expected={ + "year": 2024, + "month": 6, + "day": 15, + "hour": 7, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should accept an offset without a colon", + ), + ExpressionTestCase( + "tz_hour_only", + doc={"timezone": "+03"}, + expression={ + "$dateToParts": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + expected={ + "year": 2024, + "month": 6, + "day": 15, + "hour": 15, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should accept an hour-only offset", + ), + ExpressionTestCase( + "tz_45min", + doc={"timezone": "+05:45"}, + expression={ + "$dateToParts": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + expected={ + "year": 2020, + "month": 1, + "day": 1, + "hour": 17, + "minute": 45, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should apply a 45-minute offset", + ), + ExpressionTestCase( + "tz_half_hour_west", + doc={"timezone": "-03:30"}, + expression={ + "$dateToParts": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + expected={ + "year": 2020, + "month": 1, + "day": 1, + "hour": 8, + "minute": 30, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should apply a half-hour west offset", + ), + ExpressionTestCase( + "tz_plus14", + doc={"timezone": "+14:00"}, + expression={ + "$dateToParts": { + "date": datetime(2024, 6, 15, 11, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + expected={ + "year": 2024, + "month": 6, + "day": 16, + "hour": 1, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should apply a +14:00 offset", + ), + ExpressionTestCase( + "tz_minus11", + doc={"timezone": "-11:00"}, + expression={ + "$dateToParts": { + "date": datetime(2024, 6, 15, 10, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + expected={ + "year": 2024, + "month": 6, + "day": 14, + "hour": 23, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should apply a -11:00 offset", + ), + ExpressionTestCase( + "tz_offset_minus13", + doc={"timezone": "-13:00"}, + expression={ + "$dateToParts": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + expected={ + "year": 2019, + "month": 12, + "day": 31, + "hour": 23, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should accept a -13:00 offset", + ), + ExpressionTestCase( + "tz_offset_plus15", + doc={"timezone": "+15:00"}, + expression={ + "$dateToParts": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + expected={ + "year": 2020, + "month": 1, + "day": 2, + "hour": 3, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should accept a +15:00 offset", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(DATETOPARTS_TZ_NUMERIC_OFFSET_TESTS)) +def test_dateToParts_timezone_numeric(collection, test_case: ExpressionTestCase): + """Test $dateToParts numeric offset timezones.""" + 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/dateToParts/test_dateToParts_timezone_offset_edge_cases.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_timezone_offset_edge_cases.py new file mode 100644 index 000000000..e510adbb2 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_timezone_offset_edge_cases.py @@ -0,0 +1,221 @@ +"""Tests for $dateToParts unusual offsets, day/year boundary shifts, and ISO with timezone.""" + +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 [Unusual Offsets]: an out-of-range but syntactically valid offset is accepted. +DATETOPARTS_TZ_UNUSUAL_OFFSET_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_over60_minutes_positive", + doc={"timezone": "+05:70"}, + expression={ + "$dateToParts": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + expected={ + "year": 2020, + "month": 1, + "day": 1, + "hour": 18, + "minute": 10, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should accept a positive offset with over 60 minutes", + ), + ExpressionTestCase( + "tz_over60_minutes_negative", + doc={"timezone": "-05:70"}, + expression={ + "$dateToParts": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + expected={ + "year": 2020, + "month": 1, + "day": 1, + "hour": 5, + "minute": 50, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should accept a negative offset with over 60 minutes", + ), + ExpressionTestCase( + "tz_over24_hours_positive", + doc={"timezone": "+25:00"}, + expression={ + "$dateToParts": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + expected={ + "year": 2020, + "month": 1, + "day": 2, + "hour": 13, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should accept a positive offset with over 24 hours", + ), + ExpressionTestCase( + "tz_over24_hours_negative", + doc={"timezone": "-25:00"}, + expression={ + "$dateToParts": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + expected={ + "year": 2019, + "month": 12, + "day": 31, + "hour": 11, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should accept a negative offset with over 24 hours", + ), + ExpressionTestCase( + "tz_max_valid_positive", + doc={"timezone": "+99:99"}, + expression={ + "$dateToParts": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + expected={ + "year": 2020, + "month": 1, + "day": 5, + "hour": 16, + "minute": 39, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should accept the max two-digit positive offset", + ), + ExpressionTestCase( + "tz_max_valid_negative", + doc={"timezone": "-99:99"}, + expression={ + "$dateToParts": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + expected={ + "year": 2019, + "month": 12, + "day": 28, + "hour": 7, + "minute": 21, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should accept the max two-digit negative offset", + ), +] + +# Property [Day And Year Shifts]: an offset can move the extracted date across day and year +# boundaries. +DATETOPARTS_TZ_SHIFT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_year_cross_fwd", + doc={"timezone": "+02:00"}, + expression={ + "$dateToParts": { + "date": datetime(2024, 12, 31, 23, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + expected={ + "year": 2025, + "month": 1, + "day": 1, + "hour": 1, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should cross forward over a year boundary", + ), + ExpressionTestCase( + "tz_year_cross_bwd", + doc={"timezone": "-05:00"}, + expression={ + "$dateToParts": { + "date": datetime(2024, 1, 1, 2, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + expected={ + "year": 2023, + "month": 12, + "day": 31, + "hour": 21, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should cross backward over a year boundary", + ), +] + +# Property [ISO With Timezone]: iso8601 and a timezone combine. +DATETOPARTS_ISO_TZ_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "iso_with_tz", + doc={"timezone": "-05:00"}, + expression={ + "$dateToParts": { + "date": datetime(2024, 1, 1, 2, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + "iso8601": True, + } + }, + expected={ + "isoWeekYear": 2023, + "isoWeek": 52, + "isoDayOfWeek": 7, + "hour": 21, + "minute": 0, + "second": 0, + "millisecond": 0, + }, + msg="$dateToParts should return ISO parts after applying a timezone", + ), +] + +DATETOPARTS_TZ_OFFSET_EDGE_TESTS: list[ExpressionTestCase] = ( + DATETOPARTS_TZ_UNUSUAL_OFFSET_TESTS + DATETOPARTS_TZ_SHIFT_TESTS + DATETOPARTS_ISO_TZ_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETOPARTS_TZ_OFFSET_EDGE_TESTS)) +def test_dateToParts_timezone_offset_edge_cases(collection, test_case: ExpressionTestCase): + """Test $dateToParts offset edge cases.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_timezone_validation.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_timezone_validation.py new file mode 100644 index 000000000..9b08104e9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_timezone_validation.py @@ -0,0 +1,178 @@ +"""Tests for $dateToParts null, missing, invalid, and non-string timezone validation.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + INVALID_TIMEZONE_ERROR, + INVALID_TIMEZONE_TYPE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import MISSING + +# Property [Null and Missing Timezone]: a null or missing timezone yields null. +DATETOPARTS_TZ_NULL_MISSING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_null", + doc={"timezone": None}, + expression={ + "$dateToParts": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + expected=None, + msg="$dateToParts should return null for a null timezone", + ), + ExpressionTestCase( + "missing_timezone_ref", + expression={ + "$dateToParts": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": MISSING, + } + }, + expected=None, + msg="$dateToParts should return null when the timezone references a missing field", + ), + ExpressionTestCase( + "missing_timezone_from_field", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateToParts": {"date": "$date", "timezone": "$tz"}}, + expected=None, + msg="$dateToParts should return null when the timezone field reference is missing", + ), +] + +# Property [Invalid Timezone String]: an unrecognized timezone string is rejected. +DATETOPARTS_TZ_STRING_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_3digit_hours", + doc={"timezone": "+100:00"}, + expression={ + "$dateToParts": { + "date": datetime(2024, 6, 15, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dateToParts should reject a three-digit hour offset", + ), + ExpressionTestCase( + "tz_olson_lowercase", + doc={"timezone": "america/new_york"}, + expression={ + "$dateToParts": { + "date": datetime(2024, 6, 15, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dateToParts should reject an all-lowercase Olson name", + ), + ExpressionTestCase( + "tz_olson_uppercase", + doc={"timezone": "AMERICA/NEW_YORK"}, + expression={ + "$dateToParts": { + "date": datetime(2024, 6, 15, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dateToParts should reject an all-uppercase Olson name", + ), + ExpressionTestCase( + "tz_invalid_str", + doc={"timezone": "NotATimezone"}, + expression={ + "$dateToParts": { + "date": datetime(2024, 6, 15, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dateToParts should reject an arbitrary timezone string", + ), + ExpressionTestCase( + "tz_empty", + doc={"timezone": ""}, + expression={ + "$dateToParts": { + "date": datetime(2024, 6, 15, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dateToParts should reject an empty timezone string", + ), + ExpressionTestCase( + "tz_nonexistent_olson", + doc={"timezone": "America/Nowhere"}, + expression={ + "$dateToParts": { + "date": datetime(2024, 6, 15, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dateToParts should reject a non-existent Olson name", + ), +] + +# Property [Timezone Type Rejection]: a timezone that is not a string or null is rejected. +DATETOPARTS_TZ_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"tz_type_{tid}", + doc={"timezone": val}, + expression={ + "$dateToParts": { + "date": datetime(2024, 6, 15, tzinfo=timezone.utc), + "timezone": "$timezone", + } + }, + error_code=INVALID_TIMEZONE_TYPE_ERROR, + msg=f"$dateToParts should reject {tid} as a timezone", + ) + for tid, val in [ + ("int", 5), + ("double", 1.0), + ("int64", Int64(5)), + ("decimal128", Decimal128("1")), + ("bool", True), + ("object", {"tz": "UTC"}), + ("array", ["UTC"]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01")), + ("regex", Regex(".*")), + ("javascript", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +DATETOPARTS_TZ_VALIDATION_TESTS: list[ExpressionTestCase] = ( + DATETOPARTS_TZ_NULL_MISSING_TESTS + + DATETOPARTS_TZ_STRING_ERROR_TESTS + + DATETOPARTS_TZ_TYPE_ERROR_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETOPARTS_TZ_VALIDATION_TESTS)) +def test_dateToParts_timezone_validation(collection, test_case: ExpressionTestCase): + """Test $dateToParts timezone validation.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_types.py new file mode 100644 index 000000000..bc4bc9a0e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToParts/test_dateToParts_types.py @@ -0,0 +1,119 @@ +"""Tests for $dateToParts type validation of the date and iso8601 fields.""" + +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 ( + DATETOPARTS_ISO8601_TYPE_ERROR, + TYPE_MISMATCH_DATE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + FLOAT_INFINITY, + FLOAT_NAN, +) + +# Property [Date Type Rejection]: a date that is not a date, ObjectId, or Timestamp is rejected. +DATETOPARTS_DATE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"date_type_{tid}", + doc={"date": val}, + expression={"$dateToParts": {"date": "$date"}}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg=f"$dateToParts should reject {tid} as a date", + ) + for tid, val in [ + ("int", 123), + ("double", 1.0), + ("string", "2017-01-01"), + ("bool", True), + ("bool_false", False), + ("object", {"a": 1}), + ("array", [1, 2]), + ("int64", Int64(123)), + ("decimal128", Decimal128("1")), + ("regex", Regex(".*")), + ("binary", Binary(b"\x01")), + ("javascript", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Non-Finite Date Rejection]: non-finite numeric values are rejected as dates. +DATETOPARTS_DATE_NON_FINITE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"date_{tid}", + doc={"date": val}, + expression={"$dateToParts": {"date": "$date"}}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg=f"$dateToParts should reject {tid} as a date", + ) + for tid, val in [ + ("nan", FLOAT_NAN), + ("inf", FLOAT_INFINITY), + ("decimal128_nan", DECIMAL128_NAN), + ("decimal128_inf", DECIMAL128_INFINITY), + ("decimal128_neg_inf", DECIMAL128_NEGATIVE_INFINITY), + ] +] + +# Property [iso8601 Type Rejection]: an iso8601 that is not a boolean or null is rejected. +DATETOPARTS_ISO8601_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"iso8601_type_{tid}", + doc={"iso8601": val}, + expression={ + "$dateToParts": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "iso8601": "$iso8601", + } + }, + error_code=DATETOPARTS_ISO8601_TYPE_ERROR, + msg=f"$dateToParts should reject {tid} as an iso8601 value", + ) + for tid, val in [ + ("int", 1), + ("double", 1.0), + ("int64", Int64(1)), + ("decimal128", Decimal128("1")), + ("string", "true"), + ("object", {"a": 1}), + ("array", [True]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01")), + ("regex", Regex(".*")), + ("javascript", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +DATETOPARTS_TYPE_TESTS: list[ExpressionTestCase] = ( + DATETOPARTS_DATE_TYPE_ERROR_TESTS + + DATETOPARTS_DATE_NON_FINITE_TESTS + + DATETOPARTS_ISO8601_TYPE_ERROR_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETOPARTS_TYPE_TESTS)) +def test_dateToParts_types(collection, test_case: ExpressionTestCase): + """Test $dateToParts type validation.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index af7608d53..7e895ab5b 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -345,6 +345,7 @@ DATEFROMPARTS_UNKNOWN_FIELD_ERROR = 40518 DATEFROMPARTS_NON_OBJECT_ERROR = 40519 DATETOPARTS_UNKNOWN_FIELD_ERROR = 40520 +DATETOPARTS_ISO8601_TYPE_ERROR = 40521 DATETOPARTS_MISSING_DATE_ERROR = 40522 DATEFROMPARTS_YEAR_OUT_OF_RANGE_ERROR = 40523 DATETOPARTS_NON_OBJECT_ERROR = 40524 From 1449dc83b667a347fccda410c7e40c1824a05f76 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Fri, 10 Jul 2026 13:51:07 -0700 Subject: [PATCH 4/4] Add $dateToString expression tests This change adds tests for the $dateToString expression. It was originally authored by @mitchell-elholm. Closes #310 Co-authored-by: Mitchell Elholm Signed-off-by: Daniel Frankcom --- .../expressions/date/dateToString/__init__.py | 0 .../test_dateToString_arguments.py | 208 ++++++++++++++ .../test_dateToString_boundaries.py | 195 +++++++++++++ .../test_dateToString_expressions.py | 138 +++++++++ .../dateToString/test_dateToString_format.py | 214 ++++++++++++++ .../test_dateToString_objectid.py | 97 +++++++ .../test_dateToString_timestamp.py | 86 ++++++ .../test_dateToString_timezone_names.py | 184 ++++++++++++ .../test_dateToString_timezone_offsets.py | 262 ++++++++++++++++++ .../test_dateToString_timezone_validation.py | 127 +++++++++ .../dateToString/test_dateToString_types.py | 172 ++++++++++++ 11 files changed, 1683 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_arguments.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_boundaries.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_format.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_objectid.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_timestamp.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_timezone_names.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_timezone_offsets.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_timezone_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_types.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_arguments.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_arguments.py new file mode 100644 index 000000000..09227e172 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_arguments.py @@ -0,0 +1,208 @@ +"""Tests for $dateToString argument handling, validation, and null/onNull propagation.""" + +from __future__ import annotations + +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.error_codes import ( + DATETOSTRING_MISSING_DATE_ERROR, + DATETOSTRING_NON_OBJECT_ERROR, + DATETOSTRING_UNKNOWN_FIELD_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import MISSING + +# Property [Argument Handling]: a date is formatted, optionally with format, timezone, and onNull. +DATETOSTRING_ARG_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_only", + doc={"date": datetime(2024, 6, 15, 10, 30, 45, 123000, tzinfo=timezone.utc)}, + expression={"$dateToString": {"date": "$date"}}, + expected="2024-06-15T10:30:45.123Z", + msg="$dateToString should format a date with the default format when only date is given", + ), + ExpressionTestCase( + "with_format", + doc={"date": datetime(2024, 6, 15, 10, 30, 45, 123000, tzinfo=timezone.utc)}, + expression={"$dateToString": {"date": "$date", "format": "%Y-%m-%d"}}, + expected="2024-06-15", + msg="$dateToString should apply an explicit format string", + ), + ExpressionTestCase( + "with_tz", + doc={"date": datetime(2024, 6, 15, 10, 30, 45, 123000, tzinfo=timezone.utc)}, + expression={"$dateToString": {"date": "$date", "timezone": "UTC"}}, + expected="2024-06-15T10:30:45.123Z", + msg="$dateToString should accept a timezone argument", + ), + ExpressionTestCase( + "with_onNull", + doc={"date": datetime(2024, 6, 15, 10, 30, 45, 123000, tzinfo=timezone.utc)}, + expression={"$dateToString": {"date": "$date", "format": "%Y-%m-%d", "onNull": "N/A"}}, + expected="2024-06-15", + msg="$dateToString should ignore onNull when the date is present", + ), + ExpressionTestCase( + "all_fields", + doc={"date": datetime(2024, 6, 15, 10, 30, 45, 123000, tzinfo=timezone.utc)}, + expression={ + "$dateToString": { + "date": "$date", + "format": "%Y-%m-%d %H:%M", + "timezone": "UTC", + "onNull": "N/A", + } + }, + expected="2024-06-15 10:30", + msg="$dateToString should format when every argument is provided", + ), +] + +# Property [Argument Validation]: a missing date, unknown field, or non-object argument is rejected. +DATETOSTRING_ARG_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_date", + expression={"$dateToString": {"format": "%Y"}}, + error_code=DATETOSTRING_MISSING_DATE_ERROR, + msg="$dateToString should reject an argument with no date field", + ), + ExpressionTestCase( + "empty_object", + expression={"$dateToString": {}}, + error_code=DATETOSTRING_MISSING_DATE_ERROR, + msg="$dateToString should reject an empty object argument", + ), + ExpressionTestCase( + "unknown_field", + expression={"$dateToString": {"date": datetime(2024, 1, 1, tzinfo=timezone.utc), "foo": 1}}, + error_code=DATETOSTRING_UNKNOWN_FIELD_ERROR, + msg="$dateToString should reject an unknown field", + ), + ExpressionTestCase( + "non_object_str", + expression={"$dateToString": "string"}, + error_code=DATETOSTRING_NON_OBJECT_ERROR, + msg="$dateToString should reject a string argument", + ), + ExpressionTestCase( + "non_object_arr", + expression={"$dateToString": [1, 2]}, + error_code=DATETOSTRING_NON_OBJECT_ERROR, + msg="$dateToString should reject an array argument", + ), + ExpressionTestCase( + "non_object_num", + expression={"$dateToString": 123}, + error_code=DATETOSTRING_NON_OBJECT_ERROR, + msg="$dateToString should reject a numeric argument", + ), +] + +# Property [Null and onNull]: a null or missing date yields null unless onNull supplies a value, +# and a missing format or timezone field reference yields null. +DATETOSTRING_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_no_onNull", + doc={"date": None}, + expression={"$dateToString": {"date": "$date", "format": "%Y"}}, + expected=None, + msg="$dateToString should return null for a null date with no onNull", + ), + ExpressionTestCase( + "null_onNull_str", + doc={"date": None}, + expression={"$dateToString": {"date": "$date", "format": "%Y", "onNull": "N/A"}}, + expected="N/A", + msg="$dateToString should return a string onNull value for a null date", + ), + ExpressionTestCase( + "null_onNull_num", + doc={"date": None}, + expression={"$dateToString": {"date": "$date", "format": "%Y", "onNull": 0}}, + expected=0, + msg="$dateToString should return a numeric onNull value for a null date", + ), + ExpressionTestCase( + "null_onNull_null", + doc={"date": None}, + expression={"$dateToString": {"date": "$date", "format": "%Y", "onNull": None}}, + expected=None, + msg="$dateToString should return a null onNull value for a null date", + ), + ExpressionTestCase( + "null_onNull_bool", + doc={"date": None}, + expression={"$dateToString": {"date": "$date", "format": "%Y", "onNull": False}}, + expected=False, + msg="$dateToString should return a boolean onNull value for a null date", + ), + ExpressionTestCase( + "null_onNull_obj", + doc={"date": None}, + expression={"$dateToString": {"date": "$date", "format": "%Y", "onNull": {"msg": "none"}}}, + expected={"msg": "none"}, + msg="$dateToString should return an object onNull value for a null date", + ), + ExpressionTestCase( + "null_onNull_arr", + doc={"date": None}, + expression={"$dateToString": {"date": "$date", "format": "%Y", "onNull": []}}, + expected=[], + msg="$dateToString should return an array onNull value for a null date", + ), + ExpressionTestCase( + "missing_date_ref", + expression={"$dateToString": {"date": MISSING, "format": "%Y"}}, + expected=None, + msg="$dateToString should return null when the date field is missing", + ), + ExpressionTestCase( + "missing_date_ref_onNull", + expression={"$dateToString": {"date": MISSING, "format": "%Y", "onNull": "missing"}}, + expected="missing", + msg="$dateToString should return the onNull value when the date field is missing", + ), + ExpressionTestCase( + "missing_timezone_ref", + expression={ + "$dateToString": { + "date": datetime(2024, 6, 15, 10, 30, 45, 123000, tzinfo=timezone.utc), + "format": "%Y", + "timezone": MISSING, + } + }, + expected=None, + msg="$dateToString should return null when the timezone field is missing", + ), + ExpressionTestCase( + "missing_format_ref", + expression={ + "$dateToString": { + "date": datetime(2024, 6, 15, 10, 30, 45, 123000, tzinfo=timezone.utc), + "format": MISSING, + } + }, + expected=None, + msg="$dateToString should return null when the format field is missing", + ), +] + +DATETOSTRING_ARGUMENT_TESTS: list[ExpressionTestCase] = ( + DATETOSTRING_ARG_TESTS + DATETOSTRING_ARG_ERROR_TESTS + DATETOSTRING_NULL_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETOSTRING_ARGUMENT_TESTS)) +def test_dateToString_arguments(collection, test_case: ExpressionTestCase): + """Test $dateToString argument handling and null/onNull.""" + 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/dateToString/test_dateToString_boundaries.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_boundaries.py new file mode 100644 index 000000000..0d036f4b7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_boundaries.py @@ -0,0 +1,195 @@ +"""Tests for $dateToString formatting at calendar, range, and year-limit boundaries.""" + +from __future__ import annotations + +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.error_codes import DATETOSTRING_YEAR_RANGE_ERROR +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_EPOCH, + DATE_LEAP_FEB29, + DATE_MS_BEFORE_EPOCH, + DATE_MS_EPOCH, + DATE_MS_MAX, + DATE_MS_MIN, + DATE_MS_YEAR_10000, + DATE_YEAR_1900, +) + +# Property [Leap Year]: February 29 and day-of-year formatting reflect leap and non-leap years. +DATETOSTRING_LEAP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "leap_feb29", + doc={"date": datetime(2024, 2, 29, 0, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateToString": { + "date": "$date", + "format": "%Y-%m-%d", + } + }, + expected="2024-02-29", + msg="$dateToString should format February 29 in a leap year", + ), + ExpressionTestCase( + "leap_j_feb29", + doc={"date": datetime(2024, 2, 29, 0, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateToString": { + "date": "$date", + "format": "%j", + } + }, + expected="060", + msg="$dateToString should format the day of year for February 29", + ), + ExpressionTestCase( + "leap_j_dec31", + doc={"date": datetime(2024, 12, 31, 0, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateToString": { + "date": "$date", + "format": "%j", + } + }, + expected="366", + msg="$dateToString should format day 366 for December 31 in a leap year", + ), + ExpressionTestCase( + "non_leap_j_dec31", + doc={"date": datetime(2023, 12, 31, 0, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateToString": { + "date": "$date", + "format": "%j", + } + }, + expected="365", + msg="$dateToString should format day 365 for December 31 in a non-leap year", + ), + ExpressionTestCase( + "leap_feb29_2000", + doc={"date": DATE_LEAP_FEB29}, + expression={ + "$dateToString": { + "date": "$date", + "format": "%Y-%m-%d", + } + }, + expected="2000-02-29", + msg="$dateToString should format February 29 in the leap century year 2000", + ), + ExpressionTestCase( + "non_leap_century_1900", + doc={"date": datetime(1900, 2, 28, 0, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateToString": { + "date": "$date", + "format": "%Y-%m-%d", + } + }, + expected="1900-02-28", + msg="$dateToString should format February 28 in the non-leap century year 1900", + ), +] + +# Property [Date Range]: dates across the epoch and into the distant past and future are formatted. +DATETOSTRING_RANGE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "epoch", + doc={"date": DATE_EPOCH}, + expression={"$dateToString": {"date": "$date", "format": "%Y-%m-%d"}}, + expected="1970-01-01", + msg="$dateToString should format the Unix epoch", + ), + ExpressionTestCase( + "pre_epoch", + doc={"date": datetime(1969, 12, 31, 0, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateToString": { + "date": "$date", + "format": "%Y-%m-%d", + } + }, + expected="1969-12-31", + msg="$dateToString should format a pre-epoch date", + ), + ExpressionTestCase( + "distant_past", + doc={"date": DATE_YEAR_1900}, + expression={"$dateToString": {"date": "$date", "format": "%Y-%m-%d"}}, + expected="1900-01-01", + msg="$dateToString should format a distant past date", + ), + ExpressionTestCase( + "distant_future", + doc={"date": datetime(2100, 12, 31, 0, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateToString": { + "date": "$date", + "format": "%Y-%m-%d", + } + }, + expected="2100-12-31", + msg="$dateToString should format a distant future date", + ), + ExpressionTestCase( + "date_ms_epoch", + doc={"date": DATE_MS_EPOCH}, + expression={"$dateToString": {"date": "$date", "format": "%Y-%m-%d"}}, + expected="1970-01-01", + msg="$dateToString should format a DatetimeMS at the epoch", + ), + ExpressionTestCase( + "date_ms_before_epoch", + doc={"date": DATE_MS_BEFORE_EPOCH}, + expression={"$dateToString": {"date": "$date", "format": "%Y-%m-%d"}}, + expected="1969-12-31", + msg="$dateToString should format a DatetimeMS just before the epoch", + ), +] + +# Property [Year Range Error]: a date whose year falls outside the formattable range is rejected. +DATETOSTRING_YEAR_RANGE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "year_range_year_10000", + doc={"date": DATE_MS_YEAR_10000}, + expression={"$dateToString": {"date": "$date", "format": "%Y-%m-%d"}}, + error_code=DATETOSTRING_YEAR_RANGE_ERROR, + msg="$dateToString should reject a date in year 10000", + ), + ExpressionTestCase( + "year_range_ms_max", + doc={"date": DATE_MS_MAX}, + expression={"$dateToString": {"date": "$date", "format": "%Y-%m-%d"}}, + error_code=DATETOSTRING_YEAR_RANGE_ERROR, + msg="$dateToString should reject the maximum DatetimeMS value", + ), + ExpressionTestCase( + "year_range_ms_min", + doc={"date": DATE_MS_MIN}, + expression={"$dateToString": {"date": "$date", "format": "%Y-%m-%d"}}, + error_code=DATETOSTRING_YEAR_RANGE_ERROR, + msg="$dateToString should reject the minimum DatetimeMS value", + ), +] + +DATETOSTRING_BOUNDARY_TESTS: list[ExpressionTestCase] = ( + DATETOSTRING_LEAP_TESTS + DATETOSTRING_RANGE_TESTS + DATETOSTRING_YEAR_RANGE_ERROR_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETOSTRING_BOUNDARY_TESTS)) +def test_dateToString_boundaries(collection, test_case: ExpressionTestCase): + """Test $dateToString at calendar and range 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/dateToString/test_dateToString_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_expressions.py new file mode 100644 index 000000000..19b6c5345 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_expressions.py @@ -0,0 +1,138 @@ +"""Tests for $dateToString field references, expression inputs, and return type.""" + +from __future__ import annotations + +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, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_DATE_ERROR +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Field References]: each argument may be supplied through a field-path reference. +DATETOSTRING_FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "field_ref_date", + doc={"d": datetime(2024, 6, 15, tzinfo=timezone.utc)}, + expression={"$dateToString": {"date": "$d", "format": "%Y-%m-%d"}}, + expected="2024-06-15", + msg="$dateToString should accept a date from a field reference", + ), + ExpressionTestCase( + "field_ref_format", + doc={"d": datetime(2024, 6, 15, tzinfo=timezone.utc), "fmt": "%Y-%m-%d"}, + expression={"$dateToString": {"date": "$d", "format": "$fmt"}}, + expected="2024-06-15", + msg="$dateToString should accept a format from a field reference", + ), + ExpressionTestCase( + "field_ref_timezone", + doc={"d": datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), "tz": "America/New_York"}, + expression={"$dateToString": {"date": "$d", "format": "%H:%M", "timezone": "$tz"}}, + expected="07:00", + msg="$dateToString should accept a timezone from a field reference", + ), + ExpressionTestCase( + "field_ref_onNull", + doc={"fb": "FALLBACK"}, + expression={"$dateToString": {"date": "$d", "format": "%Y", "onNull": "$fb"}}, + expected="FALLBACK", + msg="$dateToString should accept an onNull value from a field reference", + ), + ExpressionTestCase( + "field_ref_objectid", + doc={"oid": oid_from_args(2024, 6, 15, 12, 0, 0)}, + expression={"$dateToString": {"date": "$oid", "format": "%Y-%m-%d"}}, + expected="2024-06-15", + msg="$dateToString should accept an ObjectId date from a field reference", + ), + ExpressionTestCase( + "field_ref_timestamp", + doc={"ts": ts_from_args(2024, 6, 15, 12, 0, 0)}, + expression={"$dateToString": {"date": "$ts", "format": "%Y-%m-%d"}}, + expected="2024-06-15", + msg="$dateToString should accept a Timestamp date from a field reference", + ), + ExpressionTestCase( + "nested_field", + doc={"a": {"b": datetime(2024, 6, 15, 0, 0, 0, tzinfo=timezone.utc)}}, + expression={"$dateToString": {"date": "$a.b", "format": "%Y-%m-%d"}}, + expected="2024-06-15", + msg="$dateToString should resolve a nested field-path date", + ), + ExpressionTestCase( + "missing_timezone_field_ref", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateToString": {"date": "$date", "format": "%Y", "timezone": "$tz"}}, + expected=None, + msg="$dateToString should return null when the referenced timezone field is missing", + ), + ExpressionTestCase( + "missing_format_field_ref", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateToString": {"date": "$date", "format": "$fmt"}}, + expected=None, + msg="$dateToString should return null when the referenced format field is missing", + ), + ExpressionTestCase( + "composite_array_path", + doc={ + "a": [ + {"b": datetime(2024, 6, 15, tzinfo=timezone.utc)}, + {"b": datetime(2025, 1, 1, tzinfo=timezone.utc)}, + ] + }, + expression={"$dateToString": {"date": "$a.b", "format": "%Y"}}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dateToString should reject a field path that resolves to an array of dates", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(DATETOSTRING_FIELD_REF_TESTS)) +def test_dateToString_field_references(collection, test_case: ExpressionTestCase): + """Test $dateToString 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 + ) + + +def test_dateToString_expression_as_date(collection): + """Test $dateToString with an expression operator as the date input.""" + result = execute_expression( + collection, + { + "$dateToString": { + "date": {"$dateFromString": {"dateString": "2024-06-15"}}, + "format": "%Y-%m-%d", + } + }, + ) + assert_expression_result(result, expected="2024-06-15") + + +def test_dateToString_return_type(collection): + """Test $dateToString return type.""" + result = execute_expression( + collection, + { + "$type": { + "$dateToString": { + "date": datetime(2024, 6, 15, tzinfo=timezone.utc), + "format": "%Y", + } + } + }, + ) + assert_expression_result(result, expected="string") diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_format.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_format.py new file mode 100644 index 000000000..b3480443a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_format.py @@ -0,0 +1,214 @@ +"""Tests for $dateToString format handling, specifiers, padding, and the default format.""" + +from __future__ import annotations + +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.error_codes import ( + DATETOSTRING_INVALID_FORMAT_ERROR, + DATETOSTRING_INVALID_FORMAT_TYPE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Format Validation]: a literal format is emitted verbatim, and an unsupported specifier +# or a non-string format is rejected. +DATETOSTRING_FORMAT_VALIDATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "format_empty", + doc={"format": ""}, + expression={ + "$dateToString": { + "date": datetime(2024, 1, 1, tzinfo=timezone.utc), + "format": "$format", + } + }, + expected="", + msg="$dateToString should return an empty string for an empty format", + ), + ExpressionTestCase( + "format_no_spec", + doc={"format": "hello"}, + expression={ + "$dateToString": { + "date": datetime(2024, 1, 1, tzinfo=timezone.utc), + "format": "$format", + } + }, + expected="hello", + msg="$dateToString should emit a format with no specifiers verbatim", + ), + ExpressionTestCase( + "format_percent", + doc={"format": "100%%"}, + expression={ + "$dateToString": { + "date": datetime(2024, 1, 1, tzinfo=timezone.utc), + "format": "$format", + } + }, + expected="100%", + msg="$dateToString should emit a literal percent for %%", + ), + ExpressionTestCase( + "format_invalid_spec", + doc={"format": "%x"}, + expression={ + "$dateToString": { + "date": datetime(2024, 1, 1, tzinfo=timezone.utc), + "format": "$format", + } + }, + error_code=DATETOSTRING_INVALID_FORMAT_ERROR, + msg="$dateToString should reject an unsupported format specifier", + ), + ExpressionTestCase( + "format_number", + doc={"format": 123}, + expression={ + "$dateToString": { + "date": datetime(2024, 1, 1, tzinfo=timezone.utc), + "format": "$format", + } + }, + error_code=DATETOSTRING_INVALID_FORMAT_TYPE_ERROR, + msg="$dateToString should reject a numeric format", + ), + ExpressionTestCase( + "format_boolean", + doc={"format": True}, + expression={ + "$dateToString": { + "date": datetime(2024, 1, 1, tzinfo=timezone.utc), + "format": "$format", + } + }, + error_code=DATETOSTRING_INVALID_FORMAT_TYPE_ERROR, + msg="$dateToString should reject a boolean format", + ), +] + +# Property [Format Specifiers]: each supported conversion specifier formats its component correctly. +DATETOSTRING_FORMAT_SPECIFIER_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"spec_{name}", + doc={"date": datetime(2024, 6, 15, 10, 30, 45, 123000, tzinfo=timezone.utc)}, + expression={"$dateToString": {"date": "$date", "format": fmt}}, + expected=expected, + msg=f"$dateToString should format the {name} specifier", + ) + for name, fmt, expected in [ + ("Y", "%Y", "2024"), + ("m", "%m", "06"), + ("d", "%d", "15"), + ("H", "%H", "10"), + ("M", "%M", "30"), + ("S", "%S", "45"), + ("L", "%L", "123"), + ("j", "%j", "167"), + ("u", "%u", "6"), + ("w", "%w", "7"), + ("pct", "%%", "%"), + ("G", "%G", "2024"), + ("V", "%V", "24"), + ("U", "%U", "23"), + ("b", "%b", "Jun"), + ("B", "%B", "June"), + ] +] + +# Property [Zero Padding]: single-digit components are zero-padded to their fixed width. +DATETOSTRING_PADDING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"pad_{name}", + doc={"date": date}, + expression={"$dateToString": {"date": "$date", "format": fmt}}, + expected=expected, + msg=f"$dateToString should zero-pad the {name} specifier", + ) + for name, date, fmt, expected in [ + ("d", datetime(2024, 1, 5, 0, 0, 0, tzinfo=timezone.utc), "%d", "05"), + ("m", datetime(2024, 3, 1, 0, 0, 0, tzinfo=timezone.utc), "%m", "03"), + ("H", datetime(2024, 1, 1, 8, 0, 0, tzinfo=timezone.utc), "%H", "08"), + ("M", datetime(2024, 1, 1, 0, 5, 0, tzinfo=timezone.utc), "%M", "05"), + ("S", datetime(2024, 1, 1, 0, 0, 3, tzinfo=timezone.utc), "%S", "03"), + ("L", datetime(2024, 1, 1, 0, 0, 0, 1000, tzinfo=timezone.utc), "%L", "001"), + ("j", datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc), "%j", "001"), + ] +] + +# Property [Timezone Specifiers]: the %z and %Z specifiers emit the offset of the given timezone. +DATETOSTRING_TZ_SPECIFIER_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "spec_z", + doc={"date": datetime(2024, 6, 15, 10, 30, 45, 123000, tzinfo=timezone.utc)}, + expression={"$dateToString": {"date": "$date", "format": "%z", "timezone": "UTC"}}, + expected="+0000", + msg="$dateToString should format the %z specifier as a numeric offset", + ), + ExpressionTestCase( + "spec_Z", + doc={"date": datetime(2024, 6, 15, 10, 30, 45, 123000, tzinfo=timezone.utc)}, + expression={"$dateToString": {"date": "$date", "format": "%Z", "timezone": "UTC"}}, + expected="0", + msg="$dateToString should format the %Z specifier as an offset in minutes", + ), +] + +# Property [Default Format]: with no format, the ISO representation reflects the applied timezone. +DATETOSTRING_DEFAULT_FORMAT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "default_utc", + doc={"date": datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$dateToString": {"date": "$date"}}, + expected="2024-01-01T12:00:00.000Z", + msg="$dateToString should use the ISO default format with a trailing Z for UTC", + ), + ExpressionTestCase( + "default_utc_tz", + doc={"date": datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateToString": { + "date": "$date", + "timezone": "UTC", + } + }, + expected="2024-01-01T12:00:00.000Z", + msg="$dateToString should keep the trailing Z when the timezone is UTC", + ), + ExpressionTestCase( + "default_non_utc", + doc={"date": datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={ + "$dateToString": { + "date": "$date", + "timezone": "America/New_York", + } + }, + expected="2024-01-01T07:00:00.000", + msg="$dateToString should drop the trailing Z for a non-UTC timezone", + ), +] + +DATETOSTRING_FORMAT_TESTS: list[ExpressionTestCase] = ( + DATETOSTRING_FORMAT_VALIDATION_TESTS + + DATETOSTRING_FORMAT_SPECIFIER_TESTS + + DATETOSTRING_PADDING_TESTS + + DATETOSTRING_TZ_SPECIFIER_TESTS + + DATETOSTRING_DEFAULT_FORMAT_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETOSTRING_FORMAT_TESTS)) +def test_dateToString_format(collection, test_case: ExpressionTestCase): + """Test $dateToString format handling and specifiers.""" + 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/dateToString/test_dateToString_objectid.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_objectid.py new file mode 100644 index 000000000..d5d367b38 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_objectid.py @@ -0,0 +1,97 @@ +"""Tests for $dateToString formatting of ObjectId date inputs, including boundaries.""" + +from __future__ import annotations + +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, +) +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 ( + OID_MAX_SIGNED32, + OID_MAX_UNSIGNED32, + OID_MIN_SIGNED32, +) + +# Property [ObjectId Date]: an ObjectId is formatted using its embedded timestamp, across +# boundaries. +DATETOSTRING_OBJECTID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "oid_ymd", + doc={"date": oid_from_args(2024, 6, 15, 12, 0, 0)}, + expression={"$dateToString": {"date": "$date", "format": "%Y-%m-%d"}}, + expected="2024-06-15", + msg="$dateToString should format an ObjectId date with %Y-%m-%d", + ), + ExpressionTestCase( + "oid_hms", + doc={"date": oid_from_args(2024, 6, 15, 12, 0, 0)}, + expression={"$dateToString": {"date": "$date", "format": "%H:%M:%S"}}, + expected="12:00:00", + msg="$dateToString should format an ObjectId date with %H:%M:%S", + ), + ExpressionTestCase( + "oid_default_fmt", + doc={"date": oid_from_args(2024, 6, 15, 12, 0, 0)}, + expression={"$dateToString": {"date": "$date"}}, + expected="2024-06-15T12:00:00.000Z", + msg="$dateToString should format an ObjectId date with the default format", + ), + ExpressionTestCase( + "oid_with_tz", + doc={"date": oid_from_args(2024, 6, 15, 12, 0, 0)}, + expression={"$dateToString": {"date": "$date", "format": "%Y-%m-%d", "timezone": "UTC"}}, + expected="2024-06-15", + msg="$dateToString should format an ObjectId date with a timezone", + ), + ExpressionTestCase( + "oid_epoch", + doc={"date": oid_from_args(1970, 1, 1, 0, 0, 0)}, + expression={"$dateToString": {"date": "$date", "format": "%Y-%m-%d"}}, + expected="1970-01-01", + msg="$dateToString should format an ObjectId date at the epoch", + ), + ExpressionTestCase( + "oid_future", + doc={"date": oid_from_args(2035, 6, 15, 0, 0, 0)}, + expression={"$dateToString": {"date": "$date", "format": "%Y-%m-%d"}}, + expected="2035-06-15", + msg="$dateToString should format a future ObjectId date", + ), + ExpressionTestCase( + "oid_boundary_max_s32", + doc={"date": OID_MAX_SIGNED32}, + expression={"$dateToString": {"date": "$date", "format": "%Y-%m-%d"}}, + expected="2038-01-19", + msg="$dateToString should format a max signed 32-bit ObjectId date", + ), + ExpressionTestCase( + "oid_boundary_min_s32", + doc={"date": OID_MIN_SIGNED32}, + expression={"$dateToString": {"date": "$date", "format": "%Y-%m-%d"}}, + expected="1901-12-13", + msg="$dateToString should format a min signed 32-bit ObjectId date", + ), + ExpressionTestCase( + "oid_boundary_max_u32", + doc={"date": OID_MAX_UNSIGNED32}, + expression={"$dateToString": {"date": "$date", "format": "%Y-%m-%d"}}, + expected="1969-12-31", + msg="$dateToString should format a max unsigned 32-bit ObjectId date", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(DATETOSTRING_OBJECTID_TESTS)) +def test_dateToString_objectid(collection, test_case: ExpressionTestCase): + """Test $dateToString with ObjectId date inputs.""" + 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/dateToString/test_dateToString_timestamp.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_timestamp.py new file mode 100644 index 000000000..988af512c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_timestamp.py @@ -0,0 +1,86 @@ +"""Tests for $dateToString formatting of BSON Timestamp date inputs, including boundaries.""" + +from __future__ import annotations + +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 ( + 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 TS_MAX_SIGNED32, TS_MAX_UNSIGNED32 + +# Property [Timestamp Date]: a BSON Timestamp is formatted using its seconds field, across +# boundaries. +DATETOSTRING_TIMESTAMP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "ts_ymd", + doc={"date": ts_from_args(2024, 6, 15, 12, 0, 0)}, + expression={"$dateToString": {"date": "$date", "format": "%Y-%m-%d"}}, + expected="2024-06-15", + msg="$dateToString should format a Timestamp date with %Y-%m-%d", + ), + ExpressionTestCase( + "ts_hms", + doc={"date": ts_from_args(2024, 6, 15, 12, 0, 0)}, + expression={"$dateToString": {"date": "$date", "format": "%H:%M:%S"}}, + expected="12:00:00", + msg="$dateToString should format a Timestamp date with %H:%M:%S", + ), + ExpressionTestCase( + "ts_default_fmt", + doc={"date": ts_from_args(2024, 6, 15, 12, 0, 0)}, + expression={"$dateToString": {"date": "$date"}}, + expected="2024-06-15T12:00:00.000Z", + msg="$dateToString should format a Timestamp date with the default format", + ), + ExpressionTestCase( + "ts_with_tz", + doc={"date": ts_from_args(2024, 6, 15, 12, 0, 0)}, + expression={"$dateToString": {"date": "$date", "format": "%Y-%m-%d", "timezone": "UTC"}}, + expected="2024-06-15", + msg="$dateToString should format a Timestamp date with a timezone", + ), + ExpressionTestCase( + "ts_epoch", + doc={"date": ts_from_args(1970, 1, 1, 0, 0, 0)}, + expression={"$dateToString": {"date": "$date", "format": "%Y-%m-%d"}}, + expected="1970-01-01", + msg="$dateToString should format a Timestamp date at the epoch", + ), + ExpressionTestCase( + "ts_future", + doc={"date": ts_from_args(2100, 6, 15, 0, 0, 0)}, + expression={"$dateToString": {"date": "$date", "format": "%Y-%m-%d"}}, + expected="2100-06-15", + msg="$dateToString should format a future Timestamp date", + ), + ExpressionTestCase( + "ts_boundary_max_s32", + doc={"date": TS_MAX_SIGNED32}, + expression={"$dateToString": {"date": "$date", "format": "%Y-%m-%d"}}, + expected="2038-01-19", + msg="$dateToString should format a max signed 32-bit Timestamp date", + ), + ExpressionTestCase( + "ts_boundary_max_u32", + doc={"date": TS_MAX_UNSIGNED32}, + expression={"$dateToString": {"date": "$date", "format": "%Y-%m-%d"}}, + expected="2106-02-07", + msg="$dateToString should format a max unsigned 32-bit Timestamp date", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(DATETOSTRING_TIMESTAMP_TESTS)) +def test_dateToString_timestamp(collection, test_case: ExpressionTestCase): + """Test $dateToString with Timestamp date inputs.""" + 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/dateToString/test_dateToString_timezone_names.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_timezone_names.py new file mode 100644 index 000000000..d4a14461f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_timezone_names.py @@ -0,0 +1,184 @@ +"""Tests for $dateToString with named and abbreviated timezones, including DST.""" + +from __future__ import annotations + +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 [Named Timezones]: named and abbreviated timezones apply the correct offset, including +# DST rules. +DATETOSTRING_NAMED_TZ_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_gmt", + doc={"timezone": "GMT"}, + expression={ + "$dateToString": { + "date": datetime(2024, 6, 15, 10, 30, 45, 123000, tzinfo=timezone.utc), + "format": "%Y-%m-%d", + "timezone": "$timezone", + } + }, + expected="2024-06-15", + msg="$dateToString should accept the GMT timezone", + ), + ExpressionTestCase( + "tz_utc", + doc={"timezone": "UTC"}, + expression={ + "$dateToString": { + "date": datetime(2024, 6, 15, 10, 30, 45, 123000, tzinfo=timezone.utc), + "format": "%Y-%m-%d", + "timezone": "$timezone", + } + }, + expected="2024-06-15", + msg="$dateToString should accept the UTC timezone", + ), + ExpressionTestCase( + "tz_europe_london", + doc={"timezone": "Europe/London"}, + expression={ + "$dateToString": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "format": "%H:%M", + "timezone": "$timezone", + } + }, + expected="12:00", + msg="$dateToString should apply the winter offset for Europe/London", + ), + ExpressionTestCase( + "tz_europe_london_bst", + doc={"timezone": "Europe/London"}, + expression={ + "$dateToString": { + "date": datetime(2020, 7, 1, 12, 0, 0, tzinfo=timezone.utc), + "format": "%H:%M", + "timezone": "$timezone", + } + }, + expected="13:00", + msg="$dateToString should apply the BST summer offset for Europe/London", + ), + ExpressionTestCase( + "tz_asia_tokyo", + doc={"timezone": "Asia/Tokyo"}, + expression={ + "$dateToString": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "format": "%H:%M", + "timezone": "$timezone", + } + }, + expected="21:00", + msg="$dateToString should apply the Asia/Tokyo offset", + ), + ExpressionTestCase( + "tz_asia_kolkata", + doc={"timezone": "Asia/Kolkata"}, + expression={ + "$dateToString": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "format": "%H:%M", + "timezone": "$timezone", + } + }, + expected="17:30", + msg="$dateToString should apply the Asia/Kolkata half-hour offset", + ), + ExpressionTestCase( + "tz_pacific_apia", + doc={"timezone": "Pacific/Apia"}, + expression={ + "$dateToString": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "format": "%Y-%m-%d %H:%M", + "timezone": "$timezone", + } + }, + expected="2020-01-02 02:00", + msg="$dateToString should apply the Pacific/Apia offset", + ), + ExpressionTestCase( + "tz_est_abbreviation", + doc={"timezone": "EST"}, + expression={ + "$dateToString": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "format": "%H:%M", + "timezone": "$timezone", + } + }, + expected="07:00", + msg="$dateToString should accept the EST three-letter abbreviation", + ), + ExpressionTestCase( + "tz_dst_summer", + doc={"timezone": "America/New_York"}, + expression={ + "$dateToString": { + "date": datetime(2024, 7, 1, 12, 0, 0, tzinfo=timezone.utc), + "format": "%H:%M%z", + "timezone": "$timezone", + } + }, + expected="08:00-0400", + msg="$dateToString should apply the summer DST offset for America/New_York", + ), + ExpressionTestCase( + "tz_dst_winter", + doc={"timezone": "America/New_York"}, + expression={ + "$dateToString": { + "date": datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "format": "%H:%M%z", + "timezone": "$timezone", + } + }, + expected="07:00-0500", + msg="$dateToString should apply the winter standard offset for America/New_York", + ), + ExpressionTestCase( + "tz_ny_time", + doc={"timezone": "America/New_York"}, + expression={ + "$dateToString": { + "date": datetime(2014, 1, 1, 8, 15, 39, 736000, tzinfo=timezone.utc), + "format": "%H:%M:%S:%L%z", + "timezone": "$timezone", + } + }, + expected="03:15:39:736-0500", + msg="$dateToString should apply America/New_York to a full time format", + ), + ExpressionTestCase( + "tz_Z_ny", + doc={"timezone": "America/New_York"}, + expression={ + "$dateToString": { + "date": datetime(2014, 1, 1, 8, 15, 39, 736000, tzinfo=timezone.utc), + "format": "%Z", + "timezone": "$timezone", + } + }, + expected="-300", + msg="$dateToString should format %Z as offset minutes for America/New_York", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(DATETOSTRING_NAMED_TZ_TESTS)) +def test_dateToString_timezone_names(collection, test_case: ExpressionTestCase): + """Test $dateToString with named timezones.""" + 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/dateToString/test_dateToString_timezone_offsets.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_timezone_offsets.py new file mode 100644 index 000000000..190e204fb --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_timezone_offsets.py @@ -0,0 +1,262 @@ +"""Tests for $dateToString with numeric UTC offset timezones, including boundary offsets.""" + +from __future__ import annotations + +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 [Numeric Offset Timezones]: UTC offsets in various syntaxes apply the correct shift, +# including out-of-range offsets the server accepts. +DATETOSTRING_OFFSET_TZ_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_offset", + doc={"timezone": "+05:30"}, + expression={ + "$dateToString": { + "date": datetime(2024, 6, 15, 10, 30, 45, 123000, tzinfo=timezone.utc), + "format": "%Y-%m-%d", + "timezone": "$timezone", + } + }, + expected="2024-06-15", + msg="$dateToString should accept a colon-separated offset", + ), + ExpressionTestCase( + "tz_zero", + doc={"timezone": "+00:00"}, + expression={ + "$dateToString": { + "date": datetime(2024, 6, 15, 10, 30, 45, 123000, tzinfo=timezone.utc), + "format": "%Y-%m-%d", + "timezone": "$timezone", + } + }, + expected="2024-06-15", + msg="$dateToString should accept a zero offset", + ), + ExpressionTestCase( + "tz_no_colon", + doc={"timezone": "-0500"}, + expression={ + "$dateToString": { + "date": datetime(2024, 6, 15, 10, 30, 45, 123000, tzinfo=timezone.utc), + "format": "%H:%M", + "timezone": "$timezone", + } + }, + expected="05:30", + msg="$dateToString should accept a colonless offset", + ), + ExpressionTestCase( + "tz_hour_only", + doc={"timezone": "+03"}, + expression={ + "$dateToString": { + "date": datetime(2024, 6, 15, 10, 30, 45, 123000, tzinfo=timezone.utc), + "format": "%H:%M", + "timezone": "$timezone", + } + }, + expected="13:30", + msg="$dateToString should accept an hour-only offset", + ), + ExpressionTestCase( + "tz_45min", + doc={"timezone": "+05:45"}, + expression={ + "$dateToString": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "format": "%H:%M", + "timezone": "$timezone", + } + }, + expected="17:45", + msg="$dateToString should accept a 45-minute offset", + ), + ExpressionTestCase( + "tz_half_hour_west", + doc={"timezone": "-03:30"}, + expression={ + "$dateToString": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "format": "%H:%M", + "timezone": "$timezone", + } + }, + expected="08:30", + msg="$dateToString should accept a half-hour west offset", + ), + ExpressionTestCase( + "tz_plus14", + doc={"timezone": "+14:00"}, + expression={ + "$dateToString": { + "date": datetime(2024, 6, 15, 11, 0, 0, tzinfo=timezone.utc), + "format": "%Y-%m-%d", + "timezone": "$timezone", + } + }, + expected="2024-06-16", + msg="$dateToString should accept a +14:00 offset that rolls the date forward", + ), + ExpressionTestCase( + "tz_minus11", + doc={"timezone": "-11:00"}, + expression={ + "$dateToString": { + "date": datetime(2024, 6, 15, 10, 0, 0, tzinfo=timezone.utc), + "format": "%Y-%m-%d", + "timezone": "$timezone", + } + }, + expected="2024-06-14", + msg="$dateToString should accept a -11:00 offset that rolls the date backward", + ), + ExpressionTestCase( + "tz_offset_minus13", + doc={"timezone": "-13:00"}, + expression={ + "$dateToString": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "format": "%Y-%m-%d %H:%M", + "timezone": "$timezone", + } + }, + expected="2019-12-31 23:00", + msg="$dateToString should accept a -13:00 offset", + ), + ExpressionTestCase( + "tz_offset_plus15", + doc={"timezone": "+15:00"}, + expression={ + "$dateToString": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "format": "%Y-%m-%d %H:%M", + "timezone": "$timezone", + } + }, + expected="2020-01-02 03:00", + msg="$dateToString should accept a +15:00 offset", + ), + ExpressionTestCase( + "tz_over60_minutes_positive", + doc={"timezone": "+05:70"}, + expression={ + "$dateToString": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "format": "%H:%M", + "timezone": "$timezone", + } + }, + expected="18:10", + msg="$dateToString should accept a positive offset with over 60 minutes", + ), + ExpressionTestCase( + "tz_over60_minutes_negative", + doc={"timezone": "-05:70"}, + expression={ + "$dateToString": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "format": "%H:%M", + "timezone": "$timezone", + } + }, + expected="05:50", + msg="$dateToString should accept a negative offset with over 60 minutes", + ), + ExpressionTestCase( + "tz_over24_hours_positive", + doc={"timezone": "+25:00"}, + expression={ + "$dateToString": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "format": "%Y-%m-%d %H:%M", + "timezone": "$timezone", + } + }, + expected="2020-01-02 13:00", + msg="$dateToString should accept a positive offset over 24 hours", + ), + ExpressionTestCase( + "tz_over24_hours_negative", + doc={"timezone": "-25:00"}, + expression={ + "$dateToString": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "format": "%Y-%m-%d %H:%M", + "timezone": "$timezone", + } + }, + expected="2019-12-31 11:00", + msg="$dateToString should accept a negative offset over 24 hours", + ), + ExpressionTestCase( + "tz_max_valid_positive", + doc={"timezone": "+99:99"}, + expression={ + "$dateToString": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "format": "%Y-%m-%d %H:%M", + "timezone": "$timezone", + } + }, + expected="2020-01-05 16:39", + msg="$dateToString should accept the maximum two-digit positive offset", + ), + ExpressionTestCase( + "tz_max_valid_negative", + doc={"timezone": "-99:99"}, + expression={ + "$dateToString": { + "date": datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "format": "%Y-%m-%d %H:%M", + "timezone": "$timezone", + } + }, + expected="2019-12-28 07:21", + msg="$dateToString should accept the maximum two-digit negative offset", + ), + ExpressionTestCase( + "tz_Z_430", + doc={"timezone": "+04:30"}, + expression={ + "$dateToString": { + "date": datetime(2014, 1, 1, 8, 15, 39, 736000, tzinfo=timezone.utc), + "format": "%Z", + "timezone": "$timezone", + } + }, + expected="270", + msg="$dateToString should format %Z as offset minutes for a numeric offset", + ), + ExpressionTestCase( + "tz_430_time", + doc={"timezone": "+04:30"}, + expression={ + "$dateToString": { + "date": datetime(2014, 1, 1, 8, 15, 39, 736000, tzinfo=timezone.utc), + "format": "%H:%M:%S:%L%z", + "timezone": "$timezone", + } + }, + expected="12:45:39:736+0430", + msg="$dateToString should apply a +04:30 offset to a full time format", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(DATETOSTRING_OFFSET_TZ_TESTS)) +def test_dateToString_timezone_offsets(collection, test_case: ExpressionTestCase): + """Test $dateToString with numeric offset timezones.""" + 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/dateToString/test_dateToString_timezone_validation.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_timezone_validation.py new file mode 100644 index 000000000..09e9c3783 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_timezone_validation.py @@ -0,0 +1,127 @@ +"""Tests for $dateToString null and invalid timezone handling.""" + +from __future__ import annotations + +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.error_codes import INVALID_TIMEZONE_ERROR +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Null Timezone]: a null timezone yields null. +DATETOSTRING_NULL_TZ_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_null", + doc={"timezone": None}, + expression={ + "$dateToString": { + "date": datetime(2024, 6, 15, 10, 30, 45, 123000, tzinfo=timezone.utc), + "format": "%Y", + "timezone": "$timezone", + } + }, + expected=None, + msg="$dateToString should return null for a null timezone", + ), +] + +# Property [Invalid Timezone]: an unrecognized or malformed timezone string is rejected. +DATETOSTRING_INVALID_TZ_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_invalid_str", + doc={"timezone": "NotATimezone"}, + expression={ + "$dateToString": { + "date": datetime(2024, 6, 15, 10, 30, 45, 123000, tzinfo=timezone.utc), + "format": "%Y", + "timezone": "$timezone", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dateToString should reject an unrecognized timezone string", + ), + ExpressionTestCase( + "tz_empty", + doc={"timezone": ""}, + expression={ + "$dateToString": { + "date": datetime(2024, 6, 15, 10, 30, 45, 123000, tzinfo=timezone.utc), + "format": "%Y", + "timezone": "$timezone", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dateToString should reject an empty timezone string", + ), + ExpressionTestCase( + "tz_nonexistent_olson", + doc={"timezone": "America/Nowhere"}, + expression={ + "$dateToString": { + "date": datetime(2024, 6, 15, 10, 30, 45, 123000, tzinfo=timezone.utc), + "format": "%Y", + "timezone": "$timezone", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dateToString should reject a non-existent Olson timezone", + ), + ExpressionTestCase( + "tz_3digit_hours_invalid", + doc={"timezone": "+100:00"}, + expression={ + "$dateToString": { + "date": datetime(2024, 6, 15, 10, 30, 45, 123000, tzinfo=timezone.utc), + "format": "%Y", + "timezone": "$timezone", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dateToString should reject a three-digit hour offset", + ), + ExpressionTestCase( + "tz_olson_wrong_case_lowercase", + doc={"timezone": "america/new_york"}, + expression={ + "$dateToString": { + "date": datetime(2024, 6, 15, 10, 30, 45, 123000, tzinfo=timezone.utc), + "format": "%Y", + "timezone": "$timezone", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dateToString should reject an all-lowercase Olson name", + ), + ExpressionTestCase( + "tz_olson_wrong_case_uppercase", + doc={"timezone": "AMERICA/NEW_YORK"}, + expression={ + "$dateToString": { + "date": datetime(2024, 6, 15, 10, 30, 45, 123000, tzinfo=timezone.utc), + "format": "%Y", + "timezone": "$timezone", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$dateToString should reject an all-uppercase Olson name", + ), +] + +DATETOSTRING_TZ_VALIDATION_TESTS: list[ExpressionTestCase] = ( + DATETOSTRING_NULL_TZ_TESTS + DATETOSTRING_INVALID_TZ_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETOSTRING_TZ_VALIDATION_TESTS)) +def test_dateToString_timezone_validation(collection, test_case: ExpressionTestCase): + """Test $dateToString null and invalid timezones.""" + 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/dateToString/test_dateToString_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_types.py new file mode 100644 index 000000000..8bd3e311f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/dateToString/test_dateToString_types.py @@ -0,0 +1,172 @@ +"""Tests for $dateToString type validation of the date, format, and timezone fields.""" + +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 ( + DATETOSTRING_INVALID_FORMAT_TYPE_ERROR, + INVALID_TIMEZONE_TYPE_ERROR, + TYPE_MISMATCH_DATE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + FLOAT_INFINITY, + FLOAT_NAN, +) + +# Property [date Type Rejection]: any non-date, non-null date value is rejected as a type mismatch. +DATETOSTRING_DATE_TYPE_TESTS: list[ExpressionTestCase] = [ + *[ + ExpressionTestCase( + f"date_type_{tid}", + doc={"date": val}, + expression={"$dateToString": {"date": "$date", "format": "%Y"}}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg=f"$dateToString should reject a {tid} date", + ) + for tid, val in [ + ("int32", 123), + ("int64", Int64(123)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("string", "2024-01-01"), + ("bool_true", True), + ("bool_false", False), + ("object", {"a": 1}), + ("array", [1, 2]), + ("binary", Binary(b"\x01")), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + ExpressionTestCase( + "date_nan", + doc={"date": FLOAT_NAN}, + expression={"$dateToString": {"date": "$date", "format": "%Y"}}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dateToString should reject a NaN date", + ), + ExpressionTestCase( + "date_infinity", + doc={"date": FLOAT_INFINITY}, + expression={"$dateToString": {"date": "$date", "format": "%Y"}}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dateToString should reject an infinity date", + ), + ExpressionTestCase( + "date_decimal128_nan", + doc={"date": DECIMAL128_NAN}, + expression={"$dateToString": {"date": "$date", "format": "%Y"}}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dateToString should reject a Decimal128 NaN date", + ), + ExpressionTestCase( + "date_decimal128_infinity", + doc={"date": DECIMAL128_INFINITY}, + expression={"$dateToString": {"date": "$date", "format": "%Y"}}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dateToString should reject a Decimal128 infinity date", + ), + ExpressionTestCase( + "date_decimal128_neg_infinity", + doc={"date": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$dateToString": {"date": "$date", "format": "%Y"}}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$dateToString should reject a Decimal128 negative-infinity date", + ), +] + +# Property [format Type Rejection]: any non-string, non-null format is rejected as an invalid type. +DATETOSTRING_FORMAT_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"format_type_{tid}", + doc={"format": val}, + expression={ + "$dateToString": { + "date": datetime(2024, 1, 1, tzinfo=timezone.utc), + "format": "$format", + } + }, + error_code=DATETOSTRING_INVALID_FORMAT_TYPE_ERROR, + msg=f"$dateToString should reject a {tid} format", + ) + for tid, val in [ + ("int32", 123), + ("int64", Int64(123)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("bool", True), + ("object", {"%Y": 1}), + ("array", ["%Y"]), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("000000000000000000000000")), + ("timestamp", Timestamp(0, 1)), + ("binary", Binary(b"\x01")), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [timezone Type Rejection]: any non-string, non-null timezone is rejected as an invalid +# type. +DATETOSTRING_TIMEZONE_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"timezone_type_{tid}", + doc={"timezone": val}, + expression={ + "$dateToString": { + "date": datetime(2024, 6, 15, 10, 30, 45, 123000, tzinfo=timezone.utc), + "format": "%Y", + "timezone": "$timezone", + } + }, + error_code=INVALID_TIMEZONE_TYPE_ERROR, + msg=f"$dateToString should reject a {tid} timezone", + ) + for tid, val in [ + ("int32", 5), + ("int64", Int64(5)), + ("double", 5.0), + ("decimal128", Decimal128("5")), + ("bool", True), + ("object", {"tz": "UTC"}), + ("array", ["UTC"]), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("000000000000000000000000")), + ("timestamp", Timestamp(0, 1)), + ("binary", Binary(b"\x01")), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +DATETOSTRING_TYPE_TESTS: list[ExpressionTestCase] = ( + DATETOSTRING_DATE_TYPE_TESTS + DATETOSTRING_FORMAT_TYPE_TESTS + DATETOSTRING_TIMEZONE_TYPE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(DATETOSTRING_TYPE_TESTS)) +def test_dateToString_types(collection, test_case: ExpressionTestCase): + """Test $dateToString type validation.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + )