From 5b4f5c3463acadc78a2546a76a861d6839ff3f88 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Tue, 14 Jul 2026 10:12:13 -0700 Subject: [PATCH 1/5] Add $year expression tests This change adds tests for the $year expression. It was originally authored by @mitchell-elholm. Closes #324 Co-authored-by: Mitchell Elholm Signed-off-by: Daniel Frankcom --- .../expressions/date/year/__init__.py | 0 .../date/year/test_year_calendar.py | 231 ++++++++++++++++++ .../date/year/test_year_date_types.py | 174 +++++++++++++ .../date/year/test_year_expressions.py | 198 +++++++++++++++ .../year/test_year_null_and_type_errors.py | 229 +++++++++++++++++ .../year/test_year_timezone_input_types.py | 137 +++++++++++ .../date/year/test_year_timezone_names.py | 120 +++++++++ .../date/year/test_year_timezone_offsets.py | 141 +++++++++++ .../year/test_year_timezone_validation.py | 115 +++++++++ 9 files changed, 1345 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/year/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/year/test_year_calendar.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/year/test_year_date_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/year/test_year_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/year/test_year_null_and_type_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/year/test_year_timezone_input_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/year/test_year_timezone_names.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/year/test_year_timezone_offsets.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/year/test_year_timezone_validation.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/year/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/year/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/year/test_year_calendar.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/year/test_year_calendar.py new file mode 100644 index 000000000..3cbb4db11 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/year/test_year_calendar.py @@ -0,0 +1,231 @@ +"""Tests for $year calendar-year extraction across the calendar, leap years, and the year range.""" + +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_LEAP_FEB29, DATE_YEAR_9999 + +# Property [Year Extraction]: $year returns the calendar year, so every instant from the first +# to the last moment of a year maps to that year regardless of month, day, or sub-second. +YEAR_EXTRACTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "year_2024", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": "$date"}, + expected=2024, + msg="$year should return 2024 for a mid-2024 date", + ), + ExpressionTestCase( + "year_2000", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": "$date"}, + expected=2000, + msg="$year should return 2000 for a date in the year 2000", + ), + ExpressionTestCase( + "year_1999", + doc={"date": datetime(1999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$year": "$date"}, + expected=1999, + msg="$year should return 1999 for the last second of 1999", + ), + ExpressionTestCase( + "year_2099", + doc={"date": datetime(2099, 7, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": "$date"}, + expected=2099, + msg="$year should return 2099 for a mid-2099 date", + ), + ExpressionTestCase( + "year_9999", + doc={"date": DATE_YEAR_9999}, + expression={"$year": "$date"}, + expected=9999, + msg="$year should return 9999 for the maximum representable datetime", + ), + ExpressionTestCase( + "first_moment_of_year", + doc={"date": datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": "$date"}, + expected=2024, + msg="$year should return 2024 for the first moment of 2024", + ), + ExpressionTestCase( + "last_moment_of_year", + doc={"date": datetime(2024, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$year": "$date"}, + expected=2024, + msg="$year should return 2024 for the last second of 2024", + ), + ExpressionTestCase( + "millisecond_before_year_end", + doc={"date": datetime(2024, 12, 31, 23, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={"$year": "$date"}, + expected=2024, + msg="$year should return 2024 one millisecond before the end of 2024", + ), + ExpressionTestCase( + "millisecond_after_year_start", + doc={"date": datetime(2024, 1, 1, 0, 0, 0, 1000, tzinfo=timezone.utc)}, + expression={"$year": "$date"}, + expected=2024, + msg="$year should return 2024 one millisecond after the start of 2024", + ), + ExpressionTestCase( + "millisecond_mid_year", + doc={"date": datetime(2024, 6, 15, 12, 30, 30, 500000, tzinfo=timezone.utc)}, + expression={"$year": "$date"}, + expected=2024, + msg="$year should return 2024 for a mid-year instant with sub-second precision", + ), +] + +# Property [Leap Years]: the calendar year is correct on and around leap day, including the +# century leap-rule boundaries (year-2000 leap and non-leap century 1900). +YEAR_LEAP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "leap_year_feb_29_2024", + doc={"date": datetime(2024, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": "$date"}, + expected=2024, + msg="$year should return 2024 for leap day 2024", + ), + ExpressionTestCase( + "non_leap_year_feb_28", + doc={"date": datetime(2023, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": "$date"}, + expected=2023, + msg="$year should return 2023 for Feb 28 of the non-leap year 2023", + ), + ExpressionTestCase( + "leap_year_feb_29_2020", + doc={"date": datetime(2020, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": "$date"}, + expected=2020, + msg="$year should return 2020 for leap day 2020", + ), + ExpressionTestCase( + "leap_year_feb_29_2000", + doc={"date": DATE_LEAP_FEB29}, + expression={"$year": "$date"}, + expected=2000, + msg="$year should return 2000 for leap day of the year-2000 century leap", + ), + ExpressionTestCase( + "non_leap_century_1900_feb_28", + doc={"date": datetime(1900, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": "$date"}, + expected=1900, + msg="$year should return 1900 for Feb 28 of the non-leap century 1900", + ), +] + +# Property [Pre-Epoch]: dates at and before the 1970 epoch return their correct calendar year. +YEAR_PRE_EPOCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "epoch_1970", + doc={"date": DATE_EPOCH}, + expression={"$year": "$date"}, + expected=1970, + msg="$year should return 1970 for the epoch", + ), + ExpressionTestCase( + "pre_epoch_1969_dec", + doc={"date": datetime(1969, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$year": "$date"}, + expected=1969, + msg="$year should return 1969 for the last second before the epoch", + ), + ExpressionTestCase( + "pre_epoch_1969_jan", + doc={"date": datetime(1969, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": "$date"}, + expected=1969, + msg="$year should return 1969 for the first moment of 1969", + ), + ExpressionTestCase( + "pre_epoch_1960", + doc={"date": datetime(1960, 3, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": "$date"}, + expected=1960, + msg="$year should return 1960 for a pre-epoch date in 1960", + ), + ExpressionTestCase( + "pre_epoch_1900", + doc={"date": datetime(1900, 7, 4, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": "$date"}, + expected=1900, + msg="$year should return 1900 for a pre-epoch date in 1900", + ), + ExpressionTestCase( + "pre_epoch_1952_leap", + doc={"date": datetime(1952, 2, 29, 6, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": "$date"}, + expected=1952, + msg="$year should return 1952 for leap day of the pre-epoch leap year 1952", + ), +] + +# Property [Extended Range]: computed instants beyond the native datetime range resolve to the +# correct calendar year, including the millisecond before the epoch, BCE/year-zero dates, and +# years far past the datetime maximum of 9999. +YEAR_EXTENDED_RANGE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "epoch_ms_zero", + expression={"$year": {"$add": [DATE_EPOCH, 0]}}, + expected=1970, + msg="$year should return 1970 at the epoch millisecond", + ), + ExpressionTestCase( + "ms_negative_one", + expression={"$year": {"$add": [DATE_EPOCH, -1]}}, + expected=1969, + msg="$year should return 1969 one millisecond before the epoch", + ), + ExpressionTestCase( + "far_future_year_10000", + expression={"$year": {"$add": [DATE_EPOCH, 253_402_300_800_000]}}, + expected=10000, + msg="$year should return 10000 for the first instant of year 10000", + ), + ExpressionTestCase( + "far_past_year_0", + expression={"$year": {"$subtract": [DATE_EPOCH, 62_167_219_200_000]}}, + expected=0, + msg="$year should return 0 for the first instant of year 0", + ), + ExpressionTestCase( + "deep_past_bce", + expression={"$year": {"$subtract": [DATE_EPOCH, 3_217_862_400_000_000]}}, + expected=-100000, + msg="$year should return a negative year for a deep BCE instant", + ), + ExpressionTestCase( + "deep_future", + expression={"$year": {"$add": [DATE_EPOCH, 3_093_527_980_800_000]}}, + expected=100000, + msg="$year should return 100000 for a deep-future instant", + ), +] + +YEAR_CALENDAR_TESTS: list[ExpressionTestCase] = ( + YEAR_EXTRACTION_TESTS + YEAR_LEAP_TESTS + YEAR_PRE_EPOCH_TESTS + YEAR_EXTENDED_RANGE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(YEAR_CALENDAR_TESTS)) +def test_year_calendar(collection, test_case: ExpressionTestCase): + """Test $year extraction across the calendar, leap years, pre-epoch, and extended range.""" + 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/year/test_year_date_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/year/test_year_date_types.py new file mode 100644 index 000000000..db0fb440f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/year/test_year_date_types.py @@ -0,0 +1,174 @@ +"""Tests for $year with Timestamp, ObjectId, and extended-range date 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, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_MS_BEFORE_EPOCH, + DATE_MS_EPOCH, + DATE_MS_MAX, + DATE_MS_MIN, + DATE_MS_YEAR_10000, + OID_MAX_SIGNED32, + OID_MAX_UNSIGNED32, + OID_MIN_SIGNED32, + TS_MAX_SIGNED32, + TS_MAX_UNSIGNED32, +) + +# Property [Timestamp Input]: a BSON Timestamp is accepted as a date and yields its calendar +# year. +YEAR_TIMESTAMP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "timestamp_2024", + doc={"date": ts_from_args(2024, 6, 15, 0, 0, 0)}, + expression={"$year": "$date"}, + expected=2024, + msg="$year should return 2024 for a Timestamp in mid-2024", + ), + ExpressionTestCase( + "timestamp_2000", + doc={"date": ts_from_args(2000, 1, 1, 0, 0, 0)}, + expression={"$year": "$date"}, + expected=2000, + msg="$year should return 2000 for a Timestamp on Jan 1 2000", + ), + ExpressionTestCase( + "timestamp_zero_increment", + doc={"date": ts_from_args(2024, 6, 15, 0, 0, 0, inc=0)}, + expression={"$year": "$date"}, + expected=2024, + msg="$year should return 2024 for a Timestamp with a zero increment", + ), + ExpressionTestCase( + "timestamp_epoch", + doc={"date": ts_from_args(1970, 1, 1, 0, 0, 0)}, + expression={"$year": "$date"}, + expected=1970, + msg="$year should return 1970 for a Timestamp at the epoch", + ), +] + +# Property [ObjectId Input]: an ObjectId is accepted as a date via its embedded timestamp. +YEAR_OBJECTID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "objectid_2024", + doc={"date": oid_from_args(2024, 6, 15, 0, 0, 0)}, + expression={"$year": "$date"}, + expected=2024, + msg="$year should return 2024 for an ObjectId in mid-2024", + ), + ExpressionTestCase( + "objectid_2000", + doc={"date": oid_from_args(2000, 1, 1, 0, 0, 0)}, + expression={"$year": "$date"}, + expected=2000, + msg="$year should return 2000 for an ObjectId on Jan 1 2000", + ), + ExpressionTestCase( + "objectid_epoch", + doc={"date": oid_from_args(1970, 1, 1, 0, 0, 0)}, + expression={"$year": "$date"}, + expected=1970, + msg="$year should return 1970 for an ObjectId at the epoch", + ), +] + +# Property [Extended Range]: DatetimeMS, Timestamp, and ObjectId boundary instants beyond the +# native datetime range resolve to the correct calendar year. +YEAR_EXTENDED_RANGE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_ms_epoch", + doc={"date": DATE_MS_EPOCH}, + expression={"$year": "$date"}, + expected=1970, + msg="$year should return 1970 for the epoch as a DatetimeMS", + ), + ExpressionTestCase( + "date_ms_before_epoch", + doc={"date": DATE_MS_BEFORE_EPOCH}, + expression={"$year": "$date"}, + expected=1969, + msg="$year should return 1969 for a DatetimeMS one millisecond before the epoch", + ), + ExpressionTestCase( + "date_ms_year_10000", + doc={"date": DATE_MS_YEAR_10000}, + expression={"$year": "$date"}, + expected=10000, + msg="$year should return 10000 for a DatetimeMS at the year-10000 boundary", + ), + ExpressionTestCase( + "date_ms_max", + doc={"date": DATE_MS_MAX}, + expression={"$year": "$date"}, + expected=292_278_994, + msg="$year should return the far-future year for the maximum 64-bit DatetimeMS", + ), + ExpressionTestCase( + "date_ms_min", + doc={"date": DATE_MS_MIN}, + expression={"$year": "$date"}, + expected=-292_275_055, + msg="$year should return the far-past year for the minimum 64-bit DatetimeMS", + ), + ExpressionTestCase( + "ts_boundary_max_s32", + doc={"date": TS_MAX_SIGNED32}, + expression={"$year": "$date"}, + expected=2038, + msg="$year should return 2038 for the max signed 32-bit Timestamp", + ), + ExpressionTestCase( + "ts_boundary_max_u32", + doc={"date": TS_MAX_UNSIGNED32}, + expression={"$year": "$date"}, + expected=2106, + msg="$year should return 2106 for the max unsigned 32-bit Timestamp", + ), + ExpressionTestCase( + "oid_boundary_max_s32", + doc={"date": OID_MAX_SIGNED32}, + expression={"$year": "$date"}, + expected=2038, + msg="$year should return 2038 for the max signed 32-bit ObjectId", + ), + ExpressionTestCase( + "oid_boundary_min_s32", + doc={"date": OID_MIN_SIGNED32}, + expression={"$year": "$date"}, + expected=1901, + msg="$year should return 1901 for the min signed 32-bit ObjectId", + ), + ExpressionTestCase( + "oid_boundary_max_u32", + doc={"date": OID_MAX_UNSIGNED32}, + expression={"$year": "$date"}, + expected=1969, + msg="$year should return 1969 for the max unsigned 32-bit ObjectId read as signed", + ), +] + +YEAR_DATE_TYPES_TESTS: list[ExpressionTestCase] = ( + YEAR_TIMESTAMP_TESTS + YEAR_OBJECTID_TESTS + YEAR_EXTENDED_RANGE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(YEAR_DATE_TYPES_TESTS)) +def test_year_date_types(collection, test_case: ExpressionTestCase): + """Test $year with Timestamp, ObjectId, and extended-range 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/year/test_year_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/year/test_year_expressions.py new file mode 100644 index 000000000..016ffac1d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/year/test_year_expressions.py @@ -0,0 +1,198 @@ +"""Tests for $year argument forms, field-path resolution, expression input, 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 ( + ISO_DATE_INVALID_ARRAY_INPUT_ERROR, + ISO_DATE_MISSING_DATE_ERROR, + ISO_DATE_UNKNOWN_FIELD_ERROR, + TYPE_MISMATCH_DATE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Argument Forms]: the operand may be a date, a single-element operand array, or a +# document with exactly a date field; other operand shapes and scalar non-date operands are +# rejected. +YEAR_ARGUMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "document_form_missing_date", + expression={"$year": {"timezone": "UTC"}}, + error_code=ISO_DATE_MISSING_DATE_ERROR, + msg="$year should error when the document form omits the date field", + ), + ExpressionTestCase( + "extra_field", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "extra": 1}}, + error_code=ISO_DATE_UNKNOWN_FIELD_ERROR, + msg="$year should error for an unknown field in the document form", + ), + ExpressionTestCase( + "object_expression_input", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"a": "$date"}}, + error_code=ISO_DATE_UNKNOWN_FIELD_ERROR, + msg="$year should treat an object with an unknown key as an invalid document form", + ), + ExpressionTestCase( + "empty_array", + expression={"$year": []}, + error_code=ISO_DATE_INVALID_ARRAY_INPUT_ERROR, + msg="$year should error for an empty operand array", + ), + ExpressionTestCase( + "two_element_array", + expression={ + "$year": [ + datetime(2024, 6, 15, tzinfo=timezone.utc), + datetime(2024, 7, 15, tzinfo=timezone.utc), + ] + }, + error_code=ISO_DATE_INVALID_ARRAY_INPUT_ERROR, + msg="$year should error for a multi-element operand array", + ), + ExpressionTestCase( + "single_element_array", + expression={"$year": [datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)]}, + expected=2024, + msg="$year should accept a single-element operand array as the date", + ), + ExpressionTestCase( + "invalid_string", + expression={"$year": "string"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$year should reject a literal string operand", + ), + ExpressionTestCase( + "invalid_number", + expression={"$year": 123}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$year should reject a literal number operand", + ), + ExpressionTestCase( + "invalid_boolean", + expression={"$year": True}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$year should reject a literal boolean operand", + ), +] + +# Property [Input Expression Types]: the operator accepts each expression type it can receive +# as the date argument (literal, field reference in shorthand and document form, an array +# expression holding a field reference, a nested field path, and a nested expression); a path +# that resolves to an array feeds the type contract and errors. +YEAR_FIELD_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_date", + expression={"$year": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expected=2024, + msg="$year should return the calendar year for a literal date operand", + ), + ExpressionTestCase( + "field_ref_simple", + doc={"d": datetime(2024, 6, 15, tzinfo=timezone.utc)}, + expression={"$year": "$d"}, + expected=2024, + msg="$year should accept a date from a shorthand field reference", + ), + ExpressionTestCase( + "field_ref_object", + doc={"d": datetime(2024, 6, 15, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$d"}}, + expected=2024, + msg="$year should accept a date field reference in the document form", + ), + ExpressionTestCase( + "array_expression_field_ref", + doc={"d": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": ["$d"]}, + expected=2024, + msg="$year should unwrap a single-element array expression with a field reference", + ), + ExpressionTestCase( + "nested_field_path", + doc={"a": {"b": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}}, + expression={"$year": "$a.b"}, + expected=2024, + msg="$year should accept a date resolved from a nested field path", + ), + ExpressionTestCase( + "missing_nested_field", + doc={"a": {"x": 1}}, + expression={"$year": "$a.missing"}, + expected=None, + msg="$year should return null when a nested field path is missing", + ), + ExpressionTestCase( + "array_index_path", + doc={"a": [{"b": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}]}, + expression={"$year": "$a.0.b"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$year should error when an array-index path resolves to an array", + ), + ExpressionTestCase( + "composite_array_path", + doc={ + "a": [ + {"b": datetime(2024, 6, 15, 0, 0, 0, tzinfo=timezone.utc)}, + {"b": datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + ] + }, + expression={"$year": "$a.b"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$year should error when a path over an array of objects resolves to an array", + ), + ExpressionTestCase( + "timezone_from_field", + doc={"date": datetime(2024, 1, 1, 3, 0, 0, tzinfo=timezone.utc), "tz": "America/New_York"}, + expression={"$year": {"date": "$date", "timezone": "$tz"}}, + expected=2023, + msg="$year should apply a timezone resolved from a field reference", + ), + ExpressionTestCase( + "missing_tz_field_ref", + doc={"date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "$tz"}}, + expected=None, + msg="$year should return null when the timezone field reference is missing", + ), + ExpressionTestCase( + "expression_as_input", + expression={"$year": {"$dateFromString": {"dateString": "2024-06-15T00:00:00Z"}}}, + expected=2024, + msg="$year should accept the result of a nested expression as the date", + ), +] + +# Property [Return Type]: $year returns a value of BSON type int. +YEAR_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$type": {"$year": "$date"}}, + expected="int", + msg="$year should return an int", + ), +] + +YEAR_EXPRESSION_TESTS: list[ExpressionTestCase] = ( + YEAR_ARGUMENT_TESTS + YEAR_FIELD_PATH_TESTS + YEAR_RETURN_TYPE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(YEAR_EXPRESSION_TESTS)) +def test_year_expressions(collection, test_case: ExpressionTestCase): + """Test $year argument forms, field paths, expression input, 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/year/test_year_null_and_type_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/year/test_year_null_and_type_errors.py new file mode 100644 index 000000000..1e2512039 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/year/test_year_null_and_type_errors.py @@ -0,0 +1,229 @@ +"""Tests for $year null propagation and non-date type rejection.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, Regex + +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 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, + FLOAT_NEGATIVE_INFINITY, + MISSING, +) + +# Property [Null Propagation]: a null or missing date resolves to null rather than an error, +# including when an explicit timezone is supplied. +YEAR_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_date", + doc={"date": None}, + expression={"$year": "$date"}, + expected=None, + msg="$year should return null for a null date", + ), + ExpressionTestCase( + "missing_date", + expression={"$year": MISSING}, + expected=None, + msg="$year should return null when the date references a missing field", + ), + ExpressionTestCase( + "null_date_with_timezone", + doc={"date": None}, + expression={"$year": {"date": "$date", "timezone": "UTC"}}, + expected=None, + msg="$year should return null for a null date even when a timezone is supplied", + ), +] + +# Property [Type Rejection]: any non-date input type is rejected with a type-mismatch error, +# including empty containers and non-finite numerics. +YEAR_TYPE_REJECTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_date", + doc={"date": "not-a-date"}, + expression={"$year": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$year should reject a string as the date input", + ), + ExpressionTestCase( + "empty_string_date", + doc={"date": ""}, + expression={"$year": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$year should reject an empty string as the date input", + ), + ExpressionTestCase( + "integer_date", + doc={"date": 42}, + expression={"$year": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$year should reject an int as the date input", + ), + ExpressionTestCase( + "int64_date", + doc={"date": Int64(42)}, + expression={"$year": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$year should reject an int64 as the date input", + ), + ExpressionTestCase( + "double_date", + doc={"date": 3.14}, + expression={"$year": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$year should reject a double as the date input", + ), + ExpressionTestCase( + "decimal128_date", + doc={"date": Decimal128("42")}, + expression={"$year": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$year should reject a decimal128 as the date input", + ), + ExpressionTestCase( + "boolean_date", + doc={"date": True}, + expression={"$year": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$year should reject a boolean as the date input", + ), + ExpressionTestCase( + "array_date", + doc={"date": [1, 2, 3]}, + expression={"$year": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$year should reject an array as the date input", + ), + ExpressionTestCase( + "empty_array_date", + doc={"date": []}, + expression={"$year": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$year should reject an empty array as the date input", + ), + ExpressionTestCase( + "array_containing_date", + doc={"date": [datetime(2024, 1, 15, tzinfo=timezone.utc)]}, + expression={"$year": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$year should reject an array holding a date as the date input", + ), + ExpressionTestCase( + "object_date", + doc={"date": {"a": 1}}, + expression={"$year": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$year should reject an object as the date input", + ), + ExpressionTestCase( + "empty_object_date", + doc={"date": {}}, + expression={"$year": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$year should reject an empty object as the date input", + ), + ExpressionTestCase( + "regex_date", + doc={"date": Regex(".*")}, + expression={"$year": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$year should reject a regex as the date input", + ), + ExpressionTestCase( + "minkey_date", + doc={"date": MinKey()}, + expression={"$year": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$year should reject MinKey as the date input", + ), + ExpressionTestCase( + "maxkey_date", + doc={"date": MaxKey()}, + expression={"$year": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$year should reject MaxKey as the date input", + ), + ExpressionTestCase( + "bindata_date", + doc={"date": Binary(b"")}, + expression={"$year": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$year should reject binary data as the date input", + ), + ExpressionTestCase( + "javascript_date", + doc={"date": Code("function(){}")}, + expression={"$year": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$year should reject JavaScript code as the date input", + ), + ExpressionTestCase( + "float_nan_date", + doc={"date": FLOAT_NAN}, + expression={"$year": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$year should reject a float NaN as the date input", + ), + ExpressionTestCase( + "decimal128_nan_date", + doc={"date": DECIMAL128_NAN}, + expression={"$year": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$year should reject a decimal128 NaN as the date input", + ), + ExpressionTestCase( + "float_inf_date", + doc={"date": FLOAT_INFINITY}, + expression={"$year": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$year should reject a float infinity as the date input", + ), + ExpressionTestCase( + "float_neg_inf_date", + doc={"date": FLOAT_NEGATIVE_INFINITY}, + expression={"$year": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$year should reject a float negative infinity as the date input", + ), + ExpressionTestCase( + "decimal128_inf_date", + doc={"date": DECIMAL128_INFINITY}, + expression={"$year": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$year should reject a decimal128 infinity as the date input", + ), + ExpressionTestCase( + "decimal128_neg_inf_date", + doc={"date": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$year": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$year should reject a decimal128 negative infinity as the date input", + ), +] + +YEAR_NULL_AND_TYPE_ERROR_TESTS: list[ExpressionTestCase] = ( + YEAR_NULL_TESTS + YEAR_TYPE_REJECTION_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(YEAR_NULL_AND_TYPE_ERROR_TESTS)) +def test_year_null_and_type_errors(collection, test_case: ExpressionTestCase): + """Test $year null propagation and non-date type rejection.""" + 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/year/test_year_timezone_input_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/year/test_year_timezone_input_types.py new file mode 100644 index 000000000..4787fa1b9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/year/test_year_timezone_input_types.py @@ -0,0 +1,137 @@ +"""Tests for $year timezone application when the date is a Timestamp or ObjectId.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( + oid_from_args, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Timestamp Input with Zones]: a Timestamp input honours the named zone or offset +# before the calendar year is taken, which may cross a year boundary. +YEAR_TIMESTAMP_ZONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "ts_utc_no_cross", + doc={"date": ts_from_args(2024, 7, 15, 12, 0, 0)}, + expression={"$year": {"date": "$date", "timezone": "UTC"}}, + expected=2024, + msg="$year should return 2024 for a mid-2024 Timestamp in UTC", + ), + ExpressionTestCase( + "ts_ny_year_bwd", + doc={"date": ts_from_args(2024, 1, 1, 3, 0, 0)}, + expression={"$year": {"date": "$date", "timezone": "America/New_York"}}, + expected=2023, + msg="$year should cross back to 2023 for a Timestamp in America/New_York early on Jan 1", + ), + ExpressionTestCase( + "ts_helsinki_year_fwd", + doc={"date": ts_from_args(2024, 12, 31, 23, 0, 0)}, + expression={"$year": {"date": "$date", "timezone": "Europe/Helsinki"}}, + expected=2025, + msg="$year should cross forward to 2025 for a Timestamp in Europe/Helsinki late on Dec 31", + ), + ExpressionTestCase( + "ts_kolkata_year_fwd", + doc={"date": ts_from_args(2024, 12, 31, 22, 0, 0)}, + expression={"$year": {"date": "$date", "timezone": "Asia/Kolkata"}}, + expected=2025, + msg="$year should cross forward to 2025 for a Timestamp at the Asia/Kolkata offset", + ), + ExpressionTestCase( + "ts_offset_minus5_year_bwd", + doc={"date": ts_from_args(2024, 1, 1, 3, 0, 0)}, + expression={"$year": {"date": "$date", "timezone": "-05:00"}}, + expected=2023, + msg="$year should cross back to 2023 for a Timestamp at a -05:00 offset", + ), + ExpressionTestCase( + "ts_offset_plus2_year_fwd", + doc={"date": ts_from_args(2024, 12, 31, 23, 0, 0)}, + expression={"$year": {"date": "$date", "timezone": "+02:00"}}, + expected=2025, + msg="$year should cross forward to 2025 for a Timestamp at a +02:00 offset", + ), + ExpressionTestCase( + "ts_offset_plus530_year_fwd", + doc={"date": ts_from_args(2024, 12, 31, 22, 0, 0)}, + expression={"$year": {"date": "$date", "timezone": "+05:30"}}, + expected=2025, + msg="$year should cross forward to 2025 for a Timestamp at a +05:30 offset", + ), +] + +# Property [ObjectId Input with Zones]: an ObjectId input honours the named zone or offset +# before the calendar year is taken, which may cross a year boundary. +YEAR_OBJECTID_ZONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "oid_utc_no_cross", + doc={"date": oid_from_args(2024, 7, 15, 12, 0, 0)}, + expression={"$year": {"date": "$date", "timezone": "UTC"}}, + expected=2024, + msg="$year should return 2024 for a mid-2024 ObjectId in UTC", + ), + ExpressionTestCase( + "oid_ny_year_bwd", + doc={"date": oid_from_args(2024, 1, 1, 3, 0, 0)}, + expression={"$year": {"date": "$date", "timezone": "America/New_York"}}, + expected=2023, + msg="$year should cross back to 2023 for an ObjectId in America/New_York early on Jan 1", + ), + ExpressionTestCase( + "oid_helsinki_year_fwd", + doc={"date": oid_from_args(2024, 12, 31, 23, 0, 0)}, + expression={"$year": {"date": "$date", "timezone": "Europe/Helsinki"}}, + expected=2025, + msg="$year should cross forward to 2025 for an ObjectId in Europe/Helsinki late on Dec 31", + ), + ExpressionTestCase( + "oid_kolkata_year_fwd", + doc={"date": oid_from_args(2024, 12, 31, 22, 0, 0)}, + expression={"$year": {"date": "$date", "timezone": "Asia/Kolkata"}}, + expected=2025, + msg="$year should cross forward to 2025 for an ObjectId at the Asia/Kolkata offset", + ), + ExpressionTestCase( + "oid_offset_minus5_year_bwd", + doc={"date": oid_from_args(2024, 1, 1, 3, 0, 0)}, + expression={"$year": {"date": "$date", "timezone": "-05:00"}}, + expected=2023, + msg="$year should cross back to 2023 for an ObjectId at a -05:00 offset", + ), + ExpressionTestCase( + "oid_offset_plus2_year_fwd", + doc={"date": oid_from_args(2024, 12, 31, 23, 0, 0)}, + expression={"$year": {"date": "$date", "timezone": "+02:00"}}, + expected=2025, + msg="$year should cross forward to 2025 for an ObjectId at a +02:00 offset", + ), + ExpressionTestCase( + "oid_offset_plus530_year_fwd", + doc={"date": oid_from_args(2024, 12, 31, 22, 0, 0)}, + expression={"$year": {"date": "$date", "timezone": "+05:30"}}, + expected=2025, + msg="$year should cross forward to 2025 for an ObjectId at a +05:30 offset", + ), +] + +YEAR_TIMEZONE_INPUT_TYPES_TESTS: list[ExpressionTestCase] = ( + YEAR_TIMESTAMP_ZONE_TESTS + YEAR_OBJECTID_ZONE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(YEAR_TIMEZONE_INPUT_TYPES_TESTS)) +def test_year_timezone_input_types(collection, test_case: ExpressionTestCase): + """Test $year timezone application for Timestamp and 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/year/test_year_timezone_names.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/year/test_year_timezone_names.py new file mode 100644 index 000000000..2369557ad --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/year/test_year_timezone_names.py @@ -0,0 +1,120 @@ +"""Tests for $year named-timezone and abbreviation application, including DST and year crossings.""" + +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]: a named zone or abbreviation shifts the instant before the calendar +# year is taken, which may cross a year boundary depending on the offset, while a DST +# transition within a year leaves the year unchanged. +YEAR_NAMED_ZONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_utc_no_cross", + doc={"date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "UTC"}}, + expected=2024, + msg="$year should return 2024 for UTC with no year crossing", + ), + ExpressionTestCase( + "tz_ny_no_cross", + doc={"date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "America/New_York"}}, + expected=2024, + msg="$year should return 2024 for America/New_York with no year crossing", + ), + ExpressionTestCase( + "tz_tokyo_no_cross", + doc={"date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "Asia/Tokyo"}}, + expected=2024, + msg="$year should return 2024 for Asia/Tokyo with no year crossing", + ), + ExpressionTestCase( + "tz_ny_year_bwd", + doc={"date": datetime(2024, 1, 1, 3, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "America/New_York"}}, + expected=2023, + msg="$year should cross back to 2023 for America/New_York early on Jan 1", + ), + ExpressionTestCase( + "tz_ny_year_stays", + doc={"date": datetime(2024, 1, 1, 6, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "America/New_York"}}, + expected=2024, + msg="$year should stay in 2024 for America/New_York after the local new year", + ), + ExpressionTestCase( + "tz_helsinki_year_fwd", + doc={"date": datetime(2024, 12, 31, 23, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "Europe/Helsinki"}}, + expected=2025, + msg="$year should cross forward to 2025 for Europe/Helsinki late on Dec 31", + ), + ExpressionTestCase( + "tz_kolkata_year_fwd", + doc={"date": datetime(2024, 12, 31, 22, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "Asia/Kolkata"}}, + expected=2025, + msg="$year should cross forward to 2025 for the half-hour Asia/Kolkata offset", + ), + ExpressionTestCase( + "tz_la_year_bwd", + doc={"date": datetime(2024, 1, 1, 3, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "America/Los_Angeles"}}, + expected=2023, + msg="$year should cross back to 2023 for America/Los_Angeles early on Jan 1", + ), + ExpressionTestCase( + "tz_ny_dst_spring", + doc={"date": datetime(2024, 3, 10, 7, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "America/New_York"}}, + expected=2024, + msg="$year should return 2024 across the America/New_York spring DST transition", + ), + ExpressionTestCase( + "tz_ny_dst_fall", + doc={"date": datetime(2024, 11, 3, 6, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "America/New_York"}}, + expected=2024, + msg="$year should return 2024 across the America/New_York fall DST transition", + ), + ExpressionTestCase( + "tz_london_winter", + doc={"date": datetime(2024, 12, 31, 23, 30, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "Europe/London"}}, + expected=2024, + msg="$year should return 2024 for Europe/London in winter with no year crossing", + ), + ExpressionTestCase( + "tz_pacific_apia", + doc={"date": datetime(2024, 12, 31, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "Pacific/Apia"}}, + expected=2025, + msg="$year should cross forward to 2025 for the +13 Pacific/Apia offset", + ), + ExpressionTestCase( + "tz_est_abbreviation", + doc={"date": datetime(2024, 1, 1, 3, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "EST"}}, + expected=2023, + msg="$year should accept the EST abbreviation and cross back a year", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(YEAR_NAMED_ZONE_TESTS)) +def test_year_timezone_names(collection, test_case: ExpressionTestCase): + """Test $year named-timezone and abbreviation application.""" + 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/year/test_year_timezone_offsets.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/year/test_year_timezone_offsets.py new file mode 100644 index 000000000..3bf41d5bd --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/year/test_year_timezone_offsets.py @@ -0,0 +1,141 @@ +"""Tests for $year UTC-offset timezone application, including compact and unusual offsets.""" + +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 [UTC Offsets]: an explicit +HH:MM/-HH:MM offset shifts the instant before the +# calendar year is taken, including compact, hour-only, half-hour, extreme, and out-of-range +# offsets the server still accepts, and offsets that cross a year boundary. +YEAR_OFFSET_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_offset_zero_no_cross", + doc={"date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "+00:00"}}, + expected=2024, + msg="$year should accept a +00:00 offset with no year crossing", + ), + ExpressionTestCase( + "tz_offset_minus5_no_cross", + doc={"date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "-05:00"}}, + expected=2024, + msg="$year should accept a -05:00 offset with no year crossing", + ), + ExpressionTestCase( + "tz_offset_minus5_year_bwd", + doc={"date": datetime(2024, 1, 1, 3, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "-05:00"}}, + expected=2023, + msg="$year should cross back to 2023 for a -05:00 offset early on Jan 1", + ), + ExpressionTestCase( + "tz_offset_plus2_year_fwd", + doc={"date": datetime(2024, 12, 31, 23, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "+02:00"}}, + expected=2025, + msg="$year should cross forward to 2025 for a +02:00 offset late on Dec 31", + ), + ExpressionTestCase( + "tz_offset_plus530_year_fwd", + doc={"date": datetime(2024, 12, 31, 22, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "+05:30"}}, + expected=2025, + msg="$year should cross forward to 2025 for a +05:30 half-hour offset", + ), + ExpressionTestCase( + "tz_offset_minus12_year_bwd", + doc={"date": datetime(2024, 1, 1, 11, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "-12:00"}}, + expected=2023, + msg="$year should cross back to 2023 for a -12:00 offset on Jan 1", + ), + ExpressionTestCase( + "tz_offset_plus14_year_fwd", + doc={"date": datetime(2024, 12, 31, 11, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "+14:00"}}, + expected=2025, + msg="$year should cross forward to 2025 for a +14:00 offset on Dec 31", + ), + ExpressionTestCase( + "tz_offset_minus5_no_colon", + doc={"date": datetime(2024, 1, 1, 3, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "-0500"}}, + expected=2023, + msg="$year should accept a compact -0500 offset without a colon and cross back a year", + ), + ExpressionTestCase( + "tz_offset_minus8_hour_only", + doc={"date": datetime(2024, 1, 1, 3, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "-08"}}, + expected=2023, + msg="$year should accept an hour-only -08 offset and cross back a year", + ), + ExpressionTestCase( + "tz_offset_minus13", + doc={"date": datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "-13:00"}}, + expected=2023, + msg="$year should accept a -13:00 offset that crosses back into the prior year", + ), + ExpressionTestCase( + "tz_offset_plus15", + doc={"date": datetime(2024, 12, 31, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "+15:00"}}, + expected=2025, + msg="$year should accept a +15:00 offset that crosses into the next year", + ), + ExpressionTestCase( + "tz_offset_minus0330", + doc={"date": datetime(2024, 1, 1, 2, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "-03:30"}}, + expected=2023, + msg="$year should accept a -03:30 half-hour west offset that crosses back a year", + ), + ExpressionTestCase( + "tz_offset_plus0570", + doc={"date": datetime(2024, 12, 31, 17, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "+05:70"}}, + expected=2024, + msg="$year should accept a +05:70 (70-minute) offset", + ), + ExpressionTestCase( + "tz_offset_minus0570", + doc={"date": datetime(2024, 1, 1, 5, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "-05:70"}}, + expected=2023, + msg="$year should accept a -05:70 (70-minute) offset that crosses back a year", + ), + ExpressionTestCase( + "tz_offset_plus2500", + doc={"date": datetime(2024, 12, 30, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "+25:00"}}, + expected=2024, + msg="$year should accept a +25:00 (25-hour) offset", + ), + ExpressionTestCase( + "tz_offset_minus2500", + doc={"date": datetime(2024, 1, 2, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "-25:00"}}, + expected=2023, + msg="$year should accept a -25:00 (25-hour) offset that crosses back a year", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(YEAR_OFFSET_TESTS)) +def test_year_timezone_offsets(collection, test_case: ExpressionTestCase): + """Test $year UTC-offset timezone application, including edge and unusual offsets.""" + 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/year/test_year_timezone_validation.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/year/test_year_timezone_validation.py new file mode 100644 index 000000000..beeb7586d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/year/test_year_timezone_validation.py @@ -0,0 +1,115 @@ +"""Tests for $year timezone validation and null-timezone propagation.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + INVALID_TIMEZONE_ERROR, + INVALID_TIMEZONE_TYPE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Timezone Validation]: unparseable zone strings, wrong-typed timezones, and null +# timezones are rejected or propagate null. +YEAR_TIMEZONE_VALIDATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "invalid_tz_olson", + doc={"date": datetime(2024, 6, 15, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "Not/A_Timezone"}}, + error_code=INVALID_TIMEZONE_ERROR, + msg="$year should reject an unparseable timezone string", + ), + ExpressionTestCase( + "invalid_tz_nonexistent_olson", + doc={"date": datetime(2024, 6, 15, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "America/Nowhere"}}, + error_code=INVALID_TIMEZONE_ERROR, + msg="$year should reject a non-existent Olson timezone", + ), + ExpressionTestCase( + "invalid_tz_offset_format", + doc={"date": datetime(2024, 6, 15, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "25:00"}}, + error_code=INVALID_TIMEZONE_ERROR, + msg="$year should reject an unsigned out-of-range offset string", + ), + ExpressionTestCase( + "invalid_tz_numeric_string", + doc={"date": datetime(2024, 6, 15, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "123"}}, + error_code=INVALID_TIMEZONE_ERROR, + msg="$year should reject a bare numeric timezone string", + ), + ExpressionTestCase( + "invalid_tz_empty_string", + doc={"date": datetime(2024, 6, 15, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": ""}}, + error_code=INVALID_TIMEZONE_ERROR, + msg="$year should reject an empty-string timezone", + ), + ExpressionTestCase( + "invalid_tz_olson_lowercase", + doc={"date": datetime(2024, 6, 15, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "america/new_york"}}, + error_code=INVALID_TIMEZONE_ERROR, + msg="$year should reject an all-lowercase Olson name", + ), + ExpressionTestCase( + "invalid_tz_olson_uppercase", + doc={"date": datetime(2024, 6, 15, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": "AMERICA/NEW_YORK"}}, + error_code=INVALID_TIMEZONE_ERROR, + msg="$year should reject an all-uppercase Olson name", + ), + *[ + ExpressionTestCase( + f"invalid_tz_{tid}", + doc={"date": datetime(2024, 6, 15, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": val}}, + error_code=INVALID_TIMEZONE_TYPE_ERROR, + msg=f"$year should reject a {tid} timezone", + ) + for tid, val in [ + ("int", 5), + ("int64", Int64(5)), + ("double", 3.14), + ("decimal128", Decimal128("5")), + ("bool", True), + ("object", {"tz": "UTC"}), + ("array", ["UTC"]), + ("datetime", datetime(2024, 6, 15, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + ExpressionTestCase( + "null_tz", + doc={"date": datetime(2024, 6, 15, tzinfo=timezone.utc)}, + expression={"$year": {"date": "$date", "timezone": None}}, + expected=None, + msg="$year should return null for a null timezone", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(YEAR_TIMEZONE_VALIDATION_TESTS)) +def test_year_timezone_validation(collection, test_case: ExpressionTestCase): + """Test $year timezone validation and null-timezone 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 + ) From 15402dbaba7a1a2f963d7383cc99a278cb0c8c7f Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Tue, 14 Jul 2026 10:59:24 -0700 Subject: [PATCH 2/5] Add $month expression tests This change adds tests for the $month expression. It was originally authored by @mitchell-elholm. Closes #321 Co-authored-by: Mitchell Elholm Signed-off-by: Daniel Frankcom --- .../expressions/date/month/__init__.py | 0 .../date/month/test_month_calendar.py | 297 ++++++++++++++++++ .../date/month/test_month_date_types.py | 187 +++++++++++ .../date/month/test_month_expressions.py | 162 ++++++++++ .../month/test_month_null_and_type_errors.py | 211 +++++++++++++ .../month/test_month_timezone_input_types.py | 206 ++++++++++++ .../date/month/test_month_timezone_names.py | 248 +++++++++++++++ .../date/month/test_month_timezone_offsets.py | 259 +++++++++++++++ .../month/test_month_timezone_validation.py | 151 +++++++++ 9 files changed, 1721 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/month/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/month/test_month_calendar.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/month/test_month_date_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/month/test_month_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/month/test_month_null_and_type_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/month/test_month_timezone_input_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/month/test_month_timezone_names.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/month/test_month_timezone_offsets.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/month/test_month_timezone_validation.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/month/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/month/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/month/test_month_calendar.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/month/test_month_calendar.py new file mode 100644 index 000000000..d106ca2ae --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/month/test_month_calendar.py @@ -0,0 +1,297 @@ +"""Tests for $month month extraction from datetimes across the calendar and year range.""" + +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 + +# Property [Month Extraction]: $month returns the month component (1-12) of a UTC date. +MONTH_EXTRACTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "january", + doc={"date": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=1, + msg="$month should return 1 for a date in January", + ), + ExpressionTestCase( + "february", + doc={"date": datetime(2024, 2, 28, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=2, + msg="$month should return 2 for a date in February", + ), + ExpressionTestCase( + "march", + doc={"date": datetime(2024, 3, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=3, + msg="$month should return 3 for a date in March", + ), + ExpressionTestCase( + "april", + doc={"date": datetime(2024, 4, 30, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=4, + msg="$month should return 4 for a date in April", + ), + ExpressionTestCase( + "may", + doc={"date": datetime(2024, 5, 15, 6, 0, 0, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=5, + msg="$month should return 5 for a date in May", + ), + ExpressionTestCase( + "june", + doc={"date": datetime(2024, 6, 21, 18, 0, 0, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=6, + msg="$month should return 6 for a date in June", + ), + ExpressionTestCase( + "july", + doc={"date": datetime(2024, 7, 4, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=7, + msg="$month should return 7 for a date in July", + ), + ExpressionTestCase( + "august", + doc={"date": datetime(2024, 8, 31, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=8, + msg="$month should return 8 for a date in August", + ), + ExpressionTestCase( + "september", + doc={"date": datetime(2024, 9, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=9, + msg="$month should return 9 for a date in September", + ), + ExpressionTestCase( + "october", + doc={"date": datetime(2024, 10, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=10, + msg="$month should return 10 for a date in October", + ), + ExpressionTestCase( + "november", + doc={"date": datetime(2024, 11, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=11, + msg="$month should return 11 for a date in November", + ), + ExpressionTestCase( + "december", + doc={"date": datetime(2024, 12, 25, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=12, + msg="$month should return 12 for a date in December", + ), +] + +# Property [Calendar Boundaries]: year edges, leap-year Feb 29, century rules, and +# sub-second precision resolve to the correct month. +MONTH_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "first_day_of_year", + doc={"date": datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=1, + msg="$month should return 1 for the first day of the year", + ), + ExpressionTestCase( + "last_day_of_year", + doc={"date": datetime(2024, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=12, + msg="$month should return 12 for the last day of the year", + ), + ExpressionTestCase( + "leap_year_feb_29", + doc={"date": datetime(2024, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=2, + msg="$month should return 2 for Feb 29 in a leap year", + ), + ExpressionTestCase( + "non_leap_year_feb_28", + doc={"date": datetime(2023, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=2, + msg="$month should return 2 for Feb 28 in a non-leap year", + ), + ExpressionTestCase( + "leap_year_feb_29_2020", + doc={"date": datetime(2020, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=2, + msg="$month should return 2 for Feb 29 in the leap year 2020", + ), + ExpressionTestCase( + "leap_year_feb_29_2000", + doc={"date": datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=2, + msg="$month should return 2 for Feb 29 in the century leap year 2000", + ), + ExpressionTestCase( + "non_leap_century_1900_feb_28", + doc={"date": datetime(1900, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=2, + msg="$month should return 2 for Feb 28 in the non-leap century year 1900", + ), + ExpressionTestCase( + "midnight", + doc={"date": datetime(2024, 6, 15, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=6, + msg="$month should return 6 at the start of a day", + ), + ExpressionTestCase( + "end_of_day", + doc={"date": datetime(2024, 6, 15, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=6, + msg="$month should return 6 at the end of a day", + ), + ExpressionTestCase( + "millisecond_before_month_end", + doc={"date": datetime(2024, 6, 30, 23, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=6, + msg="$month should return 6 one millisecond before the month ends", + ), + ExpressionTestCase( + "millisecond_after_month_start", + doc={"date": datetime(2024, 7, 1, 0, 0, 0, 1000, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=7, + msg="$month should return 7 one millisecond after the month starts", + ), + ExpressionTestCase( + "millisecond_mid_month", + doc={"date": datetime(2024, 6, 15, 12, 30, 30, 500000, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=6, + msg="$month should return 6 for a mid-month instant with milliseconds", + ), + ExpressionTestCase( + "millisecond_year_boundary", + doc={"date": datetime(2024, 12, 31, 23, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=12, + msg="$month should return 12 one millisecond before the year ends", + ), + ExpressionTestCase( + "millisecond_leap_feb_end", + doc={"date": datetime(2024, 2, 29, 23, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=2, + msg="$month should return 2 one millisecond before the end of leap-year February", + ), +] + +# Property [Year Range]: the month component is correct across a wide span of years. +MONTH_YEAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "year_2000", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=1, + msg="$month should return 1 for a date in the year 2000", + ), + ExpressionTestCase( + "year_1970_epoch", + doc={"date": DATE_EPOCH}, + expression={"$month": "$date"}, + expected=1, + msg="$month should return 1 for the Unix epoch", + ), + ExpressionTestCase( + "year_1999", + doc={"date": datetime(1999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=12, + msg="$month should return 12 for the last day of 1999", + ), + ExpressionTestCase( + "year_2099", + doc={"date": datetime(2099, 7, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=7, + msg="$month should return 7 for a date in the year 2099", + ), + ExpressionTestCase( + "year_9999", + doc={"date": datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=12, + msg="$month should return 12 for the last representable year 9999", + ), +] + +# Property [Pre-Epoch]: negative-millisecond dates before 1970 resolve to the correct month. +MONTH_PRE_EPOCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "pre_epoch_1960", + doc={"date": datetime(1960, 3, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=3, + msg="$month should return 3 for a pre-epoch date in 1960", + ), + ExpressionTestCase( + "pre_epoch_1900", + doc={"date": datetime(1900, 7, 4, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=7, + msg="$month should return 7 for a pre-epoch date in 1900", + ), + ExpressionTestCase( + "pre_epoch_1969_dec", + doc={"date": datetime(1969, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=12, + msg="$month should return 12 for the last month before the epoch", + ), + ExpressionTestCase( + "pre_epoch_1969_jan", + doc={"date": datetime(1969, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=1, + msg="$month should return 1 for the first month of 1969", + ), + ExpressionTestCase( + "pre_epoch_1950_leap", + doc={"date": datetime(1952, 2, 29, 6, 0, 0, tzinfo=timezone.utc)}, + expression={"$month": "$date"}, + expected=2, + msg="$month should return 2 for Feb 29 in the pre-epoch leap year 1952", + ), +] + +MONTH_CALENDAR_TESTS: list[ExpressionTestCase] = ( + MONTH_EXTRACTION_TESTS + MONTH_BOUNDARY_TESTS + MONTH_YEAR_TESTS + MONTH_PRE_EPOCH_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(MONTH_CALENDAR_TESTS)) +def test_month_calendar(collection, test_case: ExpressionTestCase): + """Test $month month extraction across the calendar and year range.""" + 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/month/test_month_date_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/month/test_month_date_types.py new file mode 100644 index 000000000..64c774f31 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/month/test_month_date_types.py @@ -0,0 +1,187 @@ +"""Tests for $month with Timestamp, ObjectId, and extended-range date 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, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_MS_BEFORE_EPOCH, + DATE_MS_EPOCH, + DATE_MS_MAX, + DATE_MS_MIN, + DATE_MS_YEAR_10000, + OID_MAX_SIGNED32, + OID_MAX_UNSIGNED32, + OID_MIN_SIGNED32, + TS_MAX_SIGNED32, + TS_MAX_UNSIGNED32, +) + +# Property [Timestamp Input]: a BSON Timestamp is accepted as a date and yields its month. +MONTH_TIMESTAMP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "timestamp_june", + doc={"date": ts_from_args(2024, 6, 15, 0, 0, 0)}, + expression={"$month": "$date"}, + expected=6, + msg="$month should return 6 for a Timestamp in June", + ), + ExpressionTestCase( + "timestamp_january", + doc={"date": ts_from_args(2024, 1, 1, 0, 0, 0)}, + expression={"$month": "$date"}, + expected=1, + msg="$month should return 1 for a Timestamp in January", + ), + ExpressionTestCase( + "timestamp_december", + doc={"date": ts_from_args(2024, 12, 31, 0, 0, 0)}, + expression={"$month": "$date"}, + expected=12, + msg="$month should return 12 for a Timestamp in December", + ), + ExpressionTestCase( + "timestamp_zero_increment", + doc={"date": ts_from_args(2024, 6, 15, 0, 0, 0, inc=0)}, + expression={"$month": "$date"}, + expected=6, + msg="$month should return 6 for a Timestamp with a zero increment", + ), + ExpressionTestCase( + "timestamp_epoch", + doc={"date": ts_from_args(1970, 1, 1, 0, 0, 0)}, + expression={"$month": "$date"}, + expected=1, + msg="$month should return 1 for a Timestamp at the epoch", + ), +] + +# Property [ObjectId Input]: an ObjectId is accepted as a date via its embedded timestamp. +MONTH_OBJECTID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "objectid_june_2024", + doc={"date": oid_from_args(2024, 6, 15, 0, 0, 0)}, + expression={"$month": "$date"}, + expected=6, + msg="$month should return 6 for an ObjectId in June", + ), + ExpressionTestCase( + "objectid_jan_2024", + doc={"date": oid_from_args(2024, 1, 1, 0, 0, 0)}, + expression={"$month": "$date"}, + expected=1, + msg="$month should return 1 for an ObjectId in January", + ), + ExpressionTestCase( + "objectid_dec_2024", + doc={"date": oid_from_args(2024, 12, 31, 0, 0, 0)}, + expression={"$month": "$date"}, + expected=12, + msg="$month should return 12 for an ObjectId in December", + ), + ExpressionTestCase( + "objectid_epoch", + doc={"date": oid_from_args(1970, 1, 1, 0, 0, 0)}, + expression={"$month": "$date"}, + expected=1, + msg="$month should return 1 for an ObjectId at the epoch", + ), +] + +# Property [Extended Range]: DatetimeMS, Timestamp, and ObjectId boundary instants +# beyond the native datetime range resolve to the correct month. +MONTH_EXTENDED_RANGE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_ms_epoch", + doc={"date": DATE_MS_EPOCH}, + expression={"$month": "$date"}, + expected=1, + msg="$month should return 1 for the epoch as a DatetimeMS", + ), + ExpressionTestCase( + "date_ms_before_epoch", + doc={"date": DATE_MS_BEFORE_EPOCH}, + expression={"$month": "$date"}, + expected=12, + msg="$month should return 12 for a DatetimeMS one millisecond before the epoch", + ), + ExpressionTestCase( + "date_ms_year_10000", + doc={"date": DATE_MS_YEAR_10000}, + expression={"$month": "$date"}, + expected=1, + msg="$month should return 1 for a DatetimeMS at the year-10000 boundary", + ), + ExpressionTestCase( + "date_ms_max", + doc={"date": DATE_MS_MAX}, + expression={"$month": "$date"}, + expected=8, + msg="$month should return 8 for the maximum 64-bit DatetimeMS", + ), + ExpressionTestCase( + "date_ms_min", + doc={"date": DATE_MS_MIN}, + expression={"$month": "$date"}, + expected=5, + msg="$month should return 5 for the minimum 64-bit DatetimeMS", + ), + ExpressionTestCase( + "ts_boundary_max_s32", + doc={"date": TS_MAX_SIGNED32}, + expression={"$month": "$date"}, + expected=1, + msg="$month should return 1 for the max signed 32-bit Timestamp", + ), + ExpressionTestCase( + "ts_boundary_max_u32", + doc={"date": TS_MAX_UNSIGNED32}, + expression={"$month": "$date"}, + expected=2, + msg="$month should return 2 for the max unsigned 32-bit Timestamp", + ), + ExpressionTestCase( + "oid_boundary_max_s32", + doc={"date": OID_MAX_SIGNED32}, + expression={"$month": "$date"}, + expected=1, + msg="$month should return 1 for the max signed 32-bit ObjectId", + ), + ExpressionTestCase( + "oid_boundary_min_s32", + doc={"date": OID_MIN_SIGNED32}, + expression={"$month": "$date"}, + expected=12, + msg="$month should return 12 for the min signed 32-bit ObjectId", + ), + ExpressionTestCase( + "oid_boundary_max_u32", + doc={"date": OID_MAX_UNSIGNED32}, + expression={"$month": "$date"}, + expected=12, + msg="$month should return 12 for the max unsigned 32-bit ObjectId", + ), +] + +MONTH_DATE_TYPES_TESTS: list[ExpressionTestCase] = ( + MONTH_TIMESTAMP_TESTS + MONTH_OBJECTID_TESTS + MONTH_EXTENDED_RANGE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(MONTH_DATE_TYPES_TESTS)) +def test_month_date_types(collection, test_case: ExpressionTestCase): + """Test $month with Timestamp, ObjectId, and extended-range 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/month/test_month_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/month/test_month_expressions.py new file mode 100644 index 000000000..627bd7695 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/month/test_month_expressions.py @@ -0,0 +1,162 @@ +"""Tests for $month argument forms, field-path resolution, and expression input.""" + +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 ( + ISO_DATE_INVALID_ARRAY_INPUT_ERROR, + ISO_DATE_MISSING_DATE_ERROR, + ISO_DATE_UNKNOWN_FIELD_ERROR, + TYPE_MISMATCH_DATE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Literal Input]: an inline literal date computes the correct month. +MONTH_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_date", + expression={"$month": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expected=6, + msg="$month should return the month for a literal date operand", + ), +] + +# Property [Argument Forms]: the document form requires exactly a date field, and the +# operand-array form accepts only a single element. +MONTH_ARGUMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_date", + expression={"$month": {"timezone": "UTC"}}, + error_code=ISO_DATE_MISSING_DATE_ERROR, + msg="$month should error when the document form omits the date field", + ), + ExpressionTestCase( + "extra_field", + expression={ + "$month": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "extra": 1, + } + }, + error_code=ISO_DATE_UNKNOWN_FIELD_ERROR, + msg="$month should error for an unknown field in the document form", + ), + ExpressionTestCase( + "empty_array", + expression={"$month": []}, + error_code=ISO_DATE_INVALID_ARRAY_INPUT_ERROR, + msg="$month should error for an empty operand array", + ), + ExpressionTestCase( + "two_element_array", + expression={ + "$month": [ + datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + ] + }, + error_code=ISO_DATE_INVALID_ARRAY_INPUT_ERROR, + msg="$month should error for a two-element operand array", + ), + ExpressionTestCase( + "single_element_array", + expression={"$month": [datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)]}, + expected=6, + msg="$month should accept a single-element operand array as the date", + ), + ExpressionTestCase( + "object_expression_input", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$month": {"a": "$date"}}, + error_code=ISO_DATE_UNKNOWN_FIELD_ERROR, + msg="$month should treat an object with an unknown key as an invalid document form", + ), +] + +# Property [Field-Path Resolution]: the operator accepts a resolved field reference; a path +# that resolves to an array (array-index or array-of-objects) feeds the type contract and errors. +MONTH_FIELD_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_path", + doc={"a": {"b": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}}, + expression={"$month": "$a.b"}, + expected=6, + msg="$month should accept a date resolved from a nested field path", + ), + ExpressionTestCase( + "array_index_path", + doc={ + "a": [ + {"b": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + {"b": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc)}, + ] + }, + expression={"$month": "$a.0.b"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$month should error when an array-index path resolves to an array", + ), + ExpressionTestCase( + "composite_array_path", + doc={ + "a": [ + {"b": datetime(2024, 6, 15, 0, 0, 0, tzinfo=timezone.utc)}, + {"b": datetime(2024, 7, 15, 0, 0, 0, tzinfo=timezone.utc)}, + ] + }, + expression={"$month": "$a.b"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$month should error when a path over an array of objects resolves to an array", + ), + ExpressionTestCase( + "timezone_from_field", + doc={"date": datetime(2024, 1, 1, 3, 0, 0, tzinfo=timezone.utc), "tz": "America/New_York"}, + expression={"$month": {"date": "$date", "timezone": "$tz"}}, + expected=12, + msg="$month should apply a timezone resolved from a field reference", + ), + ExpressionTestCase( + "missing_tz_field_ref", + doc={"date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$month": {"date": "$date", "timezone": "$tz"}}, + expected=None, + msg="$month should return null when the timezone field reference is missing", + ), + ExpressionTestCase( + "expression_as_input", + expression={"$month": {"$dateFromString": {"dateString": "2024-06-15T00:00:00Z"}}}, + expected=6, + msg="$month should accept the result of a nested expression as the date", + ), +] + +# Property [Return Type]: $month returns a value of BSON type int. +MONTH_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$type": {"$month": "$date"}}, + expected="int", + msg="$month should return an int", + ), +] + +MONTH_EXPRESSION_TESTS: list[ExpressionTestCase] = ( + MONTH_LITERAL_TESTS + MONTH_ARGUMENT_TESTS + MONTH_FIELD_PATH_TESTS + MONTH_RETURN_TYPE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(MONTH_EXPRESSION_TESTS)) +def test_month_expressions(collection, test_case: ExpressionTestCase): + """Test $month argument forms, field-path resolution, expression input, 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/month/test_month_null_and_type_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/month/test_month_null_and_type_errors.py new file mode 100644 index 000000000..221709961 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/month/test_month_null_and_type_errors.py @@ -0,0 +1,211 @@ +"""Tests for $month null propagation and non-date type rejection.""" + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, Regex + +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 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, + FLOAT_NEGATIVE_INFINITY, + MISSING, +) + +# Property [Null Propagation]: a null or missing date resolves to null rather than an error. +MONTH_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_date", + doc={"date": None}, + expression={"$month": "$date"}, + expected=None, + msg="$month should return null for a null date", + ), + ExpressionTestCase( + "missing_date", + expression={"$month": MISSING}, + expected=None, + msg="$month should return null when the date references a missing field", + ), +] + +# Property [Type Rejection]: any non-date input type is rejected with a type-mismatch error. +MONTH_TYPE_REJECTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_date", + doc={"date": "not-a-date"}, + expression={"$month": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$month should reject a string as the date input", + ), + ExpressionTestCase( + "integer_date", + doc={"date": 42}, + expression={"$month": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$month should reject an int as the date input", + ), + ExpressionTestCase( + "int64_date", + doc={"date": Int64(42)}, + expression={"$month": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$month should reject an int64 as the date input", + ), + ExpressionTestCase( + "double_date", + doc={"date": 3.14}, + expression={"$month": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$month should reject a double as the date input", + ), + ExpressionTestCase( + "decimal128_date", + doc={"date": Decimal128("42")}, + expression={"$month": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$month should reject a decimal128 as the date input", + ), + ExpressionTestCase( + "boolean_date", + doc={"date": True}, + expression={"$month": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$month should reject a boolean as the date input", + ), + ExpressionTestCase( + "array_date", + doc={"date": [1, 2, 3]}, + expression={"$month": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$month should reject an array as the date input", + ), + ExpressionTestCase( + "object_date", + doc={"date": {"a": 1}}, + expression={"$month": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$month should reject an object as the date input", + ), + ExpressionTestCase( + "empty_string_date", + doc={"date": ""}, + expression={"$month": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$month should reject an empty string as the date input", + ), + ExpressionTestCase( + "empty_array_date", + doc={"date": []}, + expression={"$month": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$month should reject an empty array as the date input", + ), + ExpressionTestCase( + "empty_object_date", + doc={"date": {}}, + expression={"$month": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$month should reject an empty object as the date input", + ), + ExpressionTestCase( + "float_nan_date", + doc={"date": FLOAT_NAN}, + expression={"$month": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$month should reject a float NaN as the date input", + ), + ExpressionTestCase( + "decimal128_nan_date", + doc={"date": DECIMAL128_NAN}, + expression={"$month": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$month should reject a decimal128 NaN as the date input", + ), + ExpressionTestCase( + "regex_date", + doc={"date": Regex(".*")}, + expression={"$month": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$month should reject a regex as the date input", + ), + ExpressionTestCase( + "minkey_date", + doc={"date": MinKey()}, + expression={"$month": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$month should reject MinKey as the date input", + ), + ExpressionTestCase( + "maxkey_date", + doc={"date": MaxKey()}, + expression={"$month": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$month should reject MaxKey as the date input", + ), + ExpressionTestCase( + "float_inf_date", + doc={"date": FLOAT_INFINITY}, + expression={"$month": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$month should reject a float infinity as the date input", + ), + ExpressionTestCase( + "float_neg_inf_date", + doc={"date": FLOAT_NEGATIVE_INFINITY}, + expression={"$month": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$month should reject a float negative infinity as the date input", + ), + ExpressionTestCase( + "decimal128_inf_date", + doc={"date": DECIMAL128_INFINITY}, + expression={"$month": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$month should reject a decimal128 infinity as the date input", + ), + ExpressionTestCase( + "decimal128_neg_inf_date", + doc={"date": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$month": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$month should reject a decimal128 negative infinity as the date input", + ), + ExpressionTestCase( + "bindata_date", + doc={"date": Binary(b"")}, + expression={"$month": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$month should reject binary data as the date input", + ), + ExpressionTestCase( + "javascript_date", + doc={"date": Code("function(){}")}, + expression={"$month": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$month should reject JavaScript code as the date input", + ), +] + +MONTH_NULL_AND_TYPE_ERROR_TESTS: list[ExpressionTestCase] = ( + MONTH_NULL_TESTS + MONTH_TYPE_REJECTION_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(MONTH_NULL_AND_TYPE_ERROR_TESTS)) +def test_month_null_and_type_errors(collection, test_case: ExpressionTestCase): + """Test $month null propagation and non-date type rejection.""" + 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/month/test_month_timezone_input_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/month/test_month_timezone_input_types.py new file mode 100644 index 000000000..335fdcc47 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/month/test_month_timezone_input_types.py @@ -0,0 +1,206 @@ +"""Tests for $month timezone application when the date is a Timestamp or ObjectId.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( + oid_from_args, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Timestamp Input with Zones]: a Timestamp input honours the zone offset. +MONTH_TIMESTAMP_ZONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_olson_ts_utc_no_cross", + expression={"$month": {"date": ts_from_args(2024, 7, 15, 12, 0, 0), "timezone": "UTC"}}, + expected=7, + msg="$month should return 7 for a Timestamp in UTC with no crossing", + ), + ExpressionTestCase( + "tz_olson_ts_tokyo_fwd", + expression={ + "$month": {"date": ts_from_args(2024, 6, 30, 22, 0, 0), "timezone": "Asia/Tokyo"} + }, + expected=7, + msg="$month should cross forward to July for a Timestamp in Asia/Tokyo", + ), + ExpressionTestCase( + "tz_olson_ts_la_bwd", + expression={ + "$month": { + "date": ts_from_args(2024, 7, 1, 3, 0, 0), + "timezone": "America/Los_Angeles", + } + }, + expected=6, + msg="$month should cross backward to June for a Timestamp in America/Los_Angeles", + ), + ExpressionTestCase( + "tz_olson_ts_kolkata_fwd", + expression={ + "$month": {"date": ts_from_args(2024, 3, 31, 20, 0, 0), "timezone": "Asia/Kolkata"} + }, + expected=4, + msg="$month should cross forward to April for a Timestamp in Asia/Kolkata", + ), + ExpressionTestCase( + "tz_olson_ts_ny_year_bwd", + expression={ + "$month": { + "date": ts_from_args(2024, 1, 1, 3, 0, 0), + "timezone": "America/New_York", + } + }, + expected=12, + msg="$month should cross the year boundary backward for a Timestamp in New York", + ), + ExpressionTestCase( + "tz_olson_ts_helsinki_year_fwd", + expression={ + "$month": { + "date": ts_from_args(2024, 12, 31, 23, 0, 0), + "timezone": "Europe/Helsinki", + } + }, + expected=1, + msg="$month should cross the year boundary forward for a Timestamp in Europe/Helsinki", + ), + ExpressionTestCase( + "tz_offset_ts_plus9_fwd", + expression={"$month": {"date": ts_from_args(2024, 6, 30, 22, 0, 0), "timezone": "+09:00"}}, + expected=7, + msg="$month should cross forward to July for a Timestamp at a +09:00 offset", + ), + ExpressionTestCase( + "tz_offset_ts_minus8_bwd", + expression={"$month": {"date": ts_from_args(2024, 7, 1, 3, 0, 0), "timezone": "-08:00"}}, + expected=6, + msg="$month should cross backward to June for a Timestamp at a -08:00 offset", + ), + ExpressionTestCase( + "tz_offset_ts_plus530_fwd", + expression={"$month": {"date": ts_from_args(2024, 3, 31, 20, 0, 0), "timezone": "+05:30"}}, + expected=4, + msg="$month should cross forward to April for a Timestamp at a +05:30 offset", + ), + ExpressionTestCase( + "tz_offset_ts_plus545_fwd", + expression={"$month": {"date": ts_from_args(2024, 8, 31, 23, 0, 0), "timezone": "+05:45"}}, + expected=9, + msg="$month should cross forward to September for a Timestamp at a +05:45 offset", + ), + ExpressionTestCase( + "tz_offset_ts_plus3_leap_fwd", + expression={"$month": {"date": ts_from_args(2024, 2, 29, 23, 30, 0), "timezone": "+03:00"}}, + expected=3, + msg="$month should cross the leap-day boundary forward for a Timestamp at +03:00", + ), +] + +# Property [ObjectId Input with Zones]: an ObjectId input honours the zone offset. +MONTH_OBJECTID_ZONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_olson_oid_utc_no_cross", + expression={"$month": {"date": oid_from_args(2024, 7, 15, 12, 0, 0), "timezone": "UTC"}}, + expected=7, + msg="$month should return 7 for an ObjectId in UTC with no crossing", + ), + ExpressionTestCase( + "tz_olson_oid_tokyo_fwd", + expression={ + "$month": {"date": oid_from_args(2024, 6, 30, 22, 0, 0), "timezone": "Asia/Tokyo"} + }, + expected=7, + msg="$month should cross forward to July for an ObjectId in Asia/Tokyo", + ), + ExpressionTestCase( + "tz_olson_oid_la_bwd", + expression={ + "$month": { + "date": oid_from_args(2024, 7, 1, 3, 0, 0), + "timezone": "America/Los_Angeles", + } + }, + expected=6, + msg="$month should cross backward to June for an ObjectId in America/Los_Angeles", + ), + ExpressionTestCase( + "tz_olson_oid_kolkata_fwd", + expression={ + "$month": { + "date": oid_from_args(2024, 3, 31, 20, 0, 0), + "timezone": "Asia/Kolkata", + } + }, + expected=4, + msg="$month should cross forward to April for an ObjectId in Asia/Kolkata", + ), + ExpressionTestCase( + "tz_olson_oid_ny_year_bwd", + expression={ + "$month": { + "date": oid_from_args(2024, 1, 1, 3, 0, 0), + "timezone": "America/New_York", + } + }, + expected=12, + msg="$month should cross the year boundary backward for an ObjectId in New York", + ), + ExpressionTestCase( + "tz_olson_oid_helsinki_year_fwd", + expression={ + "$month": { + "date": oid_from_args(2024, 12, 31, 23, 0, 0), + "timezone": "Europe/Helsinki", + } + }, + expected=1, + msg="$month should cross the year boundary forward for an ObjectId in Europe/Helsinki", + ), + ExpressionTestCase( + "tz_offset_oid_plus9_fwd", + expression={"$month": {"date": oid_from_args(2024, 6, 30, 22, 0, 0), "timezone": "+09:00"}}, + expected=7, + msg="$month should cross forward to July for an ObjectId at a +09:00 offset", + ), + ExpressionTestCase( + "tz_offset_oid_minus8_bwd", + expression={"$month": {"date": oid_from_args(2024, 7, 1, 3, 0, 0), "timezone": "-08:00"}}, + expected=6, + msg="$month should cross backward to June for an ObjectId at a -08:00 offset", + ), + ExpressionTestCase( + "tz_offset_oid_plus530_fwd", + expression={"$month": {"date": oid_from_args(2024, 3, 31, 20, 0, 0), "timezone": "+05:30"}}, + expected=4, + msg="$month should cross forward to April for an ObjectId at a +05:30 offset", + ), + ExpressionTestCase( + "tz_offset_oid_plus3_leap_fwd", + expression={ + "$month": {"date": oid_from_args(2024, 2, 29, 23, 30, 0), "timezone": "+03:00"} + }, + expected=3, + msg="$month should cross the leap-day boundary forward for an ObjectId at +03:00", + ), +] + +MONTH_TIMEZONE_INPUT_TYPES_TESTS: list[ExpressionTestCase] = ( + MONTH_TIMESTAMP_ZONE_TESTS + MONTH_OBJECTID_ZONE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(MONTH_TIMEZONE_INPUT_TYPES_TESTS)) +def test_month_timezone_input_types(collection, test_case: ExpressionTestCase): + """Test $month timezone application for Timestamp and 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/month/test_month_timezone_names.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/month/test_month_timezone_names.py new file mode 100644 index 000000000..7e3ccf6ae --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/month/test_month_timezone_names.py @@ -0,0 +1,248 @@ +"""Tests for $month named-timezone application, including DST and abbreviations.""" + +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]: a named zone or abbreviation shifts the instant before the month is +# taken, which may cross a month, year, or leap boundary depending on the offset and DST. +MONTH_OLSON_DATETIME_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_olson_dt_utc_no_cross", + expression={ + "$month": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "UTC", + } + }, + expected=7, + msg="$month should return 7 for UTC with no month crossing", + ), + ExpressionTestCase( + "tz_olson_dt_ny_no_cross", + expression={ + "$month": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=7, + msg="$month should return 7 for America/New_York with no month crossing", + ), + ExpressionTestCase( + "tz_olson_dt_tokyo_no_cross", + expression={ + "$month": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "Asia/Tokyo", + } + }, + expected=7, + msg="$month should return 7 for Asia/Tokyo with no month crossing", + ), + ExpressionTestCase( + "tz_olson_dt_tokyo_fwd", + expression={ + "$month": { + "date": datetime(2024, 6, 30, 22, 0, 0, tzinfo=timezone.utc), + "timezone": "Asia/Tokyo", + } + }, + expected=7, + msg="$month should cross forward to July for Asia/Tokyo (+09:00)", + ), + ExpressionTestCase( + "tz_olson_dt_la_bwd", + expression={ + "$month": { + "date": datetime(2024, 7, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "America/Los_Angeles", + } + }, + expected=6, + msg="$month should cross backward to June for America/Los_Angeles (PDT)", + ), + ExpressionTestCase( + "tz_olson_dt_denver_bwd", + expression={ + "$month": { + "date": datetime(2024, 11, 1, 5, 0, 0, tzinfo=timezone.utc), + "timezone": "America/Denver", + } + }, + expected=10, + msg="$month should cross backward to October for America/Denver (MDT)", + ), + ExpressionTestCase( + "tz_olson_dt_kolkata_fwd", + expression={ + "$month": { + "date": datetime(2024, 3, 31, 20, 0, 0, tzinfo=timezone.utc), + "timezone": "Asia/Kolkata", + } + }, + expected=4, + msg="$month should cross forward to April for Asia/Kolkata (+05:30)", + ), + ExpressionTestCase( + "tz_olson_dt_kathmandu_fwd", + expression={ + "$month": { + "date": datetime(2024, 8, 31, 23, 0, 0, tzinfo=timezone.utc), + "timezone": "Asia/Kathmandu", + } + }, + expected=9, + msg="$month should cross forward to September for Asia/Kathmandu (+05:45)", + ), + ExpressionTestCase( + "tz_olson_dt_kolkata_sep_fwd", + expression={ + "$month": { + "date": datetime(2024, 9, 30, 22, 30, 0, tzinfo=timezone.utc), + "timezone": "Asia/Kolkata", + } + }, + expected=10, + msg="$month should cross forward to October for Asia/Kolkata at a September boundary", + ), + ExpressionTestCase( + "tz_olson_dt_ny_year_bwd", + expression={ + "$month": { + "date": datetime(2024, 1, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=12, + msg="$month should cross the year boundary backward to December for America/New_York", + ), + ExpressionTestCase( + "tz_olson_dt_ny_year_stays", + expression={ + "$month": { + "date": datetime(2024, 1, 1, 6, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=1, + msg="$month should stay in January for America/New_York after the year boundary", + ), + ExpressionTestCase( + "tz_olson_dt_helsinki_year_fwd", + expression={ + "$month": { + "date": datetime(2024, 12, 31, 23, 0, 0, tzinfo=timezone.utc), + "timezone": "Europe/Helsinki", + } + }, + expected=1, + msg="$month should cross the year boundary forward to January for Europe/Helsinki", + ), + ExpressionTestCase( + "tz_olson_dt_moscow_leap_fwd", + expression={ + "$month": { + "date": datetime(2024, 2, 29, 23, 30, 0, tzinfo=timezone.utc), + "timezone": "Europe/Moscow", + } + }, + expected=3, + msg="$month should cross the leap-day boundary forward to March for Europe/Moscow", + ), + ExpressionTestCase( + "tz_olson_dt_ny_leap_bwd", + expression={ + "$month": { + "date": datetime(2024, 3, 1, 2, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=2, + msg="$month should cross backward into February for America/New_York", + ), + ExpressionTestCase( + "tz_olson_dt_ny_dst_spring", + expression={ + "$month": { + "date": datetime(2024, 3, 10, 7, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=3, + msg="$month should return 3 across the spring DST transition in America/New_York", + ), + ExpressionTestCase( + "tz_olson_dt_ny_dst_fall", + expression={ + "$month": { + "date": datetime(2024, 11, 3, 6, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=11, + msg="$month should return 11 across the fall DST transition in America/New_York", + ), + ExpressionTestCase( + "tz_olson_dt_london_bst_start", + expression={ + "$month": { + "date": datetime(2024, 3, 31, 0, 30, 0, tzinfo=timezone.utc), + "timezone": "Europe/London", + } + }, + expected=3, + msg="$month should return 3 at the Europe/London BST start", + ), + ExpressionTestCase( + "tz_olson_dt_london_bst", + expression={ + "$month": { + "date": datetime(2020, 6, 30, 23, 30, 0, tzinfo=timezone.utc), + "timezone": "Europe/London", + } + }, + expected=7, + msg="$month should cross forward to July for Europe/London in summer (BST)", + ), + ExpressionTestCase( + "tz_olson_dt_pacific_apia", + expression={ + "$month": { + "date": datetime(2024, 6, 30, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "Pacific/Apia", + } + }, + expected=7, + msg="$month should cross forward to July for Pacific/Apia (+13:00)", + ), + ExpressionTestCase( + "tz_est_abbreviation", + expression={ + "$month": { + "date": datetime(2024, 7, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "EST", + } + }, + expected=6, + msg="$month should accept the EST three-letter abbreviation", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(MONTH_OLSON_DATETIME_TESTS)) +def test_month_timezone_names(collection, test_case: ExpressionTestCase): + """Test $month named-timezone application across zones, DST, and abbreviations.""" + 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/month/test_month_timezone_offsets.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/month/test_month_timezone_offsets.py new file mode 100644 index 000000000..c109737b0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/month/test_month_timezone_offsets.py @@ -0,0 +1,259 @@ +"""Tests for $month UTC-offset timezone application, including edge and unusual offsets.""" + +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 [UTC Offsets, datetime]: an explicit +HH:MM/-HH:MM offset shifts the instant, +# including half/quarter-hour, extreme, and out-of-range offsets the server still accepts. +MONTH_OFFSET_DATETIME_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_offset_dt_plus0_no_cross", + expression={ + "$month": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "+00:00", + } + }, + expected=7, + msg="$month should return 7 for a +00:00 offset with no crossing", + ), + ExpressionTestCase( + "tz_offset_dt_minus5_no_cross", + expression={ + "$month": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "-05:00", + } + }, + expected=7, + msg="$month should return 7 for a -05:00 offset with no crossing", + ), + ExpressionTestCase( + "tz_offset_dt_plus9_fwd", + expression={ + "$month": { + "date": datetime(2024, 6, 30, 22, 0, 0, tzinfo=timezone.utc), + "timezone": "+09:00", + } + }, + expected=7, + msg="$month should cross forward to July for a +09:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_minus8_bwd", + expression={ + "$month": { + "date": datetime(2024, 7, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "-08:00", + } + }, + expected=6, + msg="$month should cross backward to June for a -08:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus530_fwd", + expression={ + "$month": { + "date": datetime(2024, 3, 31, 20, 0, 0, tzinfo=timezone.utc), + "timezone": "+05:30", + } + }, + expected=4, + msg="$month should cross forward to April for a +05:30 half-hour offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus545_fwd", + expression={ + "$month": { + "date": datetime(2024, 8, 31, 23, 0, 0, tzinfo=timezone.utc), + "timezone": "+05:45", + } + }, + expected=9, + msg="$month should cross forward to September for a +05:45 quarter-hour offset", + ), + ExpressionTestCase( + "tz_offset_dt_minus5_year_bwd", + expression={ + "$month": { + "date": datetime(2024, 1, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "-05:00", + } + }, + expected=12, + msg="$month should cross the year boundary backward to December for a -05:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus2_year_fwd", + expression={ + "$month": { + "date": datetime(2024, 12, 31, 23, 0, 0, tzinfo=timezone.utc), + "timezone": "+02:00", + } + }, + expected=1, + msg="$month should cross the year boundary forward to January for a +02:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus3_leap_fwd", + expression={ + "$month": { + "date": datetime(2024, 2, 29, 23, 30, 0, tzinfo=timezone.utc), + "timezone": "+03:00", + } + }, + expected=3, + msg="$month should cross the leap-day boundary forward to March for a +03:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_minus5_leap_bwd", + expression={ + "$month": { + "date": datetime(2024, 3, 1, 2, 0, 0, tzinfo=timezone.utc), + "timezone": "-05:00", + } + }, + expected=2, + msg="$month should cross backward into February for a -05:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus14_fwd", + expression={ + "$month": { + "date": datetime(2024, 6, 30, 11, 0, 0, tzinfo=timezone.utc), + "timezone": "+14:00", + } + }, + expected=7, + msg="$month should cross forward to July for the extreme +14:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_minus12_bwd", + expression={ + "$month": { + "date": datetime(2024, 7, 1, 11, 0, 0, tzinfo=timezone.utc), + "timezone": "-12:00", + } + }, + expected=6, + msg="$month should cross backward to June for the extreme -12:00 offset", + ), + ExpressionTestCase( + "tz_offset_no_colon", + expression={ + "$month": { + "date": datetime(2024, 7, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "-0530", + } + }, + expected=6, + msg="$month should accept a compact -0530 offset without a colon", + ), + ExpressionTestCase( + "tz_offset_hour_only", + expression={ + "$month": { + "date": datetime(2024, 7, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "-08", + } + }, + expected=6, + msg="$month should accept an hour-only -08 offset", + ), + ExpressionTestCase( + "tz_offset_minus13", + expression={ + "$month": { + "date": datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "-13:00", + } + }, + expected=12, + msg="$month should accept a -13:00 offset", + ), + ExpressionTestCase( + "tz_offset_plus15", + expression={ + "$month": { + "date": datetime(2024, 6, 30, 10, 0, 0, tzinfo=timezone.utc), + "timezone": "+15:00", + } + }, + expected=7, + msg="$month should accept a +15:00 offset", + ), + ExpressionTestCase( + "tz_offset_minus0330", + expression={ + "$month": { + "date": datetime(2024, 7, 1, 2, 0, 0, tzinfo=timezone.utc), + "timezone": "-03:30", + } + }, + expected=6, + msg="$month should accept a -03:30 half-hour west offset", + ), + ExpressionTestCase( + "tz_offset_plus0570", + expression={ + "$month": { + "date": datetime(2024, 6, 30, 17, 0, 0, tzinfo=timezone.utc), + "timezone": "+05:70", + } + }, + expected=6, + msg="$month should accept a +05:70 (70-minute) offset", + ), + ExpressionTestCase( + "tz_offset_minus0570", + expression={ + "$month": { + "date": datetime(2024, 7, 1, 5, 0, 0, tzinfo=timezone.utc), + "timezone": "-05:70", + } + }, + expected=6, + msg="$month should accept a -05:70 (70-minute) offset", + ), + ExpressionTestCase( + "tz_offset_plus2500", + expression={ + "$month": { + "date": datetime(2024, 6, 29, 0, 0, 0, tzinfo=timezone.utc), + "timezone": "+25:00", + } + }, + expected=6, + msg="$month should accept a +25:00 (25-hour) offset", + ), + ExpressionTestCase( + "tz_offset_minus2500", + expression={ + "$month": { + "date": datetime(2024, 7, 2, 0, 0, 0, tzinfo=timezone.utc), + "timezone": "-25:00", + } + }, + expected=6, + msg="$month should accept a -25:00 (25-hour) offset", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(MONTH_OFFSET_DATETIME_TESTS)) +def test_month_timezone_offsets(collection, test_case: ExpressionTestCase): + """Test $month UTC-offset timezone application, including edge and unusual offsets.""" + 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/month/test_month_timezone_validation.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/month/test_month_timezone_validation.py new file mode 100644 index 000000000..ea13daa9b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/month/test_month_timezone_validation.py @@ -0,0 +1,151 @@ +"""Tests for $month timezone validation and null-timezone propagation.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + INVALID_TIMEZONE_ERROR, + INVALID_TIMEZONE_TYPE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Timezone Validation]: unparseable zone strings, wrong-typed timezones, and +# null timezones are rejected or propagate null. +MONTH_TIMEZONE_VALIDATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "invalid_tz_olson", + expression={ + "$month": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "Not/A_Timezone", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$month should reject an unparseable Olson-like timezone string", + ), + ExpressionTestCase( + "invalid_tz_nonexistent_olson", + expression={ + "$month": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "America/Nowhere", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$month should reject a non-existent Olson timezone", + ), + ExpressionTestCase( + "invalid_tz_offset_format", + expression={ + "$month": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "25:00", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$month should reject a bare 25:00 offset without a sign", + ), + ExpressionTestCase( + "invalid_tz_numeric", + expression={ + "$month": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "123", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$month should reject a numeric-string timezone", + ), + ExpressionTestCase( + "invalid_tz_empty_string", + expression={ + "$month": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$month should reject an empty-string timezone", + ), + ExpressionTestCase( + "invalid_tz_olson_lowercase", + expression={ + "$month": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "america/new_york", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$month should reject an all-lowercase Olson name", + ), + ExpressionTestCase( + "invalid_tz_olson_uppercase", + expression={ + "$month": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "AMERICA/NEW_YORK", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$month should reject an all-uppercase Olson name", + ), + *[ + ExpressionTestCase( + f"invalid_tz_{tid}", + expression={ + "$month": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": val, + } + }, + error_code=INVALID_TIMEZONE_TYPE_ERROR, + msg=f"$month should reject a {tid} timezone", + ) + for tid, val in [ + ("int", 5), + ("int64", Int64(5)), + ("double", 3.14), + ("decimal128", Decimal128("5")), + ("bool", True), + ("object", {"tz": "UTC"}), + ("array", ["UTC"]), + ("datetime", datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + ExpressionTestCase( + "null_tz", + expression={ + "$month": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": None, + } + }, + expected=None, + msg="$month should return null for a null timezone", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(MONTH_TIMEZONE_VALIDATION_TESTS)) +def test_month_timezone_validation(collection, test_case: ExpressionTestCase): + """Test $month timezone validation and null-timezone 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 + ) From f52c6381ef8a09c39c05cfb40d1e9117c73febcc Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Tue, 14 Jul 2026 12:10:42 -0700 Subject: [PATCH 3/5] Add $week expression tests This change adds tests for the $week expression. It was originally authored by @mitchell-elholm. Closes #323 Co-authored-by: Mitchell Elholm Signed-off-by: Daniel Frankcom --- .../expressions/date/week/__init__.py | 0 .../date/week/test_week_calendar.py | 266 ++++++++++++++++++ .../date/week/test_week_date_types.py | 188 +++++++++++++ .../date/week/test_week_expressions.py | 198 +++++++++++++ .../week/test_week_null_and_type_errors.py | 229 +++++++++++++++ .../week/test_week_timezone_input_types.py | 165 +++++++++++ .../date/week/test_week_timezone_names.py | 127 +++++++++ .../date/week/test_week_timezone_offsets.py | 155 ++++++++++ .../week/test_week_timezone_validation.py | 115 ++++++++ 9 files changed, 1443 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/week/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_calendar.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_date_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_null_and_type_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_timezone_input_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_timezone_names.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_timezone_offsets.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_timezone_validation.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/week/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/week/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_calendar.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_calendar.py new file mode 100644 index 000000000..cc44f96cb --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_calendar.py @@ -0,0 +1,266 @@ +"""Tests for $week Sunday-based week numbering across the calendar, leap years, and years.""" + +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 + +# Property [Week Numbering]: $week numbers weeks from 0, weeks start on Sunday, and days before +# the first Sunday of the year are week 0, so the number increments only as each Sunday is crossed. +WEEK_NUMBERING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "week_0_jan1", + doc={"date": datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": "$date"}, + expected=0, + msg="$week should return 0 for Jan 1 2024, before the first Sunday", + ), + ExpressionTestCase( + "week_0_jan6", + doc={"date": datetime(2024, 1, 6, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$week": "$date"}, + expected=0, + msg="$week should return 0 for the last instant before the first Sunday of 2024", + ), + ExpressionTestCase( + "week_1_jan7", + doc={"date": datetime(2024, 1, 7, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": "$date"}, + expected=1, + msg="$week should return 1 for the first Sunday of 2024", + ), + ExpressionTestCase( + "week_1_jan13", + doc={"date": datetime(2024, 1, 13, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$week": "$date"}, + expected=1, + msg="$week should return 1 for the last instant of the first full week", + ), + ExpressionTestCase( + "week_2_jan14", + doc={"date": datetime(2024, 1, 14, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": "$date"}, + expected=2, + msg="$week should return 2 for the second Sunday of 2024", + ), + ExpressionTestCase( + "week_23_jun15", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": "$date"}, + expected=23, + msg="$week should return 23 for a mid-June 2024 date", + ), + ExpressionTestCase( + "week_26_jun30", + doc={"date": datetime(2024, 6, 30, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": "$date"}, + expected=26, + msg="$week should return 26 for the last day of June 2024", + ), + ExpressionTestCase( + "week_26_jul1", + doc={"date": datetime(2024, 7, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": "$date"}, + expected=26, + msg="$week should return 26 for the first day of July 2024, still in the same week", + ), + ExpressionTestCase( + "week_52_dec31", + doc={"date": datetime(2024, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$week": "$date"}, + expected=52, + msg="$week should return 52 for the last instant of 2024", + ), + ExpressionTestCase( + "week_1_2023_jan1", + doc={"date": datetime(2023, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": "$date"}, + expected=1, + msg="$week should return 1 for Jan 1 2023, which is itself a Sunday", + ), + ExpressionTestCase( + "week_53_2023_dec31", + doc={"date": datetime(2023, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$week": "$date"}, + expected=53, + msg="$week should return 53 for the last instant of 2023, which has a week 53", + ), +] + +# Property [Leap Years]: leap and non-leap February dates map to the correct Sunday-based week, +# including century leap-year rules. +WEEK_LEAP_YEAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "leap_year_feb_29_2024", + doc={"date": datetime(2024, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": "$date"}, + expected=8, + msg="$week should return 8 for Feb 29 in the 2024 leap year", + ), + ExpressionTestCase( + "non_leap_year_feb_28_2023", + doc={"date": datetime(2023, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": "$date"}, + expected=9, + msg="$week should return 9 for Feb 28 in the non-leap 2023 year", + ), + ExpressionTestCase( + "leap_year_feb_29_2020", + doc={"date": datetime(2020, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": "$date"}, + expected=8, + msg="$week should return 8 for Feb 29 in the 2020 leap year", + ), + ExpressionTestCase( + "leap_year_feb_29_2000", + doc={"date": datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": "$date"}, + expected=9, + msg="$week should return 9 for Feb 29 in the 2000 century leap year", + ), + ExpressionTestCase( + "non_leap_century_1900_feb_28", + doc={"date": datetime(1900, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": "$date"}, + expected=8, + msg="$week should return 8 for Feb 28 1900, a non-leap century year", + ), +] + +# Property [Sub-Second Precision]: the week number is determined by the instant, so a millisecond +# on either side of a Sunday or a year boundary lands in the expected week. +WEEK_MILLISECOND_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "millisecond_before_sunday", + doc={"date": datetime(2024, 1, 6, 23, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={"$week": "$date"}, + expected=0, + msg="$week should return 0 one millisecond before the first Sunday of 2024", + ), + ExpressionTestCase( + "millisecond_after_sunday_start", + doc={"date": datetime(2024, 1, 7, 0, 0, 0, 1000, tzinfo=timezone.utc)}, + expression={"$week": "$date"}, + expected=1, + msg="$week should return 1 one millisecond after the first Sunday begins", + ), + ExpressionTestCase( + "millisecond_mid_week", + doc={"date": datetime(2024, 6, 15, 12, 30, 30, 500000, tzinfo=timezone.utc)}, + expression={"$week": "$date"}, + expected=23, + msg="$week should return 23 for a mid-week instant with sub-second precision", + ), + ExpressionTestCase( + "millisecond_year_boundary", + doc={"date": datetime(2024, 12, 31, 23, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={"$week": "$date"}, + expected=52, + msg="$week should return 52 one millisecond before the end of 2024", + ), +] + +# Property [Year Range]: $week returns the correct Sunday-based week across the representable +# calendar year range, including the epoch and the maximum year. +WEEK_YEAR_RANGE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "year_2000", + doc={"date": datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": "$date"}, + expected=0, + msg="$week should return 0 for Jan 1 2000, before the first Sunday", + ), + ExpressionTestCase( + "year_1970_epoch", + doc={"date": DATE_EPOCH}, + expression={"$week": "$date"}, + expected=0, + msg="$week should return 0 for the Unix epoch", + ), + ExpressionTestCase( + "year_1999", + doc={"date": datetime(1999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$week": "$date"}, + expected=52, + msg="$week should return 52 for the last instant of 1999", + ), + ExpressionTestCase( + "year_2099", + doc={"date": datetime(2099, 7, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": "$date"}, + expected=28, + msg="$week should return 28 for a mid-2099 date", + ), + ExpressionTestCase( + "year_9999", + doc={"date": datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$week": "$date"}, + expected=52, + msg="$week should return 52 for the last instant of the maximum year", + ), +] + +# Property [Pre-Epoch]: dates before the Unix epoch resolve to the correct Sunday-based week. +WEEK_PRE_EPOCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "pre_epoch_1960", + doc={"date": datetime(1960, 3, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": "$date"}, + expected=11, + msg="$week should return 11 for a mid-March 1960 date", + ), + ExpressionTestCase( + "pre_epoch_1900", + doc={"date": datetime(1900, 7, 4, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": "$date"}, + expected=26, + msg="$week should return 26 for Jul 4 1900", + ), + ExpressionTestCase( + "pre_epoch_1969_dec", + doc={"date": datetime(1969, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$week": "$date"}, + expected=52, + msg="$week should return 52 for the last instant before the epoch year", + ), + ExpressionTestCase( + "pre_epoch_1969_jan", + doc={"date": datetime(1969, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": "$date"}, + expected=0, + msg="$week should return 0 for Jan 1 1969, before the first Sunday", + ), + ExpressionTestCase( + "pre_epoch_1952_leap", + doc={"date": datetime(1952, 2, 29, 6, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": "$date"}, + expected=8, + msg="$week should return 8 for Feb 29 1952, a pre-epoch leap year", + ), +] + +WEEK_CALENDAR_TESTS: list[ExpressionTestCase] = ( + WEEK_NUMBERING_TESTS + + WEEK_LEAP_YEAR_TESTS + + WEEK_MILLISECOND_TESTS + + WEEK_YEAR_RANGE_TESTS + + WEEK_PRE_EPOCH_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(WEEK_CALENDAR_TESTS)) +def test_week_calendar(collection, test_case: ExpressionTestCase): + """Test $week Sunday-based week numbering across the calendar, leap years, and years.""" + result = execute_expression_with_insert(collection, test_case.expression, test_case.doc) + assert_expression_result( + result, expected=test_case.expected, error_code=test_case.error_code, msg=test_case.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_date_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_date_types.py new file mode 100644 index 000000000..499079203 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_date_types.py @@ -0,0 +1,188 @@ +"""Tests for $week with Timestamp, ObjectId, and extended-range date 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, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_MS_BEFORE_EPOCH, + DATE_MS_EPOCH, + DATE_MS_MAX, + DATE_MS_MIN, + DATE_MS_YEAR_10000, + OID_MAX_SIGNED32, + OID_MAX_UNSIGNED32, + OID_MIN_SIGNED32, + TS_MAX_SIGNED32, + TS_MAX_UNSIGNED32, +) + +# Property [Timestamp Input]: a BSON Timestamp is accepted as a date and yields its Sunday-based +# week number. +WEEK_TIMESTAMP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "timestamp_week_0", + doc={"date": ts_from_args(2024, 1, 1, 0, 0, 0)}, + expression={"$week": "$date"}, + expected=0, + msg="$week should return 0 for a Timestamp on Jan 1 2024", + ), + ExpressionTestCase( + "timestamp_week_23", + doc={"date": ts_from_args(2024, 6, 15, 0, 0, 0)}, + expression={"$week": "$date"}, + expected=23, + msg="$week should return 23 for a Timestamp in mid-June 2024", + ), + ExpressionTestCase( + "timestamp_week_52", + doc={"date": ts_from_args(2024, 12, 31, 0, 0, 0)}, + expression={"$week": "$date"}, + expected=52, + msg="$week should return 52 for a Timestamp on Dec 31 2024", + ), + ExpressionTestCase( + "timestamp_zero_increment", + doc={"date": ts_from_args(2024, 6, 15, 0, 0, 0, inc=0)}, + expression={"$week": "$date"}, + expected=23, + msg="$week should return 23 for a Timestamp with a zero increment", + ), + ExpressionTestCase( + "timestamp_epoch", + doc={"date": ts_from_args(1970, 1, 1, 0, 0, 0)}, + expression={"$week": "$date"}, + expected=0, + msg="$week should return 0 for a Timestamp at the epoch", + ), +] + +# Property [ObjectId Input]: an ObjectId is accepted as a date via its embedded timestamp. +WEEK_OBJECTID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "objectid_week_0", + doc={"date": oid_from_args(2024, 1, 1, 0, 0, 0)}, + expression={"$week": "$date"}, + expected=0, + msg="$week should return 0 for an ObjectId on Jan 1 2024", + ), + ExpressionTestCase( + "objectid_week_23", + doc={"date": oid_from_args(2024, 6, 15, 0, 0, 0)}, + expression={"$week": "$date"}, + expected=23, + msg="$week should return 23 for an ObjectId in mid-June 2024", + ), + ExpressionTestCase( + "objectid_week_52", + doc={"date": oid_from_args(2024, 12, 31, 0, 0, 0)}, + expression={"$week": "$date"}, + expected=52, + msg="$week should return 52 for an ObjectId on Dec 31 2024", + ), + ExpressionTestCase( + "objectid_epoch", + doc={"date": oid_from_args(1970, 1, 1, 0, 0, 0)}, + expression={"$week": "$date"}, + expected=0, + msg="$week should return 0 for an ObjectId at the epoch", + ), +] + +# Property [Extended Range]: DatetimeMS, Timestamp, and ObjectId boundary instants beyond the +# native datetime range resolve to the correct Sunday-based week. +WEEK_EXTENDED_RANGE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_ms_epoch", + doc={"date": DATE_MS_EPOCH}, + expression={"$week": "$date"}, + expected=0, + msg="$week should return 0 for the epoch as a DatetimeMS", + ), + ExpressionTestCase( + "date_ms_before_epoch", + doc={"date": DATE_MS_BEFORE_EPOCH}, + expression={"$week": "$date"}, + expected=52, + msg="$week should return 52 for a DatetimeMS one millisecond before the epoch", + ), + ExpressionTestCase( + "date_ms_year_10000", + doc={"date": DATE_MS_YEAR_10000}, + expression={"$week": "$date"}, + expected=0, + msg="$week should return 0 for a DatetimeMS at the year-10000 boundary", + ), + ExpressionTestCase( + "date_ms_max", + doc={"date": DATE_MS_MAX}, + expression={"$week": "$date"}, + expected=33, + msg="$week should return the correct week for the maximum 64-bit DatetimeMS", + ), + ExpressionTestCase( + "date_ms_min", + doc={"date": DATE_MS_MIN}, + expression={"$week": "$date"}, + expected=20, + msg="$week should return the correct week for the minimum 64-bit DatetimeMS", + ), + ExpressionTestCase( + "ts_boundary_max_s32", + doc={"date": TS_MAX_SIGNED32}, + expression={"$week": "$date"}, + expected=3, + msg="$week should return 3 for the max signed 32-bit Timestamp", + ), + ExpressionTestCase( + "ts_boundary_max_u32", + doc={"date": TS_MAX_UNSIGNED32}, + expression={"$week": "$date"}, + expected=6, + msg="$week should return 6 for the max unsigned 32-bit Timestamp", + ), + ExpressionTestCase( + "oid_boundary_max_s32", + doc={"date": OID_MAX_SIGNED32}, + expression={"$week": "$date"}, + expected=3, + msg="$week should return 3 for the max signed 32-bit ObjectId", + ), + ExpressionTestCase( + "oid_boundary_min_s32", + doc={"date": OID_MIN_SIGNED32}, + expression={"$week": "$date"}, + expected=49, + msg="$week should return 49 for the min signed 32-bit ObjectId", + ), + ExpressionTestCase( + "oid_boundary_max_u32", + doc={"date": OID_MAX_UNSIGNED32}, + expression={"$week": "$date"}, + expected=52, + msg="$week should return 52 for the max unsigned 32-bit ObjectId read as signed", + ), +] + +WEEK_DATE_TYPES_TESTS: list[ExpressionTestCase] = ( + WEEK_TIMESTAMP_TESTS + WEEK_OBJECTID_TESTS + WEEK_EXTENDED_RANGE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(WEEK_DATE_TYPES_TESTS)) +def test_week_date_types(collection, test_case: ExpressionTestCase): + """Test $week with Timestamp, ObjectId, and extended-range 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/week/test_week_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_expressions.py new file mode 100644 index 000000000..9bad67889 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_expressions.py @@ -0,0 +1,198 @@ +"""Tests for $week argument forms, field-path resolution, expression input, 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 ( + ISO_DATE_INVALID_ARRAY_INPUT_ERROR, + ISO_DATE_MISSING_DATE_ERROR, + ISO_DATE_UNKNOWN_FIELD_ERROR, + TYPE_MISMATCH_DATE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Argument Forms]: the operand may be a date, a single-element operand array, or a +# document with exactly a date field; other operand shapes and scalar non-date operands are +# rejected. +WEEK_ARGUMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "document_form_missing_date", + expression={"$week": {"timezone": "UTC"}}, + error_code=ISO_DATE_MISSING_DATE_ERROR, + msg="$week should error when the document form omits the date field", + ), + ExpressionTestCase( + "extra_field", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "extra": 1}}, + error_code=ISO_DATE_UNKNOWN_FIELD_ERROR, + msg="$week should error for an unknown field in the document form", + ), + ExpressionTestCase( + "object_expression_input", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"a": "$date"}}, + error_code=ISO_DATE_UNKNOWN_FIELD_ERROR, + msg="$week should treat an object with an unknown key as an invalid document form", + ), + ExpressionTestCase( + "empty_array", + expression={"$week": []}, + error_code=ISO_DATE_INVALID_ARRAY_INPUT_ERROR, + msg="$week should error for an empty operand array", + ), + ExpressionTestCase( + "two_element_array", + expression={ + "$week": [ + datetime(2024, 6, 15, tzinfo=timezone.utc), + datetime(2024, 7, 15, tzinfo=timezone.utc), + ] + }, + error_code=ISO_DATE_INVALID_ARRAY_INPUT_ERROR, + msg="$week should error for a multi-element operand array", + ), + ExpressionTestCase( + "single_element_array", + expression={"$week": [datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)]}, + expected=23, + msg="$week should accept a single-element operand array as the date", + ), + ExpressionTestCase( + "invalid_string", + expression={"$week": "string"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$week should reject a literal string operand", + ), + ExpressionTestCase( + "invalid_number", + expression={"$week": 123}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$week should reject a literal number operand", + ), + ExpressionTestCase( + "invalid_boolean", + expression={"$week": True}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$week should reject a literal boolean operand", + ), +] + +# Property [Input Expression Types]: the operator accepts each expression type it can receive +# as the date argument (literal, field reference in shorthand and document form, an array +# expression holding a field reference, a nested field path, and a nested expression); a path +# that resolves to an array feeds the type contract and errors. +WEEK_FIELD_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_date", + expression={"$week": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expected=23, + msg="$week should return the Sunday-based week for a literal date operand", + ), + ExpressionTestCase( + "field_ref_simple", + doc={"d": datetime(2024, 6, 15, tzinfo=timezone.utc)}, + expression={"$week": "$d"}, + expected=23, + msg="$week should accept a date from a shorthand field reference", + ), + ExpressionTestCase( + "field_ref_object", + doc={"d": datetime(2024, 6, 15, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$d"}}, + expected=23, + msg="$week should accept a date field reference in the document form", + ), + ExpressionTestCase( + "array_expression_field_ref", + doc={"d": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": ["$d"]}, + expected=23, + msg="$week should unwrap a single-element array expression with a field reference", + ), + ExpressionTestCase( + "nested_field_path", + doc={"a": {"b": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}}, + expression={"$week": "$a.b"}, + expected=23, + msg="$week should accept a date resolved from a nested field path", + ), + ExpressionTestCase( + "missing_nested_field", + doc={"a": {"x": 1}}, + expression={"$week": "$a.missing"}, + expected=None, + msg="$week should return null when a nested field path is missing", + ), + ExpressionTestCase( + "array_index_path", + doc={"a": [{"b": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}]}, + expression={"$week": "$a.0.b"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$week should error when an array-index path resolves to an array", + ), + ExpressionTestCase( + "composite_array_path", + doc={ + "a": [ + {"b": datetime(2024, 1, 7, 0, 0, 0, tzinfo=timezone.utc)}, + {"b": datetime(2024, 6, 15, 0, 0, 0, tzinfo=timezone.utc)}, + ] + }, + expression={"$week": "$a.b"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$week should error when a path over an array of objects resolves to an array", + ), + ExpressionTestCase( + "timezone_from_field", + doc={"date": datetime(2024, 1, 1, 3, 0, 0, tzinfo=timezone.utc), "tz": "America/New_York"}, + expression={"$week": {"date": "$date", "timezone": "$tz"}}, + expected=53, + msg="$week should apply a timezone resolved from a field reference", + ), + ExpressionTestCase( + "missing_tz_field_ref", + doc={"date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "$tz"}}, + expected=None, + msg="$week should return null when the timezone field reference is missing", + ), + ExpressionTestCase( + "expression_as_input", + expression={"$week": {"$dateFromString": {"dateString": "2024-06-15T00:00:00Z"}}}, + expected=23, + msg="$week should accept the result of a nested expression as the date", + ), +] + +# Property [Return Type]: $week returns a value of BSON type int. +WEEK_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$type": {"$week": "$date"}}, + expected="int", + msg="$week should return an int", + ), +] + +WEEK_EXPRESSION_TESTS: list[ExpressionTestCase] = ( + WEEK_ARGUMENT_TESTS + WEEK_FIELD_PATH_TESTS + WEEK_RETURN_TYPE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(WEEK_EXPRESSION_TESTS)) +def test_week_expressions(collection, test_case: ExpressionTestCase): + """Test $week argument forms, field paths, expression input, 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/week/test_week_null_and_type_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_null_and_type_errors.py new file mode 100644 index 000000000..abcdee3eb --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_null_and_type_errors.py @@ -0,0 +1,229 @@ +"""Tests for $week null propagation and non-date type rejection.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, Regex + +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 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, + FLOAT_NEGATIVE_INFINITY, + MISSING, +) + +# Property [Null Propagation]: a null or missing date resolves to null rather than an error, +# including when an explicit timezone is supplied. +WEEK_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_date", + doc={"date": None}, + expression={"$week": "$date"}, + expected=None, + msg="$week should return null for a null date", + ), + ExpressionTestCase( + "missing_date", + expression={"$week": MISSING}, + expected=None, + msg="$week should return null when the date references a missing field", + ), + ExpressionTestCase( + "null_date_with_timezone", + doc={"date": None}, + expression={"$week": {"date": "$date", "timezone": "UTC"}}, + expected=None, + msg="$week should return null for a null date even when a timezone is supplied", + ), +] + +# Property [Type Rejection]: any non-date input type is rejected with a type-mismatch error, +# including empty containers and non-finite numerics. +WEEK_TYPE_REJECTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_date", + doc={"date": "not-a-date"}, + expression={"$week": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$week should reject a string as the date input", + ), + ExpressionTestCase( + "empty_string_date", + doc={"date": ""}, + expression={"$week": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$week should reject an empty string as the date input", + ), + ExpressionTestCase( + "integer_date", + doc={"date": 42}, + expression={"$week": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$week should reject an int as the date input", + ), + ExpressionTestCase( + "int64_date", + doc={"date": Int64(42)}, + expression={"$week": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$week should reject an int64 as the date input", + ), + ExpressionTestCase( + "double_date", + doc={"date": 3.14}, + expression={"$week": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$week should reject a double as the date input", + ), + ExpressionTestCase( + "decimal128_date", + doc={"date": Decimal128("42")}, + expression={"$week": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$week should reject a decimal128 as the date input", + ), + ExpressionTestCase( + "boolean_date", + doc={"date": True}, + expression={"$week": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$week should reject a boolean as the date input", + ), + ExpressionTestCase( + "array_date", + doc={"date": [1, 2, 3]}, + expression={"$week": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$week should reject an array as the date input", + ), + ExpressionTestCase( + "empty_array_date", + doc={"date": []}, + expression={"$week": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$week should reject an empty array as the date input", + ), + ExpressionTestCase( + "array_containing_date", + doc={"date": [datetime(2024, 1, 15, tzinfo=timezone.utc)]}, + expression={"$week": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$week should reject an array holding a date as the date input", + ), + ExpressionTestCase( + "object_date", + doc={"date": {"a": 1}}, + expression={"$week": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$week should reject an object as the date input", + ), + ExpressionTestCase( + "empty_object_date", + doc={"date": {}}, + expression={"$week": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$week should reject an empty object as the date input", + ), + ExpressionTestCase( + "regex_date", + doc={"date": Regex(".*")}, + expression={"$week": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$week should reject a regex as the date input", + ), + ExpressionTestCase( + "minkey_date", + doc={"date": MinKey()}, + expression={"$week": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$week should reject MinKey as the date input", + ), + ExpressionTestCase( + "maxkey_date", + doc={"date": MaxKey()}, + expression={"$week": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$week should reject MaxKey as the date input", + ), + ExpressionTestCase( + "bindata_date", + doc={"date": Binary(b"")}, + expression={"$week": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$week should reject binary data as the date input", + ), + ExpressionTestCase( + "javascript_date", + doc={"date": Code("function(){}")}, + expression={"$week": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$week should reject JavaScript code as the date input", + ), + ExpressionTestCase( + "float_nan_date", + doc={"date": FLOAT_NAN}, + expression={"$week": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$week should reject a float NaN as the date input", + ), + ExpressionTestCase( + "decimal128_nan_date", + doc={"date": DECIMAL128_NAN}, + expression={"$week": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$week should reject a decimal128 NaN as the date input", + ), + ExpressionTestCase( + "float_inf_date", + doc={"date": FLOAT_INFINITY}, + expression={"$week": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$week should reject a float infinity as the date input", + ), + ExpressionTestCase( + "float_neg_inf_date", + doc={"date": FLOAT_NEGATIVE_INFINITY}, + expression={"$week": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$week should reject a float negative infinity as the date input", + ), + ExpressionTestCase( + "decimal128_inf_date", + doc={"date": DECIMAL128_INFINITY}, + expression={"$week": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$week should reject a decimal128 infinity as the date input", + ), + ExpressionTestCase( + "decimal128_neg_inf_date", + doc={"date": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$week": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$week should reject a decimal128 negative infinity as the date input", + ), +] + +WEEK_NULL_AND_TYPE_ERROR_TESTS: list[ExpressionTestCase] = ( + WEEK_NULL_TESTS + WEEK_TYPE_REJECTION_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(WEEK_NULL_AND_TYPE_ERROR_TESTS)) +def test_week_null_and_type_errors(collection, test_case: ExpressionTestCase): + """Test $week null propagation and non-date type rejection.""" + 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/week/test_week_timezone_input_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_timezone_input_types.py new file mode 100644 index 000000000..79c92cb7e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_timezone_input_types.py @@ -0,0 +1,165 @@ +"""Tests for $week timezone application when the date is a Timestamp or ObjectId.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( + oid_from_args, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Timestamp Input with Zones]: a Timestamp input honours the named zone or offset +# before the week is taken, which may cross a week or year boundary. +WEEK_TIMESTAMP_ZONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "ts_utc_no_cross", + doc={"date": ts_from_args(2024, 7, 15, 12, 0, 0)}, + expression={"$week": {"date": "$date", "timezone": "UTC"}}, + expected=28, + msg="$week should return 28 for a mid-2024 Timestamp in UTC", + ), + ExpressionTestCase( + "ts_tokyo_week_fwd", + doc={"date": ts_from_args(2024, 1, 6, 22, 0, 0)}, + expression={"$week": {"date": "$date", "timezone": "Asia/Tokyo"}}, + expected=1, + msg="$week should cross forward to week 1 for a Timestamp in Asia/Tokyo", + ), + ExpressionTestCase( + "ts_la_week_bwd", + doc={"date": ts_from_args(2024, 1, 7, 3, 0, 0)}, + expression={"$week": {"date": "$date", "timezone": "America/Los_Angeles"}}, + expected=0, + msg="$week should cross back to week 0 for a Timestamp in America/Los_Angeles", + ), + ExpressionTestCase( + "ts_ny_year_bwd", + doc={"date": ts_from_args(2024, 1, 1, 3, 0, 0)}, + expression={"$week": {"date": "$date", "timezone": "America/New_York"}}, + expected=53, + msg="$week should cross back into week 53 for a Timestamp in America/New_York on Jan 1", + ), + ExpressionTestCase( + "ts_helsinki_year_fwd", + doc={"date": ts_from_args(2024, 12, 31, 23, 0, 0)}, + expression={"$week": {"date": "$date", "timezone": "Europe/Helsinki"}}, + expected=0, + msg="$week should cross forward into week 0 for a Timestamp in Europe/Helsinki", + ), + ExpressionTestCase( + "ts_offset_plus9_week_fwd", + doc={"date": ts_from_args(2024, 1, 6, 22, 0, 0)}, + expression={"$week": {"date": "$date", "timezone": "+09:00"}}, + expected=1, + msg="$week should cross forward to week 1 for a Timestamp at a +09:00 offset", + ), + ExpressionTestCase( + "ts_offset_minus8_week_bwd", + doc={"date": ts_from_args(2024, 1, 7, 3, 0, 0)}, + expression={"$week": {"date": "$date", "timezone": "-08:00"}}, + expected=0, + msg="$week should cross back to week 0 for a Timestamp at a -08:00 offset", + ), + ExpressionTestCase( + "ts_offset_minus5_year_bwd", + doc={"date": ts_from_args(2024, 1, 1, 3, 0, 0)}, + expression={"$week": {"date": "$date", "timezone": "-05:00"}}, + expected=53, + msg="$week should cross back into week 53 for a Timestamp at a -05:00 offset", + ), + ExpressionTestCase( + "ts_offset_plus530_week_fwd", + doc={"date": ts_from_args(2024, 1, 6, 20, 0, 0)}, + expression={"$week": {"date": "$date", "timezone": "+05:30"}}, + expected=1, + msg="$week should cross forward to week 1 for a Timestamp at a +05:30 offset", + ), +] + +# Property [ObjectId Input with Zones]: an ObjectId input honours the named zone or offset +# before the week is taken, which may cross a week or year boundary. +WEEK_OBJECTID_ZONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "oid_utc_no_cross", + doc={"date": oid_from_args(2024, 7, 15, 12, 0, 0)}, + expression={"$week": {"date": "$date", "timezone": "UTC"}}, + expected=28, + msg="$week should return 28 for a mid-2024 ObjectId in UTC", + ), + ExpressionTestCase( + "oid_tokyo_week_fwd", + doc={"date": oid_from_args(2024, 1, 6, 22, 0, 0)}, + expression={"$week": {"date": "$date", "timezone": "Asia/Tokyo"}}, + expected=1, + msg="$week should cross forward to week 1 for an ObjectId in Asia/Tokyo", + ), + ExpressionTestCase( + "oid_la_week_bwd", + doc={"date": oid_from_args(2024, 1, 7, 3, 0, 0)}, + expression={"$week": {"date": "$date", "timezone": "America/Los_Angeles"}}, + expected=0, + msg="$week should cross back to week 0 for an ObjectId in America/Los_Angeles", + ), + ExpressionTestCase( + "oid_ny_year_bwd", + doc={"date": oid_from_args(2024, 1, 1, 3, 0, 0)}, + expression={"$week": {"date": "$date", "timezone": "America/New_York"}}, + expected=53, + msg="$week should cross back into week 53 for an ObjectId in America/New_York on Jan 1", + ), + ExpressionTestCase( + "oid_helsinki_year_fwd", + doc={"date": oid_from_args(2024, 12, 31, 23, 0, 0)}, + expression={"$week": {"date": "$date", "timezone": "Europe/Helsinki"}}, + expected=0, + msg="$week should cross forward into week 0 for an ObjectId in Europe/Helsinki", + ), + ExpressionTestCase( + "oid_offset_plus9_week_fwd", + doc={"date": oid_from_args(2024, 1, 6, 22, 0, 0)}, + expression={"$week": {"date": "$date", "timezone": "+09:00"}}, + expected=1, + msg="$week should cross forward to week 1 for an ObjectId at a +09:00 offset", + ), + ExpressionTestCase( + "oid_offset_minus8_week_bwd", + doc={"date": oid_from_args(2024, 1, 7, 3, 0, 0)}, + expression={"$week": {"date": "$date", "timezone": "-08:00"}}, + expected=0, + msg="$week should cross back to week 0 for an ObjectId at a -08:00 offset", + ), + ExpressionTestCase( + "oid_offset_minus5_year_bwd", + doc={"date": oid_from_args(2024, 1, 1, 3, 0, 0)}, + expression={"$week": {"date": "$date", "timezone": "-05:00"}}, + expected=53, + msg="$week should cross back into week 53 for an ObjectId at a -05:00 offset", + ), + ExpressionTestCase( + "oid_offset_plus530_week_fwd", + doc={"date": oid_from_args(2024, 1, 6, 20, 0, 0)}, + expression={"$week": {"date": "$date", "timezone": "+05:30"}}, + expected=1, + msg="$week should cross forward to week 1 for an ObjectId at a +05:30 offset", + ), +] + +WEEK_TIMEZONE_INPUT_TYPES_TESTS: list[ExpressionTestCase] = ( + WEEK_TIMESTAMP_ZONE_TESTS + WEEK_OBJECTID_ZONE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(WEEK_TIMEZONE_INPUT_TYPES_TESTS)) +def test_week_timezone_input_types(collection, test_case: ExpressionTestCase): + """Test $week timezone application for Timestamp and 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/week/test_week_timezone_names.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_timezone_names.py new file mode 100644 index 000000000..1027de15b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_timezone_names.py @@ -0,0 +1,127 @@ +"""Tests for $week named-timezone and abbreviation application, including DST and week crossings.""" + +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]: a named zone or abbreviation shifts the instant before the week is +# taken, which may cross a week or year boundary depending on the offset, while a DST transition +# within a week leaves the week unchanged. +WEEK_NAMED_ZONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_utc_no_cross", + doc={"date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "UTC"}}, + expected=28, + msg="$week should return 28 for UTC with no week crossing", + ), + ExpressionTestCase( + "tz_ny_no_cross", + doc={"date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "America/New_York"}}, + expected=28, + msg="$week should return 28 for America/New_York with no week crossing", + ), + ExpressionTestCase( + "tz_tokyo_no_cross", + doc={"date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "Asia/Tokyo"}}, + expected=28, + msg="$week should return 28 for Asia/Tokyo with no week crossing", + ), + ExpressionTestCase( + "tz_tokyo_week_fwd", + doc={"date": datetime(2024, 1, 6, 22, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "Asia/Tokyo"}}, + expected=1, + msg="$week should cross forward to week 1 for Asia/Tokyo late on the Saturday", + ), + ExpressionTestCase( + "tz_la_week_bwd", + doc={"date": datetime(2024, 1, 7, 3, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "America/Los_Angeles"}}, + expected=0, + msg="$week should cross back to week 0 for America/Los_Angeles early on the Sunday", + ), + ExpressionTestCase( + "tz_ny_year_bwd", + doc={"date": datetime(2024, 1, 1, 3, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "America/New_York"}}, + expected=53, + msg="$week should cross back into the prior year's week 53 for America/New_York on Jan 1", + ), + ExpressionTestCase( + "tz_ny_year_stays", + doc={"date": datetime(2024, 1, 1, 6, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "America/New_York"}}, + expected=0, + msg="$week should stay in week 0 for America/New_York after the local new year", + ), + ExpressionTestCase( + "tz_helsinki_year_fwd", + doc={"date": datetime(2024, 12, 31, 23, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "Europe/Helsinki"}}, + expected=0, + msg="$week should cross forward into the next year's week 0 for Europe/Helsinki", + ), + ExpressionTestCase( + "tz_kolkata_week_fwd", + doc={"date": datetime(2024, 1, 6, 20, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "Asia/Kolkata"}}, + expected=1, + msg="$week should cross forward to week 1 for the half-hour Asia/Kolkata offset", + ), + ExpressionTestCase( + "tz_ny_dst_spring", + doc={"date": datetime(2024, 3, 10, 7, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "America/New_York"}}, + expected=10, + msg="$week should return 10 across the America/New_York spring DST transition", + ), + ExpressionTestCase( + "tz_ny_dst_fall", + doc={"date": datetime(2024, 11, 3, 6, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "America/New_York"}}, + expected=44, + msg="$week should return 44 across the America/New_York fall DST transition", + ), + ExpressionTestCase( + "tz_london_bst", + doc={"date": datetime(2024, 6, 29, 23, 30, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "Europe/London"}}, + expected=26, + msg="$week should return 26 for Europe/London in summer under BST", + ), + ExpressionTestCase( + "tz_pacific_apia", + doc={"date": datetime(2024, 1, 6, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "Pacific/Apia"}}, + expected=1, + msg="$week should cross forward to week 1 for the +13 Pacific/Apia offset", + ), + ExpressionTestCase( + "tz_est_abbreviation", + doc={"date": datetime(2024, 1, 7, 3, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "EST"}}, + expected=0, + msg="$week should accept the EST abbreviation and cross back to week 0", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(WEEK_NAMED_ZONE_TESTS)) +def test_week_timezone_names(collection, test_case: ExpressionTestCase): + """Test $week named-timezone and abbreviation application.""" + 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/week/test_week_timezone_offsets.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_timezone_offsets.py new file mode 100644 index 000000000..d663feb4b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_timezone_offsets.py @@ -0,0 +1,155 @@ +"""Tests for $week UTC-offset timezone application, including compact and unusual offsets.""" + +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 [UTC Offsets]: an explicit +HH:MM/-HH:MM offset shifts the instant before the week is +# taken, including compact, hour-only, half-hour, extreme, and out-of-range offsets the server +# still accepts, and offsets that cross a week or year boundary. +WEEK_OFFSET_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_offset_zero_no_cross", + doc={"date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "+00:00"}}, + expected=28, + msg="$week should accept a +00:00 offset with no week crossing", + ), + ExpressionTestCase( + "tz_offset_minus5_no_cross", + doc={"date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "-05:00"}}, + expected=28, + msg="$week should accept a -05:00 offset with no week crossing", + ), + ExpressionTestCase( + "tz_offset_plus9_week_fwd", + doc={"date": datetime(2024, 1, 6, 22, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "+09:00"}}, + expected=1, + msg="$week should cross forward to week 1 for a +09:00 offset late on the Saturday", + ), + ExpressionTestCase( + "tz_offset_minus8_week_bwd", + doc={"date": datetime(2024, 1, 7, 3, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "-08:00"}}, + expected=0, + msg="$week should cross back to week 0 for a -08:00 offset early on the Sunday", + ), + ExpressionTestCase( + "tz_offset_minus5_year_bwd", + doc={"date": datetime(2024, 1, 1, 3, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "-05:00"}}, + expected=53, + msg="$week should cross back into the prior year's week 53 for a -05:00 offset on Jan 1", + ), + ExpressionTestCase( + "tz_offset_plus2_year_fwd", + doc={"date": datetime(2024, 12, 31, 23, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "+02:00"}}, + expected=0, + msg="$week should cross forward into the next year's week 0 for a +02:00 offset", + ), + ExpressionTestCase( + "tz_offset_plus530_week_fwd", + doc={"date": datetime(2024, 1, 6, 20, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "+05:30"}}, + expected=1, + msg="$week should cross forward to week 1 for a +05:30 half-hour offset", + ), + ExpressionTestCase( + "tz_offset_plus14_week_fwd", + doc={"date": datetime(2024, 1, 6, 11, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "+14:00"}}, + expected=1, + msg="$week should cross forward to week 1 for a +14:00 offset", + ), + ExpressionTestCase( + "tz_offset_minus12_week_bwd", + doc={"date": datetime(2024, 1, 7, 11, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "-12:00"}}, + expected=0, + msg="$week should cross back to week 0 for a -12:00 offset", + ), + ExpressionTestCase( + "tz_offset_no_colon", + doc={"date": datetime(2024, 1, 6, 22, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "+0900"}}, + expected=1, + msg="$week should accept a compact +0900 offset without a colon", + ), + ExpressionTestCase( + "tz_offset_hour_only", + doc={"date": datetime(2024, 1, 7, 3, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "-08"}}, + expected=0, + msg="$week should accept an hour-only -08 offset and cross back to week 0", + ), + ExpressionTestCase( + "tz_offset_minus13", + doc={"date": datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "-13:00"}}, + expected=53, + msg="$week should accept a -13:00 offset that crosses back into the prior year", + ), + ExpressionTestCase( + "tz_offset_plus15", + doc={"date": datetime(2024, 1, 6, 10, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "+15:00"}}, + expected=1, + msg="$week should accept a +15:00 offset that crosses forward to week 1", + ), + ExpressionTestCase( + "tz_offset_minus0330", + doc={"date": datetime(2024, 1, 7, 2, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "-03:30"}}, + expected=0, + msg="$week should accept a -03:30 half-hour west offset that crosses back to week 0", + ), + ExpressionTestCase( + "tz_offset_plus0570", + doc={"date": datetime(2024, 1, 6, 17, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "+05:70"}}, + expected=0, + msg="$week should accept a +05:70 (70-minute) offset", + ), + ExpressionTestCase( + "tz_offset_minus0570", + doc={"date": datetime(2024, 1, 7, 5, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "-05:70"}}, + expected=0, + msg="$week should accept a -05:70 (70-minute) offset", + ), + ExpressionTestCase( + "tz_offset_plus2500", + doc={"date": datetime(2024, 1, 5, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "+25:00"}}, + expected=0, + msg="$week should accept a +25:00 (25-hour) offset", + ), + ExpressionTestCase( + "tz_offset_minus2500", + doc={"date": datetime(2024, 1, 8, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "-25:00"}}, + expected=0, + msg="$week should accept a -25:00 (25-hour) offset", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(WEEK_OFFSET_TESTS)) +def test_week_timezone_offsets(collection, test_case: ExpressionTestCase): + """Test $week UTC-offset timezone application, including edge and unusual offsets.""" + 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/week/test_week_timezone_validation.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_timezone_validation.py new file mode 100644 index 000000000..c08df9a45 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_timezone_validation.py @@ -0,0 +1,115 @@ +"""Tests for $week timezone validation and null-timezone propagation.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + INVALID_TIMEZONE_ERROR, + INVALID_TIMEZONE_TYPE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Timezone Validation]: unparseable zone strings, wrong-typed timezones, and null +# timezones are rejected or propagate null. +WEEK_TIMEZONE_VALIDATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "invalid_tz_olson", + doc={"date": datetime(2024, 6, 15, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "Not/A_Timezone"}}, + error_code=INVALID_TIMEZONE_ERROR, + msg="$week should reject an unparseable timezone string", + ), + ExpressionTestCase( + "invalid_tz_nonexistent_olson", + doc={"date": datetime(2024, 6, 15, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "America/Nowhere"}}, + error_code=INVALID_TIMEZONE_ERROR, + msg="$week should reject a non-existent Olson timezone", + ), + ExpressionTestCase( + "invalid_tz_offset_format", + doc={"date": datetime(2024, 6, 15, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "25:00"}}, + error_code=INVALID_TIMEZONE_ERROR, + msg="$week should reject an unsigned out-of-range offset string", + ), + ExpressionTestCase( + "invalid_tz_numeric_string", + doc={"date": datetime(2024, 6, 15, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "123"}}, + error_code=INVALID_TIMEZONE_ERROR, + msg="$week should reject a bare numeric timezone string", + ), + ExpressionTestCase( + "invalid_tz_empty_string", + doc={"date": datetime(2024, 6, 15, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": ""}}, + error_code=INVALID_TIMEZONE_ERROR, + msg="$week should reject an empty-string timezone", + ), + ExpressionTestCase( + "invalid_tz_olson_lowercase", + doc={"date": datetime(2024, 6, 15, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "america/new_york"}}, + error_code=INVALID_TIMEZONE_ERROR, + msg="$week should reject an all-lowercase Olson name", + ), + ExpressionTestCase( + "invalid_tz_olson_uppercase", + doc={"date": datetime(2024, 6, 15, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": "AMERICA/NEW_YORK"}}, + error_code=INVALID_TIMEZONE_ERROR, + msg="$week should reject an all-uppercase Olson name", + ), + *[ + ExpressionTestCase( + f"invalid_tz_{tid}", + doc={"date": datetime(2024, 6, 15, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": val}}, + error_code=INVALID_TIMEZONE_TYPE_ERROR, + msg=f"$week should reject a {tid} timezone", + ) + for tid, val in [ + ("int", 5), + ("int64", Int64(5)), + ("double", 3.14), + ("decimal128", Decimal128("5")), + ("bool", True), + ("object", {"tz": "UTC"}), + ("array", ["UTC"]), + ("datetime", datetime(2024, 6, 15, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + ExpressionTestCase( + "null_tz", + doc={"date": datetime(2024, 6, 15, tzinfo=timezone.utc)}, + expression={"$week": {"date": "$date", "timezone": None}}, + expected=None, + msg="$week should return null for a null timezone", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(WEEK_TIMEZONE_VALIDATION_TESTS)) +def test_week_timezone_validation(collection, test_case: ExpressionTestCase): + """Test $week timezone validation and null-timezone 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 + ) From c59ccb8941f4e768e6c8987d04dc0063a4e88545 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Tue, 14 Jul 2026 09:44:07 -0700 Subject: [PATCH 4/5] Add $hour expression tests This change adds tests for the $hour expression. It was originally authored by @mitchell-elholm. Closes #315 Co-authored-by: Mitchell Elholm Signed-off-by: Daniel Frankcom --- .../expressions/date/hour/__init__.py | 0 .../expressions/date/hour/test_hour_clock.py | 248 +++++++++++++++++ .../date/hour/test_hour_date_types.py | 187 +++++++++++++ .../date/hour/test_hour_expressions.py | 167 +++++++++++ .../hour/test_hour_null_and_type_errors.py | 211 ++++++++++++++ .../hour/test_hour_timezone_input_types.py | 189 +++++++++++++ .../date/hour/test_hour_timezone_names.py | 248 +++++++++++++++++ .../date/hour/test_hour_timezone_offsets.py | 259 ++++++++++++++++++ .../hour/test_hour_timezone_validation.py | 151 ++++++++++ 9 files changed, 1660 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/test_hour_clock.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/test_hour_date_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/test_hour_expressions.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/test_hour_null_and_type_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/test_hour_timezone_input_types.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/test_hour_timezone_names.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/test_hour_timezone_offsets.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/test_hour_timezone_validation.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/test_hour_clock.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/test_hour_clock.py new file mode 100644 index 000000000..86fae54f9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/test_hour_clock.py @@ -0,0 +1,248 @@ +"""Tests for $hour extraction across the 24-hour clock, calendar boundaries, and year range.""" + +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 + +# Property [Hour Extraction]: $hour returns the hour component (0-23) of a UTC date. +HOUR_EXTRACTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "midnight", + doc={"date": datetime(2024, 6, 15, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$hour": "$date"}, + expected=0, + msg="$hour should return 0 for midnight", + ), + ExpressionTestCase( + "hour_1", + doc={"date": datetime(2024, 6, 15, 1, 0, 0, tzinfo=timezone.utc)}, + expression={"$hour": "$date"}, + expected=1, + msg="$hour should return 1 for the 1 AM hour", + ), + ExpressionTestCase( + "hour_6", + doc={"date": datetime(2024, 6, 15, 6, 30, 0, tzinfo=timezone.utc)}, + expression={"$hour": "$date"}, + expected=6, + msg="$hour should return 6 for a time in the 6 AM hour", + ), + ExpressionTestCase( + "noon", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$hour": "$date"}, + expected=12, + msg="$hour should return 12 for noon", + ), + ExpressionTestCase( + "hour_13", + doc={"date": datetime(2024, 6, 15, 13, 0, 0, tzinfo=timezone.utc)}, + expression={"$hour": "$date"}, + expected=13, + msg="$hour should return 13 for the 1 PM hour", + ), + ExpressionTestCase( + "hour_18", + doc={"date": datetime(2024, 6, 15, 18, 45, 30, tzinfo=timezone.utc)}, + expression={"$hour": "$date"}, + expected=18, + msg="$hour should return 18 for a time in the 6 PM hour", + ), + ExpressionTestCase( + "hour_23", + doc={"date": datetime(2024, 6, 15, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$hour": "$date"}, + expected=23, + msg="$hour should return 23 for the last hour of the day", + ), +] + +# Property [Calendar Boundaries]: year edges, leap-year Feb 29, century rules, and +# sub-second precision resolve to the correct hour. +HOUR_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "first_moment_of_year", + doc={"date": datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$hour": "$date"}, + expected=0, + msg="$hour should return 0 for the first moment of the year", + ), + ExpressionTestCase( + "last_moment_of_year", + doc={"date": datetime(2024, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$hour": "$date"}, + expected=23, + msg="$hour should return 23 for the last moment of the year", + ), + ExpressionTestCase( + "leap_year_feb_29", + doc={"date": datetime(2024, 2, 29, 15, 0, 0, tzinfo=timezone.utc)}, + expression={"$hour": "$date"}, + expected=15, + msg="$hour should return 15 for Feb 29 in a leap year", + ), + ExpressionTestCase( + "non_leap_year_feb_28", + doc={"date": datetime(2023, 2, 28, 15, 0, 0, tzinfo=timezone.utc)}, + expression={"$hour": "$date"}, + expected=15, + msg="$hour should return 15 for Feb 28 in a non-leap year", + ), + ExpressionTestCase( + "leap_year_feb_29_2020", + doc={"date": datetime(2020, 2, 29, 15, 0, 0, tzinfo=timezone.utc)}, + expression={"$hour": "$date"}, + expected=15, + msg="$hour should return 15 for Feb 29 in the leap year 2020", + ), + ExpressionTestCase( + "leap_year_feb_29_2000", + doc={"date": datetime(2000, 2, 29, 15, 0, 0, tzinfo=timezone.utc)}, + expression={"$hour": "$date"}, + expected=15, + msg="$hour should return 15 for Feb 29 in the century leap year 2000", + ), + ExpressionTestCase( + "non_leap_century_1900_feb_28", + doc={"date": datetime(1900, 2, 28, 15, 0, 0, tzinfo=timezone.utc)}, + expression={"$hour": "$date"}, + expected=15, + msg="$hour should return 15 for Feb 28 in the non-leap century year 1900", + ), + ExpressionTestCase( + "millisecond_before_next_hour", + doc={"date": datetime(2024, 6, 15, 11, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={"$hour": "$date"}, + expected=11, + msg="$hour should return 11 one millisecond before the next hour", + ), + ExpressionTestCase( + "millisecond_after_hour_start", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, 1000, tzinfo=timezone.utc)}, + expression={"$hour": "$date"}, + expected=12, + msg="$hour should return 12 one millisecond after the hour starts", + ), + ExpressionTestCase( + "millisecond_mid_hour", + doc={"date": datetime(2024, 6, 15, 12, 30, 30, 500000, tzinfo=timezone.utc)}, + expression={"$hour": "$date"}, + expected=12, + msg="$hour should return 12 for a mid-hour instant with milliseconds", + ), + ExpressionTestCase( + "millisecond_before_midnight", + doc={"date": datetime(2024, 6, 15, 23, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={"$hour": "$date"}, + expected=23, + msg="$hour should return 23 one millisecond before midnight", + ), + ExpressionTestCase( + "millisecond_after_midnight", + doc={"date": datetime(2024, 6, 15, 0, 0, 0, 1000, tzinfo=timezone.utc)}, + expression={"$hour": "$date"}, + expected=0, + msg="$hour should return 0 one millisecond after midnight", + ), +] + +# Property [Year Range]: the hour component is correct across a wide span of years. +HOUR_YEAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "year_2000", + doc={"date": datetime(2000, 1, 1, 8, 0, 0, tzinfo=timezone.utc)}, + expression={"$hour": "$date"}, + expected=8, + msg="$hour should return 8 for a date in the year 2000", + ), + ExpressionTestCase( + "year_1970_epoch", + doc={"date": DATE_EPOCH}, + expression={"$hour": "$date"}, + expected=0, + msg="$hour should return 0 for the Unix epoch", + ), + ExpressionTestCase( + "year_1999", + doc={"date": datetime(1999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$hour": "$date"}, + expected=23, + msg="$hour should return 23 for the last moment of 1999", + ), + ExpressionTestCase( + "year_2099", + doc={"date": datetime(2099, 7, 15, 14, 0, 0, tzinfo=timezone.utc)}, + expression={"$hour": "$date"}, + expected=14, + msg="$hour should return 14 for a date in the year 2099", + ), + ExpressionTestCase( + "year_9999", + doc={"date": datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$hour": "$date"}, + expected=23, + msg="$hour should return 23 for the last representable year 9999", + ), +] + +# Property [Pre-Epoch]: negative-millisecond dates before 1970 resolve to the correct hour. +HOUR_PRE_EPOCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "pre_epoch_1960", + doc={"date": datetime(1960, 3, 15, 14, 0, 0, tzinfo=timezone.utc)}, + expression={"$hour": "$date"}, + expected=14, + msg="$hour should return 14 for a pre-epoch date in 1960", + ), + ExpressionTestCase( + "pre_epoch_1900", + doc={"date": datetime(1900, 7, 4, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$hour": "$date"}, + expected=0, + msg="$hour should return 0 for a pre-epoch date in 1900", + ), + ExpressionTestCase( + "pre_epoch_1969_dec", + doc={"date": datetime(1969, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$hour": "$date"}, + expected=23, + msg="$hour should return 23 for the last moment before the epoch", + ), + ExpressionTestCase( + "pre_epoch_1969_jan", + doc={"date": datetime(1969, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$hour": "$date"}, + expected=0, + msg="$hour should return 0 for the first moment of 1969", + ), + ExpressionTestCase( + "pre_epoch_1952_leap", + doc={"date": datetime(1952, 2, 29, 7, 0, 0, tzinfo=timezone.utc)}, + expression={"$hour": "$date"}, + expected=7, + msg="$hour should return 7 for Feb 29 in the pre-epoch leap year 1952", + ), +] + +HOUR_CLOCK_TESTS: list[ExpressionTestCase] = ( + HOUR_EXTRACTION_TESTS + HOUR_BOUNDARY_TESTS + HOUR_YEAR_TESTS + HOUR_PRE_EPOCH_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(HOUR_CLOCK_TESTS)) +def test_hour_clock(collection, test_case: ExpressionTestCase): + """Test $hour extraction across the clock, calendar boundaries, and year range.""" + 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/hour/test_hour_date_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/test_hour_date_types.py new file mode 100644 index 000000000..13918d220 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/test_hour_date_types.py @@ -0,0 +1,187 @@ +"""Tests for $hour with Timestamp, ObjectId, and extended-range date 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, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_MS_BEFORE_EPOCH, + DATE_MS_EPOCH, + DATE_MS_MAX, + DATE_MS_MIN, + DATE_MS_YEAR_10000, + OID_MAX_SIGNED32, + OID_MAX_UNSIGNED32, + OID_MIN_SIGNED32, + TS_MAX_SIGNED32, + TS_MAX_UNSIGNED32, +) + +# Property [Timestamp Input]: a BSON Timestamp is accepted as a date and yields its hour. +HOUR_TIMESTAMP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "timestamp_midnight", + doc={"date": ts_from_args(2024, 6, 15, 0, 0, 0)}, + expression={"$hour": "$date"}, + expected=0, + msg="$hour should return 0 for a Timestamp at midnight", + ), + ExpressionTestCase( + "timestamp_noon", + doc={"date": ts_from_args(2024, 6, 15, 12, 0, 0)}, + expression={"$hour": "$date"}, + expected=12, + msg="$hour should return 12 for a Timestamp at noon", + ), + ExpressionTestCase( + "timestamp_hour_23", + doc={"date": ts_from_args(2024, 6, 15, 23, 0, 0)}, + expression={"$hour": "$date"}, + expected=23, + msg="$hour should return 23 for a Timestamp in the last hour", + ), + ExpressionTestCase( + "timestamp_zero_increment", + doc={"date": ts_from_args(2024, 6, 15, 8, 0, 0, inc=0)}, + expression={"$hour": "$date"}, + expected=8, + msg="$hour should return 8 for a Timestamp with a zero increment", + ), + ExpressionTestCase( + "timestamp_epoch", + doc={"date": ts_from_args(1970, 1, 1, 0, 0, 0)}, + expression={"$hour": "$date"}, + expected=0, + msg="$hour should return 0 for a Timestamp at the epoch", + ), +] + +# Property [ObjectId Input]: an ObjectId is accepted as a date via its embedded timestamp. +HOUR_OBJECTID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "objectid_midnight", + doc={"date": oid_from_args(2024, 6, 15, 0, 0, 0)}, + expression={"$hour": "$date"}, + expected=0, + msg="$hour should return 0 for an ObjectId at midnight", + ), + ExpressionTestCase( + "objectid_noon", + doc={"date": oid_from_args(2024, 6, 15, 12, 0, 0)}, + expression={"$hour": "$date"}, + expected=12, + msg="$hour should return 12 for an ObjectId at noon", + ), + ExpressionTestCase( + "objectid_hour_23", + doc={"date": oid_from_args(2024, 6, 15, 23, 0, 0)}, + expression={"$hour": "$date"}, + expected=23, + msg="$hour should return 23 for an ObjectId in the last hour", + ), + ExpressionTestCase( + "objectid_epoch", + doc={"date": oid_from_args(1970, 1, 1, 0, 0, 0)}, + expression={"$hour": "$date"}, + expected=0, + msg="$hour should return 0 for an ObjectId at the epoch", + ), +] + +# Property [Extended Range]: DatetimeMS, Timestamp, and ObjectId boundary instants +# beyond the native datetime range resolve to the correct hour. +HOUR_EXTENDED_RANGE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_ms_epoch", + doc={"date": DATE_MS_EPOCH}, + expression={"$hour": "$date"}, + expected=0, + msg="$hour should return 0 for the epoch as a DatetimeMS", + ), + ExpressionTestCase( + "date_ms_before_epoch", + doc={"date": DATE_MS_BEFORE_EPOCH}, + expression={"$hour": "$date"}, + expected=23, + msg="$hour should return 23 for a DatetimeMS one millisecond before the epoch", + ), + ExpressionTestCase( + "date_ms_year_10000", + doc={"date": DATE_MS_YEAR_10000}, + expression={"$hour": "$date"}, + expected=0, + msg="$hour should return 0 for a DatetimeMS at the year-10000 boundary", + ), + ExpressionTestCase( + "date_ms_max", + doc={"date": DATE_MS_MAX}, + expression={"$hour": "$date"}, + expected=7, + msg="$hour should return 7 for the maximum 64-bit DatetimeMS", + ), + ExpressionTestCase( + "date_ms_min", + doc={"date": DATE_MS_MIN}, + expression={"$hour": "$date"}, + expected=16, + msg="$hour should return 16 for the minimum 64-bit DatetimeMS", + ), + ExpressionTestCase( + "ts_boundary_max_s32", + doc={"date": TS_MAX_SIGNED32}, + expression={"$hour": "$date"}, + expected=3, + msg="$hour should return 3 for the max signed 32-bit Timestamp", + ), + ExpressionTestCase( + "ts_boundary_max_u32", + doc={"date": TS_MAX_UNSIGNED32}, + expression={"$hour": "$date"}, + expected=6, + msg="$hour should return 6 for the max unsigned 32-bit Timestamp", + ), + ExpressionTestCase( + "oid_boundary_max_s32", + doc={"date": OID_MAX_SIGNED32}, + expression={"$hour": "$date"}, + expected=3, + msg="$hour should return 3 for the max signed 32-bit ObjectId", + ), + ExpressionTestCase( + "oid_boundary_min_s32", + doc={"date": OID_MIN_SIGNED32}, + expression={"$hour": "$date"}, + expected=20, + msg="$hour should return 20 for the min signed 32-bit ObjectId", + ), + ExpressionTestCase( + "oid_boundary_max_u32", + doc={"date": OID_MAX_UNSIGNED32}, + expression={"$hour": "$date"}, + expected=23, + msg="$hour should return 23 for the max unsigned 32-bit ObjectId", + ), +] + +HOUR_DATE_TYPES_TESTS: list[ExpressionTestCase] = ( + HOUR_TIMESTAMP_TESTS + HOUR_OBJECTID_TESTS + HOUR_EXTENDED_RANGE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(HOUR_DATE_TYPES_TESTS)) +def test_hour_date_types(collection, test_case: ExpressionTestCase): + """Test $hour with Timestamp, ObjectId, and extended-range 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/hour/test_hour_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/test_hour_expressions.py new file mode 100644 index 000000000..b57f2ff52 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/test_hour_expressions.py @@ -0,0 +1,167 @@ +"""Tests for $hour argument forms, field-path resolution, and expression input.""" + +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 ( + ISO_DATE_INVALID_ARRAY_INPUT_ERROR, + ISO_DATE_MISSING_DATE_ERROR, + ISO_DATE_UNKNOWN_FIELD_ERROR, + TYPE_MISMATCH_DATE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Literal Input]: an inline literal date computes the correct hour. +HOUR_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_date", + expression={"$hour": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expected=12, + msg="$hour should return the hour for a literal date operand", + ), +] + +# Property [Argument Forms]: the document form requires exactly a date field, and the +# operand-array form accepts only a single element. +HOUR_ARGUMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_date", + expression={"$hour": {"timezone": "UTC"}}, + error_code=ISO_DATE_MISSING_DATE_ERROR, + msg="$hour should error when the document form omits the date field", + ), + ExpressionTestCase( + "extra_field", + expression={ + "$hour": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "extra": 1, + } + }, + error_code=ISO_DATE_UNKNOWN_FIELD_ERROR, + msg="$hour should error for an unknown field in the document form", + ), + ExpressionTestCase( + "empty_array", + expression={"$hour": []}, + error_code=ISO_DATE_INVALID_ARRAY_INPUT_ERROR, + msg="$hour should error for an empty operand array", + ), + ExpressionTestCase( + "two_element_array", + expression={ + "$hour": [ + datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + datetime(2024, 6, 15, 13, 0, 0, tzinfo=timezone.utc), + ] + }, + error_code=ISO_DATE_INVALID_ARRAY_INPUT_ERROR, + msg="$hour should error for a two-element operand array", + ), + ExpressionTestCase( + "single_element_array", + expression={"$hour": [datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)]}, + expected=12, + msg="$hour should accept a single-element operand array as the date", + ), + ExpressionTestCase( + "single_element_array_field_ref", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$hour": ["$date"]}, + expected=12, + msg="$hour should accept a single-element operand array holding a field reference", + ), + ExpressionTestCase( + "object_expression_input", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$hour": {"a": "$date"}}, + error_code=ISO_DATE_UNKNOWN_FIELD_ERROR, + msg="$hour should treat an object with an unknown key as an invalid document form", + ), +] + +# Property [Field-Path Resolution]: the operator accepts a resolved field reference; a path +# that resolves to an array (array-index or array-of-objects) feeds the type contract and errors. +HOUR_FIELD_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_path", + doc={"a": {"b": datetime(2024, 6, 15, 14, 0, 0, tzinfo=timezone.utc)}}, + expression={"$hour": "$a.b"}, + expected=14, + msg="$hour should accept a date resolved from a nested field path", + ), + ExpressionTestCase( + "array_index_path", + doc={"a": [{"b": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}]}, + expression={"$hour": "$a.0.b"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$hour should error when an array-index path resolves to an array", + ), + ExpressionTestCase( + "composite_array_path", + doc={ + "a": [ + {"b": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + {"b": datetime(2024, 6, 15, 13, 0, 0, tzinfo=timezone.utc)}, + ] + }, + expression={"$hour": "$a.b"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$hour should error when a path over an array of objects resolves to an array", + ), + ExpressionTestCase( + "timezone_from_field", + doc={ + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "tz": "America/New_York", + }, + expression={"$hour": {"date": "$date", "timezone": "$tz"}}, + expected=8, + msg="$hour should apply a timezone resolved from a field reference", + ), + ExpressionTestCase( + "missing_tz_field_ref", + doc={"date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$hour": {"date": "$date", "timezone": "$tz"}}, + expected=None, + msg="$hour should return null when the timezone field reference is missing", + ), + ExpressionTestCase( + "expression_as_input", + expression={"$hour": {"$dateFromString": {"dateString": "2024-06-15T14:30:00Z"}}}, + expected=14, + msg="$hour should accept the result of a nested expression as the date", + ), +] + +# Property [Return Type]: $hour returns a value of BSON type int. +HOUR_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$type": {"$hour": "$date"}}, + expected="int", + msg="$hour should return an int", + ), +] + +HOUR_EXPRESSION_TESTS: list[ExpressionTestCase] = ( + HOUR_LITERAL_TESTS + HOUR_ARGUMENT_TESTS + HOUR_FIELD_PATH_TESTS + HOUR_RETURN_TYPE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(HOUR_EXPRESSION_TESTS)) +def test_hour_expressions(collection, test_case: ExpressionTestCase): + """Test $hour argument forms, field-path resolution, expression input, 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/hour/test_hour_null_and_type_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/test_hour_null_and_type_errors.py new file mode 100644 index 000000000..10831c219 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/test_hour_null_and_type_errors.py @@ -0,0 +1,211 @@ +"""Tests for $hour null propagation and non-date type rejection.""" + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, Regex + +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 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, + FLOAT_NEGATIVE_INFINITY, + MISSING, +) + +# Property [Null Propagation]: a null or missing date resolves to null rather than an error. +HOUR_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_date", + doc={"date": None}, + expression={"$hour": "$date"}, + expected=None, + msg="$hour should return null for a null date", + ), + ExpressionTestCase( + "missing_date", + expression={"$hour": MISSING}, + expected=None, + msg="$hour should return null when the date references a missing field", + ), +] + +# Property [Type Rejection]: any non-date input type is rejected with a type-mismatch error. +HOUR_TYPE_REJECTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_date", + doc={"date": "not-a-date"}, + expression={"$hour": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$hour should reject a string as the date input", + ), + ExpressionTestCase( + "integer_date", + doc={"date": 42}, + expression={"$hour": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$hour should reject an int as the date input", + ), + ExpressionTestCase( + "int64_date", + doc={"date": Int64(42)}, + expression={"$hour": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$hour should reject an int64 as the date input", + ), + ExpressionTestCase( + "double_date", + doc={"date": 3.14}, + expression={"$hour": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$hour should reject a double as the date input", + ), + ExpressionTestCase( + "decimal128_date", + doc={"date": Decimal128("42")}, + expression={"$hour": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$hour should reject a decimal128 as the date input", + ), + ExpressionTestCase( + "boolean_date", + doc={"date": True}, + expression={"$hour": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$hour should reject a boolean as the date input", + ), + ExpressionTestCase( + "array_date", + doc={"date": [1, 2, 3]}, + expression={"$hour": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$hour should reject an array as the date input", + ), + ExpressionTestCase( + "object_date", + doc={"date": {"a": 1}}, + expression={"$hour": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$hour should reject an object as the date input", + ), + ExpressionTestCase( + "empty_string_date", + doc={"date": ""}, + expression={"$hour": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$hour should reject an empty string as the date input", + ), + ExpressionTestCase( + "empty_array_date", + doc={"date": []}, + expression={"$hour": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$hour should reject an empty array as the date input", + ), + ExpressionTestCase( + "empty_object_date", + doc={"date": {}}, + expression={"$hour": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$hour should reject an empty object as the date input", + ), + ExpressionTestCase( + "float_nan_date", + doc={"date": FLOAT_NAN}, + expression={"$hour": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$hour should reject a float NaN as the date input", + ), + ExpressionTestCase( + "decimal128_nan_date", + doc={"date": DECIMAL128_NAN}, + expression={"$hour": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$hour should reject a decimal128 NaN as the date input", + ), + ExpressionTestCase( + "regex_date", + doc={"date": Regex(".*")}, + expression={"$hour": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$hour should reject a regex as the date input", + ), + ExpressionTestCase( + "minkey_date", + doc={"date": MinKey()}, + expression={"$hour": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$hour should reject MinKey as the date input", + ), + ExpressionTestCase( + "maxkey_date", + doc={"date": MaxKey()}, + expression={"$hour": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$hour should reject MaxKey as the date input", + ), + ExpressionTestCase( + "float_inf_date", + doc={"date": FLOAT_INFINITY}, + expression={"$hour": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$hour should reject a float infinity as the date input", + ), + ExpressionTestCase( + "float_neg_inf_date", + doc={"date": FLOAT_NEGATIVE_INFINITY}, + expression={"$hour": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$hour should reject a float negative infinity as the date input", + ), + ExpressionTestCase( + "decimal128_inf_date", + doc={"date": DECIMAL128_INFINITY}, + expression={"$hour": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$hour should reject a decimal128 infinity as the date input", + ), + ExpressionTestCase( + "decimal128_neg_inf_date", + doc={"date": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$hour": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$hour should reject a decimal128 negative infinity as the date input", + ), + ExpressionTestCase( + "bindata_date", + doc={"date": Binary(b"")}, + expression={"$hour": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$hour should reject binary data as the date input", + ), + ExpressionTestCase( + "javascript_date", + doc={"date": Code("function(){}")}, + expression={"$hour": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$hour should reject JavaScript code as the date input", + ), +] + +HOUR_NULL_AND_TYPE_ERROR_TESTS: list[ExpressionTestCase] = ( + HOUR_NULL_TESTS + HOUR_TYPE_REJECTION_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(HOUR_NULL_AND_TYPE_ERROR_TESTS)) +def test_hour_null_and_type_errors(collection, test_case: ExpressionTestCase): + """Test $hour null propagation and non-date type rejection.""" + 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/hour/test_hour_timezone_input_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/test_hour_timezone_input_types.py new file mode 100644 index 000000000..841c78ba5 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/test_hour_timezone_input_types.py @@ -0,0 +1,189 @@ +"""Tests for $hour timezone application when the date is a Timestamp or ObjectId.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( + oid_from_args, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Timestamp Input with Zones]: a Timestamp input honours the zone offset. +HOUR_TIMESTAMP_ZONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_olson_ts_utc", + expression={"$hour": {"date": ts_from_args(2024, 7, 15, 12, 0, 0), "timezone": "UTC"}}, + expected=12, + msg="$hour should return 12 for a Timestamp in UTC with no wrap", + ), + ExpressionTestCase( + "tz_olson_ts_tokyo_fwd", + expression={ + "$hour": {"date": ts_from_args(2024, 6, 30, 22, 0, 0), "timezone": "Asia/Tokyo"} + }, + expected=7, + msg="$hour should wrap forward to 7 for a Timestamp in Asia/Tokyo", + ), + ExpressionTestCase( + "tz_olson_ts_la_bwd", + expression={ + "$hour": { + "date": ts_from_args(2024, 7, 1, 3, 0, 0), + "timezone": "America/Los_Angeles", + } + }, + expected=20, + msg="$hour should wrap backward to 20 for a Timestamp in America/Los_Angeles", + ), + ExpressionTestCase( + "tz_olson_ts_kolkata_half", + expression={ + "$hour": {"date": ts_from_args(2024, 3, 31, 20, 0, 0), "timezone": "Asia/Kolkata"} + }, + expected=1, + msg="$hour should return 1 for a Timestamp in Asia/Kolkata (+05:30)", + ), + ExpressionTestCase( + "tz_olson_ts_ny_year_bwd", + expression={ + "$hour": {"date": ts_from_args(2024, 1, 1, 3, 0, 0), "timezone": "America/New_York"} + }, + expected=22, + msg="$hour should wrap backward across the year boundary for a Timestamp in New York", + ), + ExpressionTestCase( + "tz_olson_ts_helsinki_year_fwd", + expression={ + "$hour": {"date": ts_from_args(2024, 12, 31, 23, 0, 0), "timezone": "Europe/Helsinki"} + }, + expected=1, + msg="$hour should wrap forward across the year boundary for a Timestamp in Europe/Helsinki", + ), + ExpressionTestCase( + "tz_offset_ts_plus9_fwd", + expression={"$hour": {"date": ts_from_args(2024, 6, 30, 22, 0, 0), "timezone": "+09:00"}}, + expected=7, + msg="$hour should wrap forward to 7 for a Timestamp at a +09:00 offset", + ), + ExpressionTestCase( + "tz_offset_ts_minus8_bwd", + expression={"$hour": {"date": ts_from_args(2024, 7, 1, 3, 0, 0), "timezone": "-08:00"}}, + expected=19, + msg="$hour should wrap backward to 19 for a Timestamp at a -08:00 offset", + ), + ExpressionTestCase( + "tz_offset_ts_plus530_fwd", + expression={"$hour": {"date": ts_from_args(2024, 3, 31, 20, 0, 0), "timezone": "+05:30"}}, + expected=1, + msg="$hour should return 1 for a Timestamp at a +05:30 offset", + ), + ExpressionTestCase( + "tz_offset_ts_plus545_fwd", + expression={"$hour": {"date": ts_from_args(2024, 8, 31, 23, 0, 0), "timezone": "+05:45"}}, + expected=4, + msg="$hour should return 4 for a Timestamp at a +05:45 offset", + ), + ExpressionTestCase( + "tz_offset_ts_plus3_leap_fwd", + expression={"$hour": {"date": ts_from_args(2024, 2, 29, 23, 30, 0), "timezone": "+03:00"}}, + expected=2, + msg="$hour should wrap forward across the leap-day boundary for a Timestamp at +03:00", + ), +] + +# Property [ObjectId Input with Zones]: an ObjectId input honours the zone offset. +HOUR_OBJECTID_ZONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_olson_oid_utc", + expression={"$hour": {"date": oid_from_args(2024, 7, 15, 12, 0, 0), "timezone": "UTC"}}, + expected=12, + msg="$hour should return 12 for an ObjectId in UTC with no wrap", + ), + ExpressionTestCase( + "tz_olson_oid_tokyo_fwd", + expression={ + "$hour": {"date": oid_from_args(2024, 6, 30, 22, 0, 0), "timezone": "Asia/Tokyo"} + }, + expected=7, + msg="$hour should wrap forward to 7 for an ObjectId in Asia/Tokyo", + ), + ExpressionTestCase( + "tz_olson_oid_la_bwd", + expression={ + "$hour": { + "date": oid_from_args(2024, 7, 1, 3, 0, 0), + "timezone": "America/Los_Angeles", + } + }, + expected=20, + msg="$hour should wrap backward to 20 for an ObjectId in America/Los_Angeles", + ), + ExpressionTestCase( + "tz_olson_oid_kolkata_half", + expression={ + "$hour": {"date": oid_from_args(2024, 3, 31, 20, 0, 0), "timezone": "Asia/Kolkata"} + }, + expected=1, + msg="$hour should return 1 for an ObjectId in Asia/Kolkata (+05:30)", + ), + ExpressionTestCase( + "tz_olson_oid_ny_year_bwd", + expression={ + "$hour": {"date": oid_from_args(2024, 1, 1, 3, 0, 0), "timezone": "America/New_York"} + }, + expected=22, + msg="$hour should wrap backward across the year boundary for an ObjectId in New York", + ), + ExpressionTestCase( + "tz_olson_oid_helsinki_year_fwd", + expression={ + "$hour": {"date": oid_from_args(2024, 12, 31, 23, 0, 0), "timezone": "Europe/Helsinki"} + }, + expected=1, + msg="$hour should wrap forward across the year boundary for an ObjectId in Europe/Helsinki", + ), + ExpressionTestCase( + "tz_offset_oid_plus9_fwd", + expression={"$hour": {"date": oid_from_args(2024, 6, 30, 22, 0, 0), "timezone": "+09:00"}}, + expected=7, + msg="$hour should wrap forward to 7 for an ObjectId at a +09:00 offset", + ), + ExpressionTestCase( + "tz_offset_oid_minus8_bwd", + expression={"$hour": {"date": oid_from_args(2024, 7, 1, 3, 0, 0), "timezone": "-08:00"}}, + expected=19, + msg="$hour should wrap backward to 19 for an ObjectId at a -08:00 offset", + ), + ExpressionTestCase( + "tz_offset_oid_plus530_fwd", + expression={"$hour": {"date": oid_from_args(2024, 3, 31, 20, 0, 0), "timezone": "+05:30"}}, + expected=1, + msg="$hour should return 1 for an ObjectId at a +05:30 offset", + ), + ExpressionTestCase( + "tz_offset_oid_plus3_leap_fwd", + expression={"$hour": {"date": oid_from_args(2024, 2, 29, 23, 30, 0), "timezone": "+03:00"}}, + expected=2, + msg="$hour should wrap forward across the leap-day boundary for an ObjectId at +03:00", + ), +] + +HOUR_TIMEZONE_INPUT_TYPES_TESTS: list[ExpressionTestCase] = ( + HOUR_TIMESTAMP_ZONE_TESTS + HOUR_OBJECTID_ZONE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(HOUR_TIMEZONE_INPUT_TYPES_TESTS)) +def test_hour_timezone_input_types(collection, test_case: ExpressionTestCase): + """Test $hour timezone application for Timestamp and 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/hour/test_hour_timezone_names.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/test_hour_timezone_names.py new file mode 100644 index 000000000..25efca36f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/test_hour_timezone_names.py @@ -0,0 +1,248 @@ +"""Tests for $hour named-timezone application, including DST and abbreviations.""" + +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]: a named zone or abbreviation shifts the instant before the hour is +# taken, which may wrap past midnight forward or backward depending on the offset and DST. +HOUR_OLSON_DATETIME_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_olson_dt_utc", + expression={ + "$hour": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "UTC", + } + }, + expected=12, + msg="$hour should return 12 for UTC with no wrap", + ), + ExpressionTestCase( + "tz_olson_dt_ny_no_wrap", + expression={ + "$hour": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=8, + msg="$hour should return 8 for America/New_York with no wrap", + ), + ExpressionTestCase( + "tz_olson_dt_tokyo_no_wrap", + expression={ + "$hour": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "Asia/Tokyo", + } + }, + expected=21, + msg="$hour should return 21 for Asia/Tokyo with no wrap", + ), + ExpressionTestCase( + "tz_olson_dt_tokyo_wrap_fwd", + expression={ + "$hour": { + "date": datetime(2024, 6, 30, 22, 0, 0, tzinfo=timezone.utc), + "timezone": "Asia/Tokyo", + } + }, + expected=7, + msg="$hour should wrap forward to 7 for Asia/Tokyo (+09:00)", + ), + ExpressionTestCase( + "tz_olson_dt_la_wrap_bwd", + expression={ + "$hour": { + "date": datetime(2024, 7, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "America/Los_Angeles", + } + }, + expected=20, + msg="$hour should wrap backward to 20 for America/Los_Angeles (PDT)", + ), + ExpressionTestCase( + "tz_olson_dt_denver_wrap_bwd", + expression={ + "$hour": { + "date": datetime(2024, 11, 1, 5, 0, 0, tzinfo=timezone.utc), + "timezone": "America/Denver", + } + }, + expected=23, + msg="$hour should wrap backward to 23 for America/Denver (MDT)", + ), + ExpressionTestCase( + "tz_olson_dt_kolkata_half", + expression={ + "$hour": { + "date": datetime(2024, 3, 31, 20, 0, 0, tzinfo=timezone.utc), + "timezone": "Asia/Kolkata", + } + }, + expected=1, + msg="$hour should return 1 for Asia/Kolkata (+05:30) half-hour offset", + ), + ExpressionTestCase( + "tz_olson_dt_kathmandu_quarter", + expression={ + "$hour": { + "date": datetime(2024, 8, 31, 23, 0, 0, tzinfo=timezone.utc), + "timezone": "Asia/Kathmandu", + } + }, + expected=4, + msg="$hour should return 4 for Asia/Kathmandu (+05:45) quarter-hour offset", + ), + ExpressionTestCase( + "tz_olson_dt_kolkata_sep", + expression={ + "$hour": { + "date": datetime(2024, 9, 30, 22, 30, 0, tzinfo=timezone.utc), + "timezone": "Asia/Kolkata", + } + }, + expected=4, + msg="$hour should return 4 for Asia/Kolkata (+05:30) crossing into the next day", + ), + ExpressionTestCase( + "tz_olson_dt_ny_year_bwd", + expression={ + "$hour": { + "date": datetime(2024, 1, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=22, + msg="$hour should wrap backward across the year boundary to 22 for America/New_York", + ), + ExpressionTestCase( + "tz_olson_dt_ny_year_stays", + expression={ + "$hour": { + "date": datetime(2024, 1, 1, 6, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=1, + msg="$hour should return 1 for America/New_York after the year boundary", + ), + ExpressionTestCase( + "tz_olson_dt_helsinki_year_fwd", + expression={ + "$hour": { + "date": datetime(2024, 12, 31, 23, 0, 0, tzinfo=timezone.utc), + "timezone": "Europe/Helsinki", + } + }, + expected=1, + msg="$hour should wrap forward across the year boundary to 1 for Europe/Helsinki", + ), + ExpressionTestCase( + "tz_olson_dt_moscow_leap_fwd", + expression={ + "$hour": { + "date": datetime(2024, 2, 29, 23, 30, 0, tzinfo=timezone.utc), + "timezone": "Europe/Moscow", + } + }, + expected=2, + msg="$hour should wrap forward across the leap-day boundary to 2 for Europe/Moscow", + ), + ExpressionTestCase( + "tz_olson_dt_ny_leap_bwd", + expression={ + "$hour": { + "date": datetime(2024, 3, 1, 2, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=21, + msg="$hour should wrap backward across the leap-day boundary to 21 for America/New_York", + ), + ExpressionTestCase( + "tz_olson_dt_ny_dst_spring", + expression={ + "$hour": { + "date": datetime(2024, 3, 10, 7, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=3, + msg="$hour should return 3 across the spring DST transition in America/New_York", + ), + ExpressionTestCase( + "tz_olson_dt_ny_dst_fall", + expression={ + "$hour": { + "date": datetime(2024, 11, 3, 6, 0, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=1, + msg="$hour should return 1 across the fall DST transition in America/New_York", + ), + ExpressionTestCase( + "tz_olson_dt_london_bst_start", + expression={ + "$hour": { + "date": datetime(2024, 3, 31, 0, 30, 0, tzinfo=timezone.utc), + "timezone": "Europe/London", + } + }, + expected=0, + msg="$hour should return 0 at the Europe/London BST start", + ), + ExpressionTestCase( + "tz_olson_dt_london_bst", + expression={ + "$hour": { + "date": datetime(2024, 7, 15, 23, 30, 0, tzinfo=timezone.utc), + "timezone": "Europe/London", + } + }, + expected=0, + msg="$hour should wrap forward to 0 for Europe/London in summer (BST +1)", + ), + ExpressionTestCase( + "tz_olson_dt_pacific_apia", + expression={ + "$hour": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "Pacific/Apia", + } + }, + expected=1, + msg="$hour should return 1 for Pacific/Apia (+13:00)", + ), + ExpressionTestCase( + "tz_est_abbreviation", + expression={ + "$hour": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "EST", + } + }, + expected=7, + msg="$hour should accept the EST three-letter abbreviation", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(HOUR_OLSON_DATETIME_TESTS)) +def test_hour_timezone_names(collection, test_case: ExpressionTestCase): + """Test $hour named-timezone application across zones, DST, and abbreviations.""" + 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/hour/test_hour_timezone_offsets.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/test_hour_timezone_offsets.py new file mode 100644 index 000000000..dcce1201d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/test_hour_timezone_offsets.py @@ -0,0 +1,259 @@ +"""Tests for $hour UTC-offset timezone application, including edge and unusual offsets.""" + +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 [UTC Offsets, datetime]: an explicit +HH:MM/-HH:MM offset shifts the instant, +# including half/quarter-hour, compact, extreme, and out-of-range offsets the server accepts. +HOUR_OFFSET_DATETIME_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_offset_dt_plus0", + expression={ + "$hour": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "+00:00", + } + }, + expected=12, + msg="$hour should return 12 for a +00:00 offset with no wrap", + ), + ExpressionTestCase( + "tz_offset_dt_minus5_no_wrap", + expression={ + "$hour": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "-05:00", + } + }, + expected=7, + msg="$hour should return 7 for a -05:00 offset with no wrap", + ), + ExpressionTestCase( + "tz_offset_dt_plus9_fwd", + expression={ + "$hour": { + "date": datetime(2024, 6, 30, 22, 0, 0, tzinfo=timezone.utc), + "timezone": "+09:00", + } + }, + expected=7, + msg="$hour should wrap forward to 7 for a +09:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_minus8_bwd", + expression={ + "$hour": { + "date": datetime(2024, 7, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "-08:00", + } + }, + expected=19, + msg="$hour should wrap backward to 19 for a -08:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus530_fwd", + expression={ + "$hour": { + "date": datetime(2024, 3, 31, 20, 0, 0, tzinfo=timezone.utc), + "timezone": "+05:30", + } + }, + expected=1, + msg="$hour should return 1 for a +05:30 half-hour offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus545_fwd", + expression={ + "$hour": { + "date": datetime(2024, 8, 31, 23, 0, 0, tzinfo=timezone.utc), + "timezone": "+05:45", + } + }, + expected=4, + msg="$hour should return 4 for a +05:45 quarter-hour offset", + ), + ExpressionTestCase( + "tz_offset_dt_minus5_year_bwd", + expression={ + "$hour": { + "date": datetime(2024, 1, 1, 3, 0, 0, tzinfo=timezone.utc), + "timezone": "-05:00", + } + }, + expected=22, + msg="$hour should wrap backward across the year boundary to 22 for a -05:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus2_year_fwd", + expression={ + "$hour": { + "date": datetime(2024, 12, 31, 23, 0, 0, tzinfo=timezone.utc), + "timezone": "+02:00", + } + }, + expected=1, + msg="$hour should wrap forward across the year boundary to 1 for a +02:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus3_leap_fwd", + expression={ + "$hour": { + "date": datetime(2024, 2, 29, 23, 30, 0, tzinfo=timezone.utc), + "timezone": "+03:00", + } + }, + expected=2, + msg="$hour should wrap forward across the leap-day boundary to 2 for a +03:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_minus5_leap_bwd", + expression={ + "$hour": { + "date": datetime(2024, 3, 1, 2, 0, 0, tzinfo=timezone.utc), + "timezone": "-05:00", + } + }, + expected=21, + msg="$hour should wrap backward across the leap-day boundary to 21 for a -05:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus14_fwd", + expression={ + "$hour": { + "date": datetime(2024, 6, 30, 11, 0, 0, tzinfo=timezone.utc), + "timezone": "+14:00", + } + }, + expected=1, + msg="$hour should wrap forward to 1 for the extreme +14:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_minus12_bwd", + expression={ + "$hour": { + "date": datetime(2024, 7, 1, 11, 0, 0, tzinfo=timezone.utc), + "timezone": "-12:00", + } + }, + expected=23, + msg="$hour should wrap backward to 23 for the extreme -12:00 offset", + ), + ExpressionTestCase( + "tz_offset_no_colon", + expression={ + "$hour": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "-0500", + } + }, + expected=7, + msg="$hour should accept a compact -0500 offset without a colon", + ), + ExpressionTestCase( + "tz_offset_hour_only", + expression={ + "$hour": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "+03", + } + }, + expected=15, + msg="$hour should accept an hour-only +03 offset", + ), + ExpressionTestCase( + "tz_offset_minus13", + expression={ + "$hour": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "-13:00", + } + }, + expected=23, + msg="$hour should accept a -13:00 offset", + ), + ExpressionTestCase( + "tz_offset_plus15", + expression={ + "$hour": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "+15:00", + } + }, + expected=3, + msg="$hour should accept a +15:00 offset", + ), + ExpressionTestCase( + "tz_offset_minus0330", + expression={ + "$hour": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "-03:30", + } + }, + expected=8, + msg="$hour should accept a -03:30 half-hour west offset", + ), + ExpressionTestCase( + "tz_offset_plus0570", + expression={ + "$hour": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "+05:70", + } + }, + expected=18, + msg="$hour should accept a +05:70 (70-minute) offset", + ), + ExpressionTestCase( + "tz_offset_minus0570", + expression={ + "$hour": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "-05:70", + } + }, + expected=5, + msg="$hour should accept a -05:70 (70-minute) offset", + ), + ExpressionTestCase( + "tz_offset_plus2500", + expression={ + "$hour": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "+25:00", + } + }, + expected=13, + msg="$hour should accept a +25:00 (25-hour) offset", + ), + ExpressionTestCase( + "tz_offset_minus2500", + expression={ + "$hour": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "-25:00", + } + }, + expected=11, + msg="$hour should accept a -25:00 (25-hour) offset", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(HOUR_OFFSET_DATETIME_TESTS)) +def test_hour_timezone_offsets(collection, test_case: ExpressionTestCase): + """Test $hour UTC-offset timezone application, including edge and unusual offsets.""" + 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/hour/test_hour_timezone_validation.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/test_hour_timezone_validation.py new file mode 100644 index 000000000..6834fccdf --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/test_hour_timezone_validation.py @@ -0,0 +1,151 @@ +"""Tests for $hour timezone validation and null-timezone propagation.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + INVALID_TIMEZONE_ERROR, + INVALID_TIMEZONE_TYPE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Timezone Validation]: unparseable zone strings, wrong-typed timezones, and +# null timezones are rejected or propagate null. +HOUR_TIMEZONE_VALIDATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "invalid_tz_olson", + expression={ + "$hour": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "Not/A_Timezone", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$hour should reject an unparseable Olson-like timezone string", + ), + ExpressionTestCase( + "invalid_tz_nonexistent_olson", + expression={ + "$hour": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "America/Nowhere", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$hour should reject a non-existent Olson timezone", + ), + ExpressionTestCase( + "invalid_tz_offset_format", + expression={ + "$hour": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "25:00", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$hour should reject a bare 25:00 offset without a sign", + ), + ExpressionTestCase( + "invalid_tz_numeric", + expression={ + "$hour": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "123", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$hour should reject a numeric-string timezone", + ), + ExpressionTestCase( + "invalid_tz_empty_string", + expression={ + "$hour": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$hour should reject an empty-string timezone", + ), + ExpressionTestCase( + "invalid_tz_olson_lowercase", + expression={ + "$hour": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "america/new_york", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$hour should reject an all-lowercase Olson name", + ), + ExpressionTestCase( + "invalid_tz_olson_uppercase", + expression={ + "$hour": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "AMERICA/NEW_YORK", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$hour should reject an all-uppercase Olson name", + ), + *[ + ExpressionTestCase( + f"invalid_tz_{tid}", + expression={ + "$hour": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": val, + } + }, + error_code=INVALID_TIMEZONE_TYPE_ERROR, + msg=f"$hour should reject a {tid} timezone", + ) + for tid, val in [ + ("int", 5), + ("int64", Int64(5)), + ("double", 3.14), + ("decimal128", Decimal128("5")), + ("bool", True), + ("object", {"tz": "UTC"}), + ("array", ["UTC"]), + ("datetime", datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + ExpressionTestCase( + "null_tz", + expression={ + "$hour": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": None, + } + }, + expected=None, + msg="$hour should return null for a null timezone", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(HOUR_TIMEZONE_VALIDATION_TESTS)) +def test_hour_timezone_validation(collection, test_case: ExpressionTestCase): + """Test $hour timezone validation and null-timezone 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 + ) From 83d706d0b88f0e3c2bfb7b7398cd83578a48d92b Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Wed, 15 Jul 2026 15:02:06 -0700 Subject: [PATCH 5/5] Trim non-discriminating leap-year cases Signed-off-by: Daniel Frankcom --- .../expressions/date/hour/test_hour_clock.py | 25 ++---------------- .../date/month/test_month_calendar.py | 20 +++++--------- .../date/week/test_week_calendar.py | 3 ++- .../date/year/test_year_calendar.py | 26 ++----------------- 4 files changed, 13 insertions(+), 61 deletions(-) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/test_hour_clock.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/test_hour_clock.py index 86fae54f9..8452db3a2 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/test_hour_clock.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/hour/test_hour_clock.py @@ -67,8 +67,8 @@ ), ] -# Property [Calendar Boundaries]: year edges, leap-year Feb 29, century rules, and -# sub-second precision resolve to the correct hour. +# Property [Calendar Boundaries]: year edges, leap-year Feb 29, and sub-second precision +# resolve to the correct hour. HOUR_BOUNDARY_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( "first_moment_of_year", @@ -98,27 +98,6 @@ expected=15, msg="$hour should return 15 for Feb 28 in a non-leap year", ), - ExpressionTestCase( - "leap_year_feb_29_2020", - doc={"date": datetime(2020, 2, 29, 15, 0, 0, tzinfo=timezone.utc)}, - expression={"$hour": "$date"}, - expected=15, - msg="$hour should return 15 for Feb 29 in the leap year 2020", - ), - ExpressionTestCase( - "leap_year_feb_29_2000", - doc={"date": datetime(2000, 2, 29, 15, 0, 0, tzinfo=timezone.utc)}, - expression={"$hour": "$date"}, - expected=15, - msg="$hour should return 15 for Feb 29 in the century leap year 2000", - ), - ExpressionTestCase( - "non_leap_century_1900_feb_28", - doc={"date": datetime(1900, 2, 28, 15, 0, 0, tzinfo=timezone.utc)}, - expression={"$hour": "$date"}, - expected=15, - msg="$hour should return 15 for Feb 28 in the non-leap century year 1900", - ), ExpressionTestCase( "millisecond_before_next_hour", doc={"date": datetime(2024, 6, 15, 11, 59, 59, 999000, tzinfo=timezone.utc)}, diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/month/test_month_calendar.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/month/test_month_calendar.py index d106ca2ae..cdfc974f7 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/date/month/test_month_calendar.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/month/test_month_calendar.py @@ -102,8 +102,9 @@ ), ] -# Property [Calendar Boundaries]: year edges, leap-year Feb 29, century rules, and -# sub-second precision resolve to the correct month. +# Property [Calendar Boundaries]: year edges, the three leap-year branches (ordinary in 2024, +# century leap in 2000, century non-leap in 1900), and sub-second precision resolve to the +# correct month. MONTH_BOUNDARY_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( "first_day_of_year", @@ -133,13 +134,6 @@ expected=2, msg="$month should return 2 for Feb 28 in a non-leap year", ), - ExpressionTestCase( - "leap_year_feb_29_2020", - doc={"date": datetime(2020, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$month": "$date"}, - expected=2, - msg="$month should return 2 for Feb 29 in the leap year 2020", - ), ExpressionTestCase( "leap_year_feb_29_2000", doc={"date": datetime(2000, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, @@ -148,11 +142,11 @@ msg="$month should return 2 for Feb 29 in the century leap year 2000", ), ExpressionTestCase( - "non_leap_century_1900_feb_28", - doc={"date": datetime(1900, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, + "non_leap_century_1900_mar_1", + doc={"date": datetime(1900, 3, 1, 12, 0, 0, tzinfo=timezone.utc)}, expression={"$month": "$date"}, - expected=2, - msg="$month should return 2 for Feb 28 in the non-leap century year 1900", + expected=3, + msg="$month should return 3 for Mar 1 1900, a non-leap century year with no Feb 29", ), ExpressionTestCase( "midnight", diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_calendar.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_calendar.py index cc44f96cb..934d7d9d5 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_calendar.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/week/test_week_calendar.py @@ -97,7 +97,8 @@ ] # Property [Leap Years]: leap and non-leap February dates map to the correct Sunday-based week, -# including century leap-year rules. +# including the century leap-year rules, which shift the day-of-year and produce different week +# numbers (week 8 versus week 9). WEEK_LEAP_YEAR_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( "leap_year_feb_29_2024", diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/year/test_year_calendar.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/year/test_year_calendar.py index 3cbb4db11..5926b89bd 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/date/year/test_year_calendar.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/year/test_year_calendar.py @@ -12,7 +12,7 @@ execute_expression_with_insert, ) from documentdb_tests.framework.parametrize import pytest_params -from documentdb_tests.framework.test_constants import DATE_EPOCH, DATE_LEAP_FEB29, DATE_YEAR_9999 +from documentdb_tests.framework.test_constants import DATE_EPOCH, DATE_YEAR_9999 # Property [Year Extraction]: $year returns the calendar year, so every instant from the first # to the last moment of a year maps to that year regardless of month, day, or sub-second. @@ -89,8 +89,7 @@ ), ] -# Property [Leap Years]: the calendar year is correct on and around leap day, including the -# century leap-rule boundaries (year-2000 leap and non-leap century 1900). +# Property [Leap Years]: the calendar year is correct on and around leap day. YEAR_LEAP_TESTS: list[ExpressionTestCase] = [ ExpressionTestCase( "leap_year_feb_29_2024", @@ -106,27 +105,6 @@ expected=2023, msg="$year should return 2023 for Feb 28 of the non-leap year 2023", ), - ExpressionTestCase( - "leap_year_feb_29_2020", - doc={"date": datetime(2020, 2, 29, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$year": "$date"}, - expected=2020, - msg="$year should return 2020 for leap day 2020", - ), - ExpressionTestCase( - "leap_year_feb_29_2000", - doc={"date": DATE_LEAP_FEB29}, - expression={"$year": "$date"}, - expected=2000, - msg="$year should return 2000 for leap day of the year-2000 century leap", - ), - ExpressionTestCase( - "non_leap_century_1900_feb_28", - doc={"date": datetime(1900, 2, 28, 12, 0, 0, tzinfo=timezone.utc)}, - expression={"$year": "$date"}, - expected=1900, - msg="$year should return 1900 for Feb 28 of the non-leap century 1900", - ), ] # Property [Pre-Epoch]: dates at and before the 1970 epoch return their correct calendar year.