diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/millisecond/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/millisecond/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/millisecond/test_millisecond_clock.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/millisecond/test_millisecond_clock.py new file mode 100644 index 000000000..d450290c0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/millisecond/test_millisecond_clock.py @@ -0,0 +1,271 @@ +"""Tests for $millisecond extraction across the second, 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_BEFORE_EPOCH, + DATE_EPOCH, + DATE_YEAR_9999, +) + +# Property [Millisecond Extraction]: $millisecond returns the millisecond component (0-999) +# of a UTC date. +MILLISECOND_EXTRACTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "ms_0", + doc={"date": datetime(2024, 6, 15, 12, 30, 30, 0, tzinfo=timezone.utc)}, + expression={"$millisecond": "$date"}, + expected=0, + msg="$millisecond should return 0 at the top of the second", + ), + ExpressionTestCase( + "ms_1", + doc={"date": datetime(2024, 6, 15, 12, 30, 30, 1000, tzinfo=timezone.utc)}, + expression={"$millisecond": "$date"}, + expected=1, + msg="$millisecond should return 1 for one millisecond past the second", + ), + ExpressionTestCase( + "ms_2", + doc={"date": datetime(2024, 6, 15, 12, 30, 30, 2000, tzinfo=timezone.utc)}, + expression={"$millisecond": "$date"}, + expected=2, + msg="$millisecond should return 2 for two milliseconds past the second", + ), + ExpressionTestCase( + "ms_10", + doc={"date": datetime(2024, 6, 15, 12, 30, 30, 10000, tzinfo=timezone.utc)}, + expression={"$millisecond": "$date"}, + expected=10, + msg="$millisecond should return a two-digit millisecond value", + ), + ExpressionTestCase( + "ms_99", + doc={"date": datetime(2024, 6, 15, 12, 30, 30, 99000, tzinfo=timezone.utc)}, + expression={"$millisecond": "$date"}, + expected=99, + msg="$millisecond should return the last two-digit millisecond value", + ), + ExpressionTestCase( + "ms_100", + doc={"date": datetime(2024, 6, 15, 12, 30, 30, 100000, tzinfo=timezone.utc)}, + expression={"$millisecond": "$date"}, + expected=100, + msg="$millisecond should return the first three-digit millisecond value", + ), + ExpressionTestCase( + "ms_250", + doc={"date": datetime(2024, 6, 15, 12, 30, 30, 250000, tzinfo=timezone.utc)}, + expression={"$millisecond": "$date"}, + expected=250, + msg="$millisecond should return 250 for a quarter-second instant", + ), + ExpressionTestCase( + "ms_498", + doc={"date": datetime(2024, 6, 15, 12, 30, 30, 498000, tzinfo=timezone.utc)}, + expression={"$millisecond": "$date"}, + expected=498, + msg="$millisecond should return 498 just below the half-second", + ), + ExpressionTestCase( + "ms_499", + doc={"date": datetime(2024, 6, 15, 12, 30, 30, 499000, tzinfo=timezone.utc)}, + expression={"$millisecond": "$date"}, + expected=499, + msg="$millisecond should return 499 one millisecond below the half-second", + ), + ExpressionTestCase( + "ms_500", + doc={"date": datetime(2024, 6, 15, 12, 30, 30, 500000, tzinfo=timezone.utc)}, + expression={"$millisecond": "$date"}, + expected=500, + msg="$millisecond should return 500 at the half-second", + ), + ExpressionTestCase( + "ms_501", + doc={"date": datetime(2024, 6, 15, 12, 30, 30, 501000, tzinfo=timezone.utc)}, + expression={"$millisecond": "$date"}, + expected=501, + msg="$millisecond should return 501 one millisecond above the half-second", + ), + ExpressionTestCase( + "ms_750", + doc={"date": datetime(2024, 6, 15, 12, 30, 30, 750000, tzinfo=timezone.utc)}, + expression={"$millisecond": "$date"}, + expected=750, + msg="$millisecond should return 750 for a three-quarter-second instant", + ), + ExpressionTestCase( + "ms_998", + doc={"date": datetime(2024, 6, 15, 12, 30, 30, 998000, tzinfo=timezone.utc)}, + expression={"$millisecond": "$date"}, + expected=998, + msg="$millisecond should return 998 one millisecond below the maximum", + ), + ExpressionTestCase( + "ms_999", + doc={"date": datetime(2024, 6, 15, 12, 30, 30, 999000, tzinfo=timezone.utc)}, + expression={"$millisecond": "$date"}, + expected=999, + msg="$millisecond should return the maximum millisecond value 999", + ), +] + +# Property [Calendar Boundaries]: year edges, leap-year Feb 29, and second and minute boundaries +# resolve to the correct millisecond. +MILLISECOND_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "first_moment_of_year", + doc={"date": datetime(2024, 1, 1, 0, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$millisecond": "$date"}, + expected=0, + msg="$millisecond should return 0 for the first moment of the year", + ), + ExpressionTestCase( + "last_ms_of_year", + doc={"date": datetime(2024, 12, 31, 23, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={"$millisecond": "$date"}, + expected=999, + msg="$millisecond should return 999 for the last millisecond of the year", + ), + ExpressionTestCase( + "leap_year_feb_29", + doc={"date": datetime(2024, 2, 29, 15, 30, 42, 123000, tzinfo=timezone.utc)}, + expression={"$millisecond": "$date"}, + expected=123, + msg="$millisecond should return the sub-second value for Feb 29 in a leap year", + ), + ExpressionTestCase( + "non_leap_year_feb_28", + doc={"date": datetime(2023, 2, 28, 15, 30, 42, 123000, tzinfo=timezone.utc)}, + expression={"$millisecond": "$date"}, + expected=123, + msg="$millisecond should return the sub-second value for Feb 28 in a non-leap year", + ), + ExpressionTestCase( + "ms_at_second_boundary", + doc={"date": datetime(2024, 6, 15, 12, 30, 59, 999000, tzinfo=timezone.utc)}, + expression={"$millisecond": "$date"}, + expected=999, + msg="$millisecond should return 999 one millisecond before the next second", + ), + ExpressionTestCase( + "ms_after_second_start", + doc={"date": datetime(2024, 6, 15, 12, 31, 0, 1000, tzinfo=timezone.utc)}, + expression={"$millisecond": "$date"}, + expected=1, + msg="$millisecond should return 1 one millisecond after the minute starts", + ), + ExpressionTestCase( + "ms_at_midnight", + doc={"date": datetime(2024, 6, 15, 0, 0, 0, 500000, tzinfo=timezone.utc)}, + expression={"$millisecond": "$date"}, + expected=500, + msg="$millisecond should return 500 for a mid-second instant at midnight", + ), +] + +# Property [Year Range]: the millisecond component is correct across a wide span of years. +MILLISECOND_YEAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "year_2000", + doc={"date": datetime(2000, 1, 1, 8, 45, 33, 456000, tzinfo=timezone.utc)}, + expression={"$millisecond": "$date"}, + expected=456, + msg="$millisecond should return 456 for a date in the year 2000", + ), + ExpressionTestCase( + "year_1970_epoch", + doc={"date": DATE_EPOCH}, + expression={"$millisecond": "$date"}, + expected=0, + msg="$millisecond should return 0 for the Unix epoch", + ), + ExpressionTestCase( + "year_1999", + doc={"date": datetime(1999, 12, 31, 23, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={"$millisecond": "$date"}, + expected=999, + msg="$millisecond should return 999 for the last moment of 1999", + ), + ExpressionTestCase( + "year_2099", + doc={"date": datetime(2099, 7, 15, 14, 22, 11, 789000, tzinfo=timezone.utc)}, + expression={"$millisecond": "$date"}, + expected=789, + msg="$millisecond should return 789 for a date in the year 2099", + ), + ExpressionTestCase( + "year_9999", + doc={"date": DATE_YEAR_9999}, + expression={"$millisecond": "$date"}, + expected=999, + msg="$millisecond should return 999 for the last representable year 9999", + ), +] + +# Property [Pre-Epoch]: negative-millisecond dates before 1970 resolve to the correct +# millisecond. +MILLISECOND_PRE_EPOCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "pre_epoch_1960", + doc={"date": datetime(1960, 3, 15, 14, 37, 22, 333000, tzinfo=timezone.utc)}, + expression={"$millisecond": "$date"}, + expected=333, + msg="$millisecond should return 333 for a pre-epoch date in 1960", + ), + ExpressionTestCase( + "pre_epoch_1900", + doc={"date": datetime(1900, 7, 4, 0, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$millisecond": "$date"}, + expected=0, + msg="$millisecond should return 0 for a pre-epoch date in 1900", + ), + ExpressionTestCase( + "pre_epoch_1969_dec", + doc={"date": DATE_BEFORE_EPOCH}, + expression={"$millisecond": "$date"}, + expected=999, + msg="$millisecond should return 999 for the last millisecond before the epoch", + ), + ExpressionTestCase( + "pre_epoch_1969_jan", + doc={"date": datetime(1969, 1, 1, 0, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$millisecond": "$date"}, + expected=0, + msg="$millisecond should return 0 for the first moment of 1969", + ), + ExpressionTestCase( + "pre_epoch_1952_leap", + doc={"date": datetime(1952, 2, 29, 7, 22, 48, 567000, tzinfo=timezone.utc)}, + expression={"$millisecond": "$date"}, + expected=567, + msg="$millisecond should return 567 for Feb 29 in the pre-epoch leap year 1952", + ), +] + +MILLISECOND_CLOCK_TESTS: list[ExpressionTestCase] = ( + MILLISECOND_EXTRACTION_TESTS + + MILLISECOND_BOUNDARY_TESTS + + MILLISECOND_YEAR_TESTS + + MILLISECOND_PRE_EPOCH_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(MILLISECOND_CLOCK_TESTS)) +def test_millisecond_clock(collection, test_case: ExpressionTestCase): + """Test $millisecond extraction across the second, 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/millisecond/test_millisecond_date_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/millisecond/test_millisecond_date_types.py new file mode 100644 index 000000000..fc3792460 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/millisecond/test_millisecond_date_types.py @@ -0,0 +1,225 @@ +"""Tests for $millisecond with Timestamp, ObjectId, and extended-range date inputs.""" + +import pytest +from bson import ObjectId, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils import ( + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.date_utils import ( + oid_from_args, + ts_from_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_MS_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 has only second-level precision, so its +# millisecond is always 0 regardless of the increment field. +MILLISECOND_TIMESTAMP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "timestamp_inc_0", + doc={"date": ts_from_args(2024, 6, 15, 12, 30, 0, inc=0)}, + expression={"$millisecond": "$date"}, + expected=0, + msg="$millisecond should return 0 for a Timestamp with a zero increment", + ), + ExpressionTestCase( + "timestamp_inc_1", + doc={"date": ts_from_args(2024, 6, 15, 12, 30, 0, inc=1)}, + expression={"$millisecond": "$date"}, + expected=0, + msg="$millisecond should ignore an increment of 1 and return 0", + ), + ExpressionTestCase( + "timestamp_inc_500", + doc={"date": ts_from_args(2024, 6, 15, 12, 30, 0, inc=500)}, + expression={"$millisecond": "$date"}, + expected=0, + msg="$millisecond should ignore a mid-range increment and return 0", + ), + ExpressionTestCase( + "timestamp_inc_999", + doc={"date": ts_from_args(2024, 6, 15, 12, 30, 0, inc=999)}, + expression={"$millisecond": "$date"}, + expected=0, + msg="$millisecond should not treat a 999 increment as milliseconds", + ), + ExpressionTestCase( + "timestamp_inc_1000", + doc={"date": ts_from_args(2024, 6, 15, 12, 30, 0, inc=1000)}, + expression={"$millisecond": "$date"}, + expected=0, + msg="$millisecond should ignore an increment above 999 and return 0", + ), + ExpressionTestCase( + "timestamp_inc_large", + doc={"date": ts_from_args(2024, 6, 15, 12, 30, 0, inc=999999)}, + expression={"$millisecond": "$date"}, + expected=0, + msg="$millisecond should ignore a large increment and return 0", + ), + ExpressionTestCase( + "timestamp_inc_max32", + doc={"date": Timestamp(1718451000, 2**31 - 1)}, + expression={"$millisecond": "$date"}, + expected=0, + msg="$millisecond should ignore the maximum signed 32-bit increment and return 0", + ), + ExpressionTestCase( + "timestamp_with_seconds", + doc={"date": ts_from_args(2024, 6, 15, 12, 30, 59)}, + expression={"$millisecond": "$date"}, + expected=0, + msg="$millisecond should return 0 for a Timestamp at a non-zero second", + ), + ExpressionTestCase( + "timestamp_epoch", + doc={"date": ts_from_args(1970, 1, 1, 0, 0, 0)}, + expression={"$millisecond": "$date"}, + expected=0, + msg="$millisecond should return 0 for a Timestamp at the epoch", + ), +] + +# Property [ObjectId Input]: an ObjectId embeds only a second-resolution timestamp, so its +# millisecond is always 0 regardless of the machine/process/counter tail. +MILLISECOND_OBJECTID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "objectid_zero_tail", + doc={"date": oid_from_args(2024, 6, 15, 12, 30, 0)}, + expression={"$millisecond": "$date"}, + expected=0, + msg="$millisecond should return 0 for an ObjectId with a zero tail", + ), + ExpressionTestCase( + "objectid_with_seconds", + doc={"date": oid_from_args(2024, 6, 15, 12, 30, 59)}, + expression={"$millisecond": "$date"}, + expected=0, + msg="$millisecond should return 0 for an ObjectId at a non-zero second", + ), + ExpressionTestCase( + "objectid_nonzero_tail", + doc={"date": ObjectId("666cd980ffffff0000000000")}, + expression={"$millisecond": "$date"}, + expected=0, + msg="$millisecond should ignore a non-zero ObjectId tail and return 0", + ), + ExpressionTestCase( + "objectid_all_f_tail", + doc={"date": ObjectId("666cd980ffffffffffffffff")}, + expression={"$millisecond": "$date"}, + expected=0, + msg="$millisecond should ignore an all-ones ObjectId tail and return 0", + ), + ExpressionTestCase( + "objectid_epoch", + doc={"date": oid_from_args(1970, 1, 1, 0, 0, 0)}, + expression={"$millisecond": "$date"}, + expected=0, + msg="$millisecond 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 millisecond. +MILLISECOND_EXTENDED_RANGE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_ms_epoch", + doc={"date": DATE_MS_EPOCH}, + expression={"$millisecond": "$date"}, + expected=0, + msg="$millisecond should return 0 for the epoch as a DatetimeMS", + ), + ExpressionTestCase( + "date_ms_before_epoch", + doc={"date": DATE_MS_BEFORE_EPOCH}, + expression={"$millisecond": "$date"}, + expected=999, + msg="$millisecond should return 999 for a DatetimeMS one millisecond before the epoch", + ), + ExpressionTestCase( + "date_ms_year_10000", + doc={"date": DATE_MS_YEAR_10000}, + expression={"$millisecond": "$date"}, + expected=0, + msg="$millisecond should return 0 for a DatetimeMS at the year-10000 boundary", + ), + ExpressionTestCase( + "date_ms_max", + doc={"date": DATE_MS_MAX}, + expression={"$millisecond": "$date"}, + expected=807, + msg="$millisecond should return 807 for the maximum 64-bit DatetimeMS", + ), + ExpressionTestCase( + "date_ms_min", + doc={"date": DATE_MS_MIN}, + expression={"$millisecond": "$date"}, + expected=192, + msg="$millisecond should return 192 for the minimum 64-bit DatetimeMS", + ), + ExpressionTestCase( + "ts_boundary_max_s32", + doc={"date": TS_MAX_SIGNED32}, + expression={"$millisecond": "$date"}, + expected=0, + msg="$millisecond should return 0 for the max signed 32-bit Timestamp", + ), + ExpressionTestCase( + "ts_boundary_max_u32", + doc={"date": TS_MAX_UNSIGNED32}, + expression={"$millisecond": "$date"}, + expected=0, + msg="$millisecond should return 0 for the max unsigned 32-bit Timestamp", + ), + ExpressionTestCase( + "oid_boundary_max_s32", + doc={"date": OID_MAX_SIGNED32}, + expression={"$millisecond": "$date"}, + expected=0, + msg="$millisecond should return 0 for the max signed 32-bit ObjectId", + ), + ExpressionTestCase( + "oid_boundary_min_s32", + doc={"date": OID_MIN_SIGNED32}, + expression={"$millisecond": "$date"}, + expected=0, + msg="$millisecond should return 0 for the min signed 32-bit ObjectId", + ), + ExpressionTestCase( + "oid_boundary_max_u32", + doc={"date": OID_MAX_UNSIGNED32}, + expression={"$millisecond": "$date"}, + expected=0, + msg="$millisecond should return 0 for the max unsigned 32-bit ObjectId", + ), +] + +MILLISECOND_DATE_TYPES_TESTS: list[ExpressionTestCase] = ( + MILLISECOND_TIMESTAMP_TESTS + MILLISECOND_OBJECTID_TESTS + MILLISECOND_EXTENDED_RANGE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(MILLISECOND_DATE_TYPES_TESTS)) +def test_millisecond_date_types(collection, test_case: ExpressionTestCase): + """Test $millisecond 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/millisecond/test_millisecond_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/millisecond/test_millisecond_expressions.py new file mode 100644 index 000000000..ea19c4139 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/millisecond/test_millisecond_expressions.py @@ -0,0 +1,172 @@ +"""Tests for $millisecond argument forms, field-path resolution, expression input, and 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 [Literal Input]: an inline literal date computes the correct millisecond. +MILLISECOND_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_date", + expression={"$millisecond": datetime(2024, 6, 15, 12, 0, 0, 500000, tzinfo=timezone.utc)}, + expected=500, + msg="$millisecond should return the millisecond 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. +MILLISECOND_ARGUMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_date", + expression={"$millisecond": {"timezone": "UTC"}}, + error_code=ISO_DATE_MISSING_DATE_ERROR, + msg="$millisecond should error when the document form omits the date field", + ), + ExpressionTestCase( + "extra_field", + expression={ + "$millisecond": { + "date": datetime(2024, 6, 15, 12, 0, 0, 500000, tzinfo=timezone.utc), + "extra": 1, + } + }, + error_code=ISO_DATE_UNKNOWN_FIELD_ERROR, + msg="$millisecond should error for an unknown field in the document form", + ), + ExpressionTestCase( + "empty_array", + expression={"$millisecond": []}, + error_code=ISO_DATE_INVALID_ARRAY_INPUT_ERROR, + msg="$millisecond should error for an empty operand array", + ), + ExpressionTestCase( + "two_element_array", + expression={ + "$millisecond": [ + datetime(2024, 6, 15, 12, 0, 0, 500000, tzinfo=timezone.utc), + datetime(2024, 6, 16, 12, 0, 0, 250000, tzinfo=timezone.utc), + ] + }, + error_code=ISO_DATE_INVALID_ARRAY_INPUT_ERROR, + msg="$millisecond should error for a two-element operand array", + ), + ExpressionTestCase( + "single_element_array", + expression={"$millisecond": [datetime(2024, 6, 15, 12, 0, 0, 500000, tzinfo=timezone.utc)]}, + expected=500, + msg="$millisecond 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, 500000, tzinfo=timezone.utc)}, + expression={"$millisecond": ["$date"]}, + expected=500, + msg="$millisecond should accept a single-element operand array holding a field reference", + ), + ExpressionTestCase( + "object_expression_input", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, 500000, tzinfo=timezone.utc)}, + expression={"$millisecond": {"a": "$date"}}, + error_code=ISO_DATE_UNKNOWN_FIELD_ERROR, + msg="$millisecond 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. +MILLISECOND_FIELD_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_path", + doc={"a": {"b": datetime(2024, 6, 15, 12, 0, 0, 500000, tzinfo=timezone.utc)}}, + expression={"$millisecond": "$a.b"}, + expected=500, + msg="$millisecond should accept a date resolved from a nested field path", + ), + ExpressionTestCase( + "array_index_path", + doc={"a": [{"b": datetime(2024, 6, 15, 12, 0, 0, 500000, tzinfo=timezone.utc)}]}, + expression={"$millisecond": "$a.0.b"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$millisecond 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, 500000, tzinfo=timezone.utc)}, + {"b": datetime(2024, 6, 15, 12, 0, 0, 250000, tzinfo=timezone.utc)}, + ] + }, + expression={"$millisecond": "$a.b"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$millisecond should error when a path over an array of objects resolves to an array", + ), + ExpressionTestCase( + "timezone_from_field", + doc={ + "date": datetime(2024, 6, 15, 12, 0, 0, 500000, tzinfo=timezone.utc), + "tz": "America/New_York", + }, + expression={"$millisecond": {"date": "$date", "timezone": "$tz"}}, + expected=500, + msg="$millisecond should apply a timezone resolved from a field reference", + ), + ExpressionTestCase( + "missing_tz_field_ref", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, 500000, tzinfo=timezone.utc)}, + expression={"$millisecond": {"date": "$date", "timezone": "$tz"}}, + expected=None, + msg="$millisecond should return null when the timezone field reference is missing", + ), + ExpressionTestCase( + "expression_as_input", + expression={ + "$millisecond": {"$dateFromString": {"dateString": "2024-06-15T12:00:00.500Z"}} + }, + expected=500, + msg="$millisecond should accept the result of a nested expression as the date", + ), +] + +# Property [Return Type]: $millisecond returns a value of BSON type int. +MILLISECOND_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, 500000, tzinfo=timezone.utc)}, + expression={"$type": {"$millisecond": "$date"}}, + expected="int", + msg="$millisecond should return an int", + ), +] + +MILLISECOND_EXPRESSION_TESTS: list[ExpressionTestCase] = ( + MILLISECOND_LITERAL_TESTS + + MILLISECOND_ARGUMENT_TESTS + + MILLISECOND_FIELD_PATH_TESTS + + MILLISECOND_RETURN_TYPE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(MILLISECOND_EXPRESSION_TESTS)) +def test_millisecond_expressions(collection, test_case: ExpressionTestCase): + """Test $millisecond argument forms, field-path resolution, and expression input.""" + 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/millisecond/test_millisecond_null_and_type_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/millisecond/test_millisecond_null_and_type_errors.py new file mode 100644 index 000000000..4be664872 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/millisecond/test_millisecond_null_and_type_errors.py @@ -0,0 +1,211 @@ +"""Tests for $millisecond 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. +MILLISECOND_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_date", + doc={"date": None}, + expression={"$millisecond": "$date"}, + expected=None, + msg="$millisecond should return null for a null date", + ), + ExpressionTestCase( + "missing_date", + expression={"$millisecond": MISSING}, + expected=None, + msg="$millisecond 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. +MILLISECOND_TYPE_REJECTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_date", + doc={"date": "not-a-date"}, + expression={"$millisecond": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$millisecond should reject a string as the date input", + ), + ExpressionTestCase( + "empty_string_date", + doc={"date": ""}, + expression={"$millisecond": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$millisecond should reject an empty string as the date input", + ), + ExpressionTestCase( + "integer_date", + doc={"date": 42}, + expression={"$millisecond": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$millisecond should reject an int as the date input", + ), + ExpressionTestCase( + "int64_date", + doc={"date": Int64(42)}, + expression={"$millisecond": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$millisecond should reject an int64 as the date input", + ), + ExpressionTestCase( + "double_date", + doc={"date": 3.14}, + expression={"$millisecond": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$millisecond should reject a double as the date input", + ), + ExpressionTestCase( + "decimal128_date", + doc={"date": Decimal128("42")}, + expression={"$millisecond": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$millisecond should reject a decimal128 as the date input", + ), + ExpressionTestCase( + "boolean_date", + doc={"date": True}, + expression={"$millisecond": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$millisecond should reject a boolean as the date input", + ), + ExpressionTestCase( + "array_date", + doc={"date": [1, 2, 3]}, + expression={"$millisecond": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$millisecond should reject an array as the date input", + ), + ExpressionTestCase( + "empty_array_date", + doc={"date": []}, + expression={"$millisecond": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$millisecond should reject an empty array as the date input", + ), + ExpressionTestCase( + "object_date", + doc={"date": {"a": 1}}, + expression={"$millisecond": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$millisecond should reject an object as the date input", + ), + ExpressionTestCase( + "empty_object_date", + doc={"date": {}}, + expression={"$millisecond": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$millisecond should reject an empty object as the date input", + ), + ExpressionTestCase( + "float_nan_date", + doc={"date": FLOAT_NAN}, + expression={"$millisecond": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$millisecond should reject a float NaN as the date input", + ), + ExpressionTestCase( + "decimal128_nan_date", + doc={"date": DECIMAL128_NAN}, + expression={"$millisecond": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$millisecond should reject a decimal128 NaN as the date input", + ), + ExpressionTestCase( + "float_inf_date", + doc={"date": FLOAT_INFINITY}, + expression={"$millisecond": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$millisecond should reject a float infinity as the date input", + ), + ExpressionTestCase( + "float_neg_inf_date", + doc={"date": FLOAT_NEGATIVE_INFINITY}, + expression={"$millisecond": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$millisecond should reject a float negative infinity as the date input", + ), + ExpressionTestCase( + "decimal128_inf_date", + doc={"date": DECIMAL128_INFINITY}, + expression={"$millisecond": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$millisecond should reject a decimal128 infinity as the date input", + ), + ExpressionTestCase( + "decimal128_neg_inf_date", + doc={"date": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$millisecond": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$millisecond should reject a decimal128 negative infinity as the date input", + ), + ExpressionTestCase( + "regex_date", + doc={"date": Regex(".*")}, + expression={"$millisecond": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$millisecond should reject a regex as the date input", + ), + ExpressionTestCase( + "minkey_date", + doc={"date": MinKey()}, + expression={"$millisecond": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$millisecond should reject MinKey as the date input", + ), + ExpressionTestCase( + "maxkey_date", + doc={"date": MaxKey()}, + expression={"$millisecond": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$millisecond should reject MaxKey as the date input", + ), + ExpressionTestCase( + "bindata_date", + doc={"date": Binary(b"")}, + expression={"$millisecond": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$millisecond should reject binary data as the date input", + ), + ExpressionTestCase( + "javascript_date", + doc={"date": Code("function(){}")}, + expression={"$millisecond": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$millisecond should reject JavaScript code as the date input", + ), +] + +MILLISECOND_NULL_AND_TYPE_ERROR_TESTS: list[ExpressionTestCase] = ( + MILLISECOND_NULL_TESTS + MILLISECOND_TYPE_REJECTION_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(MILLISECOND_NULL_AND_TYPE_ERROR_TESTS)) +def test_millisecond_null_and_type_errors(collection, test_case: ExpressionTestCase): + """Test $millisecond 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/millisecond/test_millisecond_timezone_input_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/millisecond/test_millisecond_timezone_input_types.py new file mode 100644 index 000000000..3f8aef974 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/millisecond/test_millisecond_timezone_input_types.py @@ -0,0 +1,131 @@ +"""Tests for $millisecond 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 has only second-level precision, so the +# millisecond is always 0 regardless of the requested zone. +MILLISECOND_TIMESTAMP_ZONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_olson_ts_utc", + expression={ + "$millisecond": {"date": ts_from_args(2024, 7, 15, 12, 25, 37), "timezone": "UTC"} + }, + expected=0, + msg="$millisecond should return 0 for a Timestamp in UTC", + ), + ExpressionTestCase( + "tz_olson_ts_tokyo", + expression={ + "$millisecond": { + "date": ts_from_args(2024, 7, 15, 12, 25, 37), + "timezone": "Asia/Tokyo", + } + }, + expected=0, + msg="$millisecond should return 0 for a Timestamp in Asia/Tokyo", + ), + ExpressionTestCase( + "tz_olson_ts_kolkata", + expression={ + "$millisecond": { + "date": ts_from_args(2024, 3, 31, 20, 0, 37), + "timezone": "Asia/Kolkata", + } + }, + expected=0, + msg="$millisecond should return 0 for a Timestamp in Asia/Kolkata (+05:30)", + ), + ExpressionTestCase( + "tz_offset_ts_plus9", + expression={ + "$millisecond": {"date": ts_from_args(2024, 7, 15, 12, 25, 37), "timezone": "+09:00"} + }, + expected=0, + msg="$millisecond should return 0 for a Timestamp at a +09:00 offset", + ), + ExpressionTestCase( + "tz_offset_ts_plus530", + expression={ + "$millisecond": {"date": ts_from_args(2024, 3, 31, 20, 0, 37), "timezone": "+05:30"} + }, + expected=0, + msg="$millisecond should return 0 for a Timestamp at a +05:30 offset", + ), +] + +# Property [ObjectId Input with Zones]: an ObjectId embeds only a second-resolution timestamp, +# so the millisecond is always 0 regardless of the requested zone. +MILLISECOND_OBJECTID_ZONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_olson_oid_utc", + expression={ + "$millisecond": {"date": oid_from_args(2024, 7, 15, 12, 25, 37), "timezone": "UTC"} + }, + expected=0, + msg="$millisecond should return 0 for an ObjectId in UTC", + ), + ExpressionTestCase( + "tz_olson_oid_tokyo", + expression={ + "$millisecond": { + "date": oid_from_args(2024, 7, 15, 12, 25, 37), + "timezone": "Asia/Tokyo", + } + }, + expected=0, + msg="$millisecond should return 0 for an ObjectId in Asia/Tokyo", + ), + ExpressionTestCase( + "tz_olson_oid_kolkata", + expression={ + "$millisecond": { + "date": oid_from_args(2024, 3, 31, 20, 0, 37), + "timezone": "Asia/Kolkata", + } + }, + expected=0, + msg="$millisecond should return 0 for an ObjectId in Asia/Kolkata (+05:30)", + ), + ExpressionTestCase( + "tz_offset_oid_plus9", + expression={ + "$millisecond": {"date": oid_from_args(2024, 7, 15, 12, 25, 37), "timezone": "+09:00"} + }, + expected=0, + msg="$millisecond should return 0 for an ObjectId at a +09:00 offset", + ), + ExpressionTestCase( + "tz_offset_oid_plus530", + expression={ + "$millisecond": {"date": oid_from_args(2024, 3, 31, 20, 0, 37), "timezone": "+05:30"} + }, + expected=0, + msg="$millisecond should return 0 for an ObjectId at a +05:30 offset", + ), +] + +MILLISECOND_TIMEZONE_INPUT_TYPES_TESTS: list[ExpressionTestCase] = ( + MILLISECOND_TIMESTAMP_ZONE_TESTS + MILLISECOND_OBJECTID_ZONE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(MILLISECOND_TIMEZONE_INPUT_TYPES_TESTS)) +def test_millisecond_timezone_input_types(collection, test_case: ExpressionTestCase): + """Test $millisecond 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/millisecond/test_millisecond_timezone_names.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/millisecond/test_millisecond_timezone_names.py new file mode 100644 index 000000000..9a80af655 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/millisecond/test_millisecond_timezone_names.py @@ -0,0 +1,127 @@ +"""Tests for $millisecond named-timezone application, including DST and fractional-offset zones.""" + +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 for parsing, but the +# millisecond is unchanged across whole-hour, fractional-offset, and DST-transition zones. +MILLISECOND_OLSON_DATETIME_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_olson_dt_utc", + expression={ + "$millisecond": { + "date": datetime(2024, 7, 15, 12, 25, 37, 456000, tzinfo=timezone.utc), + "timezone": "UTC", + } + }, + expected=456, + msg="$millisecond should return 456 for UTC", + ), + ExpressionTestCase( + "tz_olson_dt_ny", + expression={ + "$millisecond": { + "date": datetime(2024, 7, 15, 12, 25, 37, 456000, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=456, + msg="$millisecond should leave the millisecond at 456 for America/New_York", + ), + ExpressionTestCase( + "tz_olson_dt_tokyo", + expression={ + "$millisecond": { + "date": datetime(2024, 7, 15, 12, 25, 37, 456000, tzinfo=timezone.utc), + "timezone": "Asia/Tokyo", + } + }, + expected=456, + msg="$millisecond should leave the millisecond at 456 for Asia/Tokyo", + ), + ExpressionTestCase( + "tz_olson_dt_kolkata", + expression={ + "$millisecond": { + "date": datetime(2024, 3, 31, 20, 0, 37, 456000, tzinfo=timezone.utc), + "timezone": "Asia/Kolkata", + } + }, + expected=456, + msg="$millisecond should leave the millisecond at 456 for Asia/Kolkata (+05:30)", + ), + ExpressionTestCase( + "tz_olson_dt_kathmandu", + expression={ + "$millisecond": { + "date": datetime(2024, 8, 31, 23, 0, 37, 456000, tzinfo=timezone.utc), + "timezone": "Asia/Kathmandu", + } + }, + expected=456, + msg="$millisecond should leave the millisecond at 456 for Asia/Kathmandu (+05:45)", + ), + ExpressionTestCase( + "tz_olson_dt_ny_dst_spring", + expression={ + "$millisecond": { + "date": datetime(2024, 3, 10, 7, 17, 53, 789000, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=789, + msg="$millisecond should leave the millisecond at 789 across the spring DST transition", + ), + ExpressionTestCase( + "tz_olson_dt_london_bst", + expression={ + "$millisecond": { + "date": datetime(2024, 7, 15, 12, 0, 0, 456000, tzinfo=timezone.utc), + "timezone": "Europe/London", + } + }, + expected=456, + msg="$millisecond should leave the millisecond at 456 for Europe/London in summer (BST +1)", + ), + ExpressionTestCase( + "tz_olson_dt_pacific_apia", + expression={ + "$millisecond": { + "date": datetime(2024, 7, 15, 12, 0, 0, 456000, tzinfo=timezone.utc), + "timezone": "Pacific/Apia", + } + }, + expected=456, + msg="$millisecond should leave the millisecond at 456 for Pacific/Apia", + ), + ExpressionTestCase( + "tz_est_abbreviation", + expression={ + "$millisecond": { + "date": datetime(2024, 7, 15, 12, 0, 0, 456000, tzinfo=timezone.utc), + "timezone": "EST", + } + }, + expected=456, + msg="$millisecond should accept the EST three-letter abbreviation", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(MILLISECOND_OLSON_DATETIME_TESTS)) +def test_millisecond_timezone_names(collection, test_case: ExpressionTestCase): + """Test $millisecond named-timezone application across zones, DST, and fractional 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/millisecond/test_millisecond_timezone_offsets.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/millisecond/test_millisecond_timezone_offsets.py new file mode 100644 index 000000000..30d88e3e1 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/millisecond/test_millisecond_timezone_offsets.py @@ -0,0 +1,215 @@ +"""Tests for $millisecond UTC-offset timezone application, including 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 for +# parsing, but the millisecond is unchanged because every offset is a whole number of minutes. +MILLISECOND_OFFSET_DATETIME_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_offset_dt_plus0", + expression={ + "$millisecond": { + "date": datetime(2024, 7, 15, 12, 25, 37, 456000, tzinfo=timezone.utc), + "timezone": "+00:00", + } + }, + expected=456, + msg="$millisecond should return 456 for a +00:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus9", + expression={ + "$millisecond": { + "date": datetime(2024, 7, 15, 12, 25, 37, 456000, tzinfo=timezone.utc), + "timezone": "+09:00", + } + }, + expected=456, + msg="$millisecond should leave the millisecond at 456 for a +09:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_minus8", + expression={ + "$millisecond": { + "date": datetime(2024, 7, 15, 12, 25, 37, 456000, tzinfo=timezone.utc), + "timezone": "-08:00", + } + }, + expected=456, + msg="$millisecond should leave the millisecond at 456 for a -08:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus530", + expression={ + "$millisecond": { + "date": datetime(2024, 3, 31, 20, 0, 37, 456000, tzinfo=timezone.utc), + "timezone": "+05:30", + } + }, + expected=456, + msg="$millisecond should leave the millisecond at 456 for a +05:30 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus545", + expression={ + "$millisecond": { + "date": datetime(2024, 8, 31, 23, 0, 37, 456000, tzinfo=timezone.utc), + "timezone": "+05:45", + } + }, + expected=456, + msg="$millisecond should leave the millisecond at 456 for a +05:45 offset", + ), + ExpressionTestCase( + "tz_offset_dt_minus930", + expression={ + "$millisecond": { + "date": datetime(2024, 6, 15, 12, 0, 37, 456000, tzinfo=timezone.utc), + "timezone": "-09:30", + } + }, + expected=456, + msg="$millisecond should leave the millisecond at 456 for a -09:30 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus14", + expression={ + "$millisecond": { + "date": datetime(2024, 6, 30, 11, 33, 37, 456000, tzinfo=timezone.utc), + "timezone": "+14:00", + } + }, + expected=456, + msg="$millisecond should leave the millisecond at 456 for the extreme +14:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_minus12", + expression={ + "$millisecond": { + "date": datetime(2024, 7, 1, 11, 33, 37, 456000, tzinfo=timezone.utc), + "timezone": "-12:00", + } + }, + expected=456, + msg="$millisecond should leave the millisecond at 456 for the extreme -12:00 offset", + ), + ExpressionTestCase( + "tz_offset_no_colon", + expression={ + "$millisecond": { + "date": datetime(2024, 6, 15, 12, 0, 0, 500000, tzinfo=timezone.utc), + "timezone": "-0500", + } + }, + expected=500, + msg="$millisecond should accept a compact -0500 offset without a colon", + ), + ExpressionTestCase( + "tz_offset_hour_only", + expression={ + "$millisecond": { + "date": datetime(2024, 6, 15, 12, 0, 0, 500000, tzinfo=timezone.utc), + "timezone": "-08", + } + }, + expected=500, + msg="$millisecond should accept an hour-only -08 offset", + ), + ExpressionTestCase( + "tz_offset_minus13", + expression={ + "$millisecond": { + "date": datetime(2024, 7, 15, 12, 0, 0, 456000, tzinfo=timezone.utc), + "timezone": "-13:00", + } + }, + expected=456, + msg="$millisecond should accept a -13:00 offset", + ), + ExpressionTestCase( + "tz_offset_plus15", + expression={ + "$millisecond": { + "date": datetime(2024, 7, 15, 12, 0, 0, 456000, tzinfo=timezone.utc), + "timezone": "+15:00", + } + }, + expected=456, + msg="$millisecond should accept a +15:00 offset", + ), + ExpressionTestCase( + "tz_offset_minus0330", + expression={ + "$millisecond": { + "date": datetime(2024, 7, 15, 12, 0, 0, 456000, tzinfo=timezone.utc), + "timezone": "-03:30", + } + }, + expected=456, + msg="$millisecond should accept a -03:30 half-hour west offset", + ), + ExpressionTestCase( + "tz_offset_plus0570", + expression={ + "$millisecond": { + "date": datetime(2024, 7, 15, 12, 0, 0, 456000, tzinfo=timezone.utc), + "timezone": "+05:70", + } + }, + expected=456, + msg="$millisecond should accept a +05:70 (70-minute) offset", + ), + ExpressionTestCase( + "tz_offset_minus0570", + expression={ + "$millisecond": { + "date": datetime(2024, 7, 15, 12, 0, 0, 456000, tzinfo=timezone.utc), + "timezone": "-05:70", + } + }, + expected=456, + msg="$millisecond should accept a -05:70 (70-minute) offset", + ), + ExpressionTestCase( + "tz_offset_plus2500", + expression={ + "$millisecond": { + "date": datetime(2024, 7, 15, 12, 0, 0, 456000, tzinfo=timezone.utc), + "timezone": "+25:00", + } + }, + expected=456, + msg="$millisecond should accept a +25:00 (25-hour) offset", + ), + ExpressionTestCase( + "tz_offset_minus2500", + expression={ + "$millisecond": { + "date": datetime(2024, 7, 15, 12, 0, 0, 456000, tzinfo=timezone.utc), + "timezone": "-25:00", + } + }, + expected=456, + msg="$millisecond should accept a -25:00 (25-hour) offset", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(MILLISECOND_OFFSET_DATETIME_TESTS)) +def test_millisecond_timezone_offsets(collection, test_case: ExpressionTestCase): + """Test $millisecond 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/millisecond/test_millisecond_timezone_validation.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/millisecond/test_millisecond_timezone_validation.py new file mode 100644 index 000000000..0775228bb --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/millisecond/test_millisecond_timezone_validation.py @@ -0,0 +1,151 @@ +"""Tests for $millisecond 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. +MILLISECOND_TIMEZONE_VALIDATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "invalid_tz_olson", + expression={ + "$millisecond": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "Not/A_Timezone", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$millisecond should reject an unparseable Olson-like timezone string", + ), + ExpressionTestCase( + "invalid_tz_nonexistent_olson", + expression={ + "$millisecond": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "America/Nowhere", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$millisecond should reject a non-existent Olson timezone", + ), + ExpressionTestCase( + "invalid_tz_offset_format", + expression={ + "$millisecond": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "25:00", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$millisecond should reject a bare 25:00 offset without a sign", + ), + ExpressionTestCase( + "invalid_tz_numeric", + expression={ + "$millisecond": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "123", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$millisecond should reject a numeric-string timezone", + ), + ExpressionTestCase( + "invalid_tz_empty_string", + expression={ + "$millisecond": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$millisecond should reject an empty-string timezone", + ), + ExpressionTestCase( + "invalid_tz_olson_lowercase", + expression={ + "$millisecond": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "america/new_york", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$millisecond should reject an all-lowercase Olson name", + ), + ExpressionTestCase( + "invalid_tz_olson_uppercase", + expression={ + "$millisecond": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "AMERICA/NEW_YORK", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$millisecond should reject an all-uppercase Olson name", + ), + *[ + ExpressionTestCase( + f"invalid_tz_{tid}", + expression={ + "$millisecond": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": val, + } + }, + error_code=INVALID_TIMEZONE_TYPE_ERROR, + msg=f"$millisecond 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={ + "$millisecond": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": None, + } + }, + expected=None, + msg="$millisecond should return null for a null timezone", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(MILLISECOND_TIMEZONE_VALIDATION_TESTS)) +def test_millisecond_timezone_validation(collection, test_case: ExpressionTestCase): + """Test $millisecond 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 + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/minute/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/minute/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/minute/test_minute_clock.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/minute/test_minute_clock.py new file mode 100644 index 000000000..e4431d9cd --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/minute/test_minute_clock.py @@ -0,0 +1,220 @@ +"""Tests for $minute extraction across the hour, 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 [Minute Extraction]: $minute returns the minute component (0-59) of a UTC date. +MINUTE_EXTRACTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "minute_0", + doc={"date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}, + expression={"$minute": "$date"}, + expected=0, + msg="$minute should return 0 for the top of the hour", + ), + ExpressionTestCase( + "minute_1", + doc={"date": datetime(2024, 6, 15, 12, 1, 0, tzinfo=timezone.utc)}, + expression={"$minute": "$date"}, + expected=1, + msg="$minute should return 1 for one minute past the hour", + ), + ExpressionTestCase( + "minute_15", + doc={"date": datetime(2024, 6, 15, 12, 15, 0, tzinfo=timezone.utc)}, + expression={"$minute": "$date"}, + expected=15, + msg="$minute should return 15 at quarter past the hour", + ), + ExpressionTestCase( + "minute_30", + doc={"date": datetime(2024, 6, 15, 12, 30, 0, tzinfo=timezone.utc)}, + expression={"$minute": "$date"}, + expected=30, + msg="$minute should return 30 at half past the hour", + ), + ExpressionTestCase( + "minute_45", + doc={"date": datetime(2024, 6, 15, 12, 45, 0, tzinfo=timezone.utc)}, + expression={"$minute": "$date"}, + expected=45, + msg="$minute should return 45 at quarter to the hour", + ), + ExpressionTestCase( + "minute_59", + doc={"date": datetime(2024, 6, 15, 12, 59, 59, tzinfo=timezone.utc)}, + expression={"$minute": "$date"}, + expected=59, + msg="$minute should return 59 for the last minute of the hour", + ), +] + +# Property [Calendar Boundaries]: year edges, leap-year Feb 29, and sub-second precision +# resolve to the correct minute. +MINUTE_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "first_moment_of_year", + doc={"date": datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$minute": "$date"}, + expected=0, + msg="$minute 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={"$minute": "$date"}, + expected=59, + msg="$minute should return 59 for the last moment of the year", + ), + ExpressionTestCase( + "leap_year_feb_29", + doc={"date": datetime(2024, 2, 29, 15, 30, 0, tzinfo=timezone.utc)}, + expression={"$minute": "$date"}, + expected=30, + msg="$minute should return 30 for Feb 29 in a leap year", + ), + ExpressionTestCase( + "non_leap_year_feb_28", + doc={"date": datetime(2023, 2, 28, 15, 30, 0, tzinfo=timezone.utc)}, + expression={"$minute": "$date"}, + expected=30, + msg="$minute should return 30 for Feb 28 in a non-leap year", + ), + ExpressionTestCase( + "millisecond_before_next_minute", + doc={"date": datetime(2024, 6, 15, 12, 29, 59, 999000, tzinfo=timezone.utc)}, + expression={"$minute": "$date"}, + expected=29, + msg="$minute should return 29 one millisecond before the next minute", + ), + ExpressionTestCase( + "millisecond_after_minute_start", + doc={"date": datetime(2024, 6, 15, 12, 30, 0, 1000, tzinfo=timezone.utc)}, + expression={"$minute": "$date"}, + expected=30, + msg="$minute should return 30 one millisecond after the minute starts", + ), + ExpressionTestCase( + "millisecond_mid_minute", + doc={"date": datetime(2024, 6, 15, 12, 30, 30, 500000, tzinfo=timezone.utc)}, + expression={"$minute": "$date"}, + expected=30, + msg="$minute should return 30 for a mid-minute instant with milliseconds", + ), + ExpressionTestCase( + "millisecond_at_hour_boundary", + doc={"date": datetime(2024, 6, 15, 12, 59, 59, 999000, tzinfo=timezone.utc)}, + expression={"$minute": "$date"}, + expected=59, + msg="$minute should return 59 one millisecond before the next hour", + ), + ExpressionTestCase( + "millisecond_after_hour_start", + doc={"date": datetime(2024, 6, 15, 13, 0, 0, 1000, tzinfo=timezone.utc)}, + expression={"$minute": "$date"}, + expected=0, + msg="$minute should return 0 one millisecond after the hour starts", + ), +] + +# Property [Year Range]: the minute component is correct across a wide span of years. +MINUTE_YEAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "year_2000", + doc={"date": datetime(2000, 1, 1, 8, 45, 0, tzinfo=timezone.utc)}, + expression={"$minute": "$date"}, + expected=45, + msg="$minute should return 45 for a date in the year 2000", + ), + ExpressionTestCase( + "year_1970_epoch", + doc={"date": DATE_EPOCH}, + expression={"$minute": "$date"}, + expected=0, + msg="$minute should return 0 for the Unix epoch", + ), + ExpressionTestCase( + "year_1999", + doc={"date": datetime(1999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$minute": "$date"}, + expected=59, + msg="$minute should return 59 for the last moment of 1999", + ), + ExpressionTestCase( + "year_2099", + doc={"date": datetime(2099, 7, 15, 14, 22, 0, tzinfo=timezone.utc)}, + expression={"$minute": "$date"}, + expected=22, + msg="$minute should return 22 for a date in the year 2099", + ), + ExpressionTestCase( + "year_9999", + doc={"date": datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$minute": "$date"}, + expected=59, + msg="$minute should return 59 for the last representable year 9999", + ), +] + +# Property [Pre-Epoch]: negative-millisecond dates before 1970 resolve to the correct minute. +MINUTE_PRE_EPOCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "pre_epoch_1960", + doc={"date": datetime(1960, 3, 15, 14, 37, 0, tzinfo=timezone.utc)}, + expression={"$minute": "$date"}, + expected=37, + msg="$minute should return 37 for a pre-epoch date in 1960", + ), + ExpressionTestCase( + "pre_epoch_1900", + doc={"date": datetime(1900, 7, 4, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$minute": "$date"}, + expected=0, + msg="$minute 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={"$minute": "$date"}, + expected=59, + msg="$minute should return 59 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={"$minute": "$date"}, + expected=0, + msg="$minute should return 0 for the first moment of 1969", + ), + ExpressionTestCase( + "pre_epoch_1952_leap", + doc={"date": datetime(1952, 2, 29, 7, 22, 0, tzinfo=timezone.utc)}, + expression={"$minute": "$date"}, + expected=22, + msg="$minute should return 22 for Feb 29 in the pre-epoch leap year 1952", + ), +] + +MINUTE_CLOCK_TESTS: list[ExpressionTestCase] = ( + MINUTE_EXTRACTION_TESTS + MINUTE_BOUNDARY_TESTS + MINUTE_YEAR_TESTS + MINUTE_PRE_EPOCH_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(MINUTE_CLOCK_TESTS)) +def test_minute_clock(collection, test_case: ExpressionTestCase): + """Test $minute extraction across the hour, 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/minute/test_minute_date_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/minute/test_minute_date_types.py new file mode 100644 index 000000000..90db84161 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/minute/test_minute_date_types.py @@ -0,0 +1,187 @@ +"""Tests for $minute 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 minute. +MINUTE_TIMESTAMP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "timestamp_minute_0", + doc={"date": ts_from_args(2024, 6, 15, 12, 0, 0)}, + expression={"$minute": "$date"}, + expected=0, + msg="$minute should return 0 for a Timestamp at the top of the hour", + ), + ExpressionTestCase( + "timestamp_minute_30", + doc={"date": ts_from_args(2024, 6, 15, 12, 30, 0)}, + expression={"$minute": "$date"}, + expected=30, + msg="$minute should return 30 for a Timestamp at half past the hour", + ), + ExpressionTestCase( + "timestamp_minute_59", + doc={"date": ts_from_args(2024, 6, 15, 12, 59, 0)}, + expression={"$minute": "$date"}, + expected=59, + msg="$minute should return 59 for a Timestamp in the last minute", + ), + ExpressionTestCase( + "timestamp_zero_increment", + doc={"date": ts_from_args(2024, 6, 15, 8, 15, 0, inc=0)}, + expression={"$minute": "$date"}, + expected=15, + msg="$minute should return 15 for a Timestamp with a zero increment", + ), + ExpressionTestCase( + "timestamp_epoch", + doc={"date": ts_from_args(1970, 1, 1, 0, 0, 0)}, + expression={"$minute": "$date"}, + expected=0, + msg="$minute should return 0 for a Timestamp at the epoch", + ), +] + +# Property [ObjectId Input]: an ObjectId is accepted as a date via its embedded timestamp. +MINUTE_OBJECTID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "objectid_minute_0", + doc={"date": oid_from_args(2024, 6, 15, 12, 0, 0)}, + expression={"$minute": "$date"}, + expected=0, + msg="$minute should return 0 for an ObjectId at the top of the hour", + ), + ExpressionTestCase( + "objectid_minute_30", + doc={"date": oid_from_args(2024, 6, 15, 12, 30, 0)}, + expression={"$minute": "$date"}, + expected=30, + msg="$minute should return 30 for an ObjectId at half past the hour", + ), + ExpressionTestCase( + "objectid_minute_59", + doc={"date": oid_from_args(2024, 6, 15, 12, 59, 0)}, + expression={"$minute": "$date"}, + expected=59, + msg="$minute should return 59 for an ObjectId in the last minute", + ), + ExpressionTestCase( + "objectid_epoch", + doc={"date": oid_from_args(1970, 1, 1, 0, 0, 0)}, + expression={"$minute": "$date"}, + expected=0, + msg="$minute 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 minute. +MINUTE_EXTENDED_RANGE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_ms_epoch", + doc={"date": DATE_MS_EPOCH}, + expression={"$minute": "$date"}, + expected=0, + msg="$minute should return 0 for the epoch as a DatetimeMS", + ), + ExpressionTestCase( + "date_ms_before_epoch", + doc={"date": DATE_MS_BEFORE_EPOCH}, + expression={"$minute": "$date"}, + expected=59, + msg="$minute should return 59 for a DatetimeMS one millisecond before the epoch", + ), + ExpressionTestCase( + "date_ms_year_10000", + doc={"date": DATE_MS_YEAR_10000}, + expression={"$minute": "$date"}, + expected=0, + msg="$minute should return 0 for a DatetimeMS at the year-10000 boundary", + ), + ExpressionTestCase( + "date_ms_max", + doc={"date": DATE_MS_MAX}, + expression={"$minute": "$date"}, + expected=12, + msg="$minute should return 12 for the maximum 64-bit DatetimeMS", + ), + ExpressionTestCase( + "date_ms_min", + doc={"date": DATE_MS_MIN}, + expression={"$minute": "$date"}, + expected=47, + msg="$minute should return 47 for the minimum 64-bit DatetimeMS", + ), + ExpressionTestCase( + "ts_boundary_max_s32", + doc={"date": TS_MAX_SIGNED32}, + expression={"$minute": "$date"}, + expected=14, + msg="$minute should return 14 for the max signed 32-bit Timestamp", + ), + ExpressionTestCase( + "ts_boundary_max_u32", + doc={"date": TS_MAX_UNSIGNED32}, + expression={"$minute": "$date"}, + expected=28, + msg="$minute should return 28 for the max unsigned 32-bit Timestamp", + ), + ExpressionTestCase( + "oid_boundary_max_s32", + doc={"date": OID_MAX_SIGNED32}, + expression={"$minute": "$date"}, + expected=14, + msg="$minute should return 14 for the max signed 32-bit ObjectId", + ), + ExpressionTestCase( + "oid_boundary_min_s32", + doc={"date": OID_MIN_SIGNED32}, + expression={"$minute": "$date"}, + expected=45, + msg="$minute should return 45 for the min signed 32-bit ObjectId", + ), + ExpressionTestCase( + "oid_boundary_max_u32", + doc={"date": OID_MAX_UNSIGNED32}, + expression={"$minute": "$date"}, + expected=59, + msg="$minute should return 59 for the max unsigned 32-bit ObjectId", + ), +] + +MINUTE_DATE_TYPES_TESTS: list[ExpressionTestCase] = ( + MINUTE_TIMESTAMP_TESTS + MINUTE_OBJECTID_TESTS + MINUTE_EXTENDED_RANGE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(MINUTE_DATE_TYPES_TESTS)) +def test_minute_date_types(collection, test_case: ExpressionTestCase): + """Test $minute 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/minute/test_minute_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/minute/test_minute_expressions.py new file mode 100644 index 000000000..0262931bc --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/minute/test_minute_expressions.py @@ -0,0 +1,170 @@ +"""Tests for $minute 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 minute. +MINUTE_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_date", + expression={"$minute": datetime(2024, 6, 15, 12, 30, 0, tzinfo=timezone.utc)}, + expected=30, + msg="$minute should return the minute 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. +MINUTE_ARGUMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_date", + expression={"$minute": {"timezone": "UTC"}}, + error_code=ISO_DATE_MISSING_DATE_ERROR, + msg="$minute should error when the document form omits the date field", + ), + ExpressionTestCase( + "extra_field", + expression={ + "$minute": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "extra": 1, + } + }, + error_code=ISO_DATE_UNKNOWN_FIELD_ERROR, + msg="$minute should error for an unknown field in the document form", + ), + ExpressionTestCase( + "empty_array", + expression={"$minute": []}, + error_code=ISO_DATE_INVALID_ARRAY_INPUT_ERROR, + msg="$minute should error for an empty operand array", + ), + ExpressionTestCase( + "two_element_array", + expression={ + "$minute": [ + 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="$minute should error for a two-element operand array", + ), + ExpressionTestCase( + "single_element_array", + expression={"$minute": [datetime(2024, 6, 15, 12, 30, 0, tzinfo=timezone.utc)]}, + expected=30, + msg="$minute should accept a single-element operand array as the date", + ), + ExpressionTestCase( + "single_element_array_field_ref", + doc={"date": datetime(2024, 6, 15, 12, 30, 0, tzinfo=timezone.utc)}, + expression={"$minute": ["$date"]}, + expected=30, + msg="$minute should accept a single-element operand array holding a field reference", + ), + ExpressionTestCase( + "object_expression_input", + doc={"date": datetime(2024, 6, 15, 12, 30, 0, tzinfo=timezone.utc)}, + expression={"$minute": {"a": "$date"}}, + error_code=ISO_DATE_UNKNOWN_FIELD_ERROR, + msg="$minute 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. +MINUTE_FIELD_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_path", + doc={"a": {"b": datetime(2024, 6, 15, 12, 30, 0, tzinfo=timezone.utc)}}, + expression={"$minute": "$a.b"}, + expected=30, + msg="$minute should accept a date resolved from a nested field path", + ), + ExpressionTestCase( + "array_index_path", + doc={"a": [{"b": datetime(2024, 6, 15, 12, 30, 0, tzinfo=timezone.utc)}]}, + expression={"$minute": "$a.0.b"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$minute should error when an array-index path resolves to an array", + ), + ExpressionTestCase( + "composite_array_path", + doc={ + "a": [ + {"b": datetime(2024, 6, 15, 12, 30, 0, tzinfo=timezone.utc)}, + {"b": datetime(2024, 6, 15, 12, 45, 0, tzinfo=timezone.utc)}, + ] + }, + expression={"$minute": "$a.b"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$minute should error when a path over an array of objects resolves to an array", + ), + ExpressionTestCase( + "timezone_from_field", + doc={ + "date": datetime(2024, 6, 15, 12, 30, 0, tzinfo=timezone.utc), + "tz": "America/New_York", + }, + expression={"$minute": {"date": "$date", "timezone": "$tz"}}, + expected=30, + msg="$minute should apply a timezone resolved from a field reference", + ), + ExpressionTestCase( + "missing_tz_field_ref", + doc={"date": datetime(2024, 6, 15, 12, 30, 0, tzinfo=timezone.utc)}, + expression={"$minute": {"date": "$date", "timezone": "$tz"}}, + expected=None, + msg="$minute should return null when the timezone field reference is missing", + ), + ExpressionTestCase( + "expression_as_input", + expression={"$minute": {"$dateFromString": {"dateString": "2024-06-15T12:30:00Z"}}}, + expected=30, + msg="$minute should accept the result of a nested expression as the date", + ), +] + +# Property [Return Type]: $minute returns a value of BSON type int. +MINUTE_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type", + doc={"date": datetime(2024, 6, 15, 12, 30, 0, tzinfo=timezone.utc)}, + expression={"$type": {"$minute": "$date"}}, + expected="int", + msg="$minute should return an int", + ), +] + +MINUTE_EXPRESSION_TESTS: list[ExpressionTestCase] = ( + MINUTE_LITERAL_TESTS + + MINUTE_ARGUMENT_TESTS + + MINUTE_FIELD_PATH_TESTS + + MINUTE_RETURN_TYPE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(MINUTE_EXPRESSION_TESTS)) +def test_minute_expressions(collection, test_case: ExpressionTestCase): + """Test $minute 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/minute/test_minute_null_and_type_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/minute/test_minute_null_and_type_errors.py new file mode 100644 index 000000000..db4225d64 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/minute/test_minute_null_and_type_errors.py @@ -0,0 +1,211 @@ +"""Tests for $minute 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. +MINUTE_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_date", + doc={"date": None}, + expression={"$minute": "$date"}, + expected=None, + msg="$minute should return null for a null date", + ), + ExpressionTestCase( + "missing_date", + expression={"$minute": MISSING}, + expected=None, + msg="$minute 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. +MINUTE_TYPE_REJECTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_date", + doc={"date": "not-a-date"}, + expression={"$minute": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$minute should reject a string as the date input", + ), + ExpressionTestCase( + "integer_date", + doc={"date": 42}, + expression={"$minute": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$minute should reject an int as the date input", + ), + ExpressionTestCase( + "int64_date", + doc={"date": Int64(42)}, + expression={"$minute": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$minute should reject an int64 as the date input", + ), + ExpressionTestCase( + "double_date", + doc={"date": 3.14}, + expression={"$minute": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$minute should reject a double as the date input", + ), + ExpressionTestCase( + "decimal128_date", + doc={"date": Decimal128("42")}, + expression={"$minute": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$minute should reject a decimal128 as the date input", + ), + ExpressionTestCase( + "boolean_date", + doc={"date": True}, + expression={"$minute": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$minute should reject a boolean as the date input", + ), + ExpressionTestCase( + "array_date", + doc={"date": [1, 2, 3]}, + expression={"$minute": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$minute should reject an array as the date input", + ), + ExpressionTestCase( + "object_date", + doc={"date": {"a": 1}}, + expression={"$minute": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$minute should reject an object as the date input", + ), + ExpressionTestCase( + "empty_string_date", + doc={"date": ""}, + expression={"$minute": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$minute should reject an empty string as the date input", + ), + ExpressionTestCase( + "empty_array_date", + doc={"date": []}, + expression={"$minute": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$minute should reject an empty array as the date input", + ), + ExpressionTestCase( + "empty_object_date", + doc={"date": {}}, + expression={"$minute": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$minute should reject an empty object as the date input", + ), + ExpressionTestCase( + "float_nan_date", + doc={"date": FLOAT_NAN}, + expression={"$minute": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$minute should reject a float NaN as the date input", + ), + ExpressionTestCase( + "decimal128_nan_date", + doc={"date": DECIMAL128_NAN}, + expression={"$minute": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$minute should reject a decimal128 NaN as the date input", + ), + ExpressionTestCase( + "regex_date", + doc={"date": Regex(".*")}, + expression={"$minute": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$minute should reject a regex as the date input", + ), + ExpressionTestCase( + "minkey_date", + doc={"date": MinKey()}, + expression={"$minute": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$minute should reject MinKey as the date input", + ), + ExpressionTestCase( + "maxkey_date", + doc={"date": MaxKey()}, + expression={"$minute": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$minute should reject MaxKey as the date input", + ), + ExpressionTestCase( + "float_inf_date", + doc={"date": FLOAT_INFINITY}, + expression={"$minute": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$minute should reject a float infinity as the date input", + ), + ExpressionTestCase( + "float_neg_inf_date", + doc={"date": FLOAT_NEGATIVE_INFINITY}, + expression={"$minute": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$minute should reject a float negative infinity as the date input", + ), + ExpressionTestCase( + "decimal128_inf_date", + doc={"date": DECIMAL128_INFINITY}, + expression={"$minute": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$minute should reject a decimal128 infinity as the date input", + ), + ExpressionTestCase( + "decimal128_neg_inf_date", + doc={"date": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$minute": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$minute should reject a decimal128 negative infinity as the date input", + ), + ExpressionTestCase( + "bindata_date", + doc={"date": Binary(b"")}, + expression={"$minute": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$minute should reject binary data as the date input", + ), + ExpressionTestCase( + "javascript_date", + doc={"date": Code("function(){}")}, + expression={"$minute": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$minute should reject JavaScript code as the date input", + ), +] + +MINUTE_NULL_AND_TYPE_ERROR_TESTS: list[ExpressionTestCase] = ( + MINUTE_NULL_TESTS + MINUTE_TYPE_REJECTION_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(MINUTE_NULL_AND_TYPE_ERROR_TESTS)) +def test_minute_null_and_type_errors(collection, test_case: ExpressionTestCase): + """Test $minute 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/minute/test_minute_timezone_input_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/minute/test_minute_timezone_input_types.py new file mode 100644 index 000000000..82c1949b6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/minute/test_minute_timezone_input_types.py @@ -0,0 +1,189 @@ +"""Tests for $minute 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, +# leaving whole-hour offsets unchanged and shifting fractional ones. +MINUTE_TIMESTAMP_ZONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_olson_ts_utc", + expression={"$minute": {"date": ts_from_args(2024, 7, 15, 12, 25, 0), "timezone": "UTC"}}, + expected=25, + msg="$minute should return 25 for a Timestamp in UTC", + ), + ExpressionTestCase( + "tz_olson_ts_tokyo", + expression={ + "$minute": {"date": ts_from_args(2024, 7, 15, 12, 25, 0), "timezone": "Asia/Tokyo"} + }, + expected=25, + msg="$minute should leave the minute at 25 for a Timestamp in whole-hour Asia/Tokyo", + ), + ExpressionTestCase( + "tz_olson_ts_kolkata_on_hour", + expression={ + "$minute": {"date": ts_from_args(2024, 3, 31, 20, 0, 0), "timezone": "Asia/Kolkata"} + }, + expected=30, + msg="$minute should return 30 for a Timestamp in Asia/Kolkata (+05:30)", + ), + ExpressionTestCase( + "tz_olson_ts_kolkata_45", + expression={ + "$minute": {"date": ts_from_args(2024, 6, 15, 20, 45, 0), "timezone": "Asia/Kolkata"} + }, + expected=15, + msg="$minute should return 15 for a Timestamp in Asia/Kolkata (+05:30) wrapping the hour", + ), + ExpressionTestCase( + "tz_olson_ts_kathmandu_on_hour", + expression={ + "$minute": {"date": ts_from_args(2024, 8, 31, 23, 0, 0), "timezone": "Asia/Kathmandu"} + }, + expected=45, + msg="$minute should return 45 for a Timestamp in Asia/Kathmandu (+05:45)", + ), + ExpressionTestCase( + "tz_olson_ts_kathmandu_20", + expression={ + "$minute": {"date": ts_from_args(2024, 6, 15, 23, 20, 0), "timezone": "Asia/Kathmandu"} + }, + expected=5, + msg="$minute should return 5 for a Timestamp in Asia/Kathmandu (+05:45) wrapping the hour", + ), + ExpressionTestCase( + "tz_offset_ts_plus9", + expression={ + "$minute": {"date": ts_from_args(2024, 7, 15, 12, 25, 0), "timezone": "+09:00"} + }, + expected=25, + msg="$minute should leave the minute at 25 for a Timestamp at a whole-hour +09:00 offset", + ), + ExpressionTestCase( + "tz_offset_ts_plus530_on_hour", + expression={"$minute": {"date": ts_from_args(2024, 3, 31, 20, 0, 0), "timezone": "+05:30"}}, + expected=30, + msg="$minute should return 30 for a Timestamp at a +05:30 offset on the hour", + ), + ExpressionTestCase( + "tz_offset_ts_plus530_wrap", + expression={ + "$minute": {"date": ts_from_args(2024, 6, 15, 20, 45, 0), "timezone": "+05:30"} + }, + expected=15, + msg="$minute should return 15 for a Timestamp at a +05:30 offset wrapping the hour", + ), + ExpressionTestCase( + "tz_offset_ts_plus545_on_hour", + expression={"$minute": {"date": ts_from_args(2024, 8, 31, 23, 0, 0), "timezone": "+05:45"}}, + expected=45, + msg="$minute should return 45 for a Timestamp at a +05:45 offset on the hour", + ), + ExpressionTestCase( + "tz_offset_ts_minus930_on_hour", + expression={"$minute": {"date": ts_from_args(2024, 6, 15, 12, 0, 0), "timezone": "-09:30"}}, + expected=30, + msg="$minute should return 30 for a Timestamp at a -09:30 offset on the hour", + ), +] + +# Property [ObjectId Input with Zones]: an ObjectId input honours the zone offset, +# leaving whole-hour offsets unchanged and shifting fractional ones. +MINUTE_OBJECTID_ZONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_olson_oid_utc", + expression={"$minute": {"date": oid_from_args(2024, 7, 15, 12, 25, 0), "timezone": "UTC"}}, + expected=25, + msg="$minute should return 25 for an ObjectId in UTC", + ), + ExpressionTestCase( + "tz_olson_oid_tokyo", + expression={ + "$minute": {"date": oid_from_args(2024, 7, 15, 12, 25, 0), "timezone": "Asia/Tokyo"} + }, + expected=25, + msg="$minute should leave the minute at 25 for an ObjectId in whole-hour Asia/Tokyo", + ), + ExpressionTestCase( + "tz_olson_oid_kolkata_on_hour", + expression={ + "$minute": {"date": oid_from_args(2024, 3, 31, 20, 0, 0), "timezone": "Asia/Kolkata"} + }, + expected=30, + msg="$minute should return 30 for an ObjectId in Asia/Kolkata (+05:30)", + ), + ExpressionTestCase( + "tz_olson_oid_kolkata_45", + expression={ + "$minute": {"date": oid_from_args(2024, 6, 15, 20, 45, 0), "timezone": "Asia/Kolkata"} + }, + expected=15, + msg="$minute should return 15 for an ObjectId in Asia/Kolkata (+05:30) wrapping the hour", + ), + ExpressionTestCase( + "tz_olson_oid_kathmandu_on_hour", + expression={ + "$minute": {"date": oid_from_args(2024, 8, 31, 23, 0, 0), "timezone": "Asia/Kathmandu"} + }, + expected=45, + msg="$minute should return 45 for an ObjectId in Asia/Kathmandu (+05:45)", + ), + ExpressionTestCase( + "tz_offset_oid_plus9", + expression={ + "$minute": {"date": oid_from_args(2024, 7, 15, 12, 25, 0), "timezone": "+09:00"} + }, + expected=25, + msg="$minute should leave the minute at 25 for an ObjectId at a whole-hour +09:00 offset", + ), + ExpressionTestCase( + "tz_offset_oid_plus530_on_hour", + expression={ + "$minute": {"date": oid_from_args(2024, 3, 31, 20, 0, 0), "timezone": "+05:30"} + }, + expected=30, + msg="$minute should return 30 for an ObjectId at a +05:30 offset on the hour", + ), + ExpressionTestCase( + "tz_offset_oid_plus545_on_hour", + expression={ + "$minute": {"date": oid_from_args(2024, 8, 31, 23, 0, 0), "timezone": "+05:45"} + }, + expected=45, + msg="$minute should return 45 for an ObjectId at a +05:45 offset on the hour", + ), + ExpressionTestCase( + "tz_offset_oid_minus930_on_hour", + expression={ + "$minute": {"date": oid_from_args(2024, 6, 15, 12, 0, 0), "timezone": "-09:30"} + }, + expected=30, + msg="$minute should return 30 for an ObjectId at a -09:30 offset on the hour", + ), +] + +MINUTE_TIMEZONE_INPUT_TYPES_TESTS: list[ExpressionTestCase] = ( + MINUTE_TIMESTAMP_ZONE_TESTS + MINUTE_OBJECTID_ZONE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(MINUTE_TIMEZONE_INPUT_TYPES_TESTS)) +def test_minute_timezone_input_types(collection, test_case: ExpressionTestCase): + """Test $minute 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/minute/test_minute_timezone_names.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/minute/test_minute_timezone_names.py new file mode 100644 index 000000000..c6dc0b354 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/minute/test_minute_timezone_names.py @@ -0,0 +1,270 @@ +"""Tests for $minute named-timezone application, including DST and fractional-offset zones.""" + +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 minute is +# taken; whole-hour zones leave the minute unchanged while fractional-offset zones change it. +MINUTE_OLSON_DATETIME_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_olson_dt_utc", + expression={ + "$minute": { + "date": datetime(2024, 7, 15, 12, 25, 0, tzinfo=timezone.utc), + "timezone": "UTC", + } + }, + expected=25, + msg="$minute should return 25 for UTC", + ), + ExpressionTestCase( + "tz_olson_dt_ny", + expression={ + "$minute": { + "date": datetime(2024, 7, 15, 12, 25, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=25, + msg="$minute should leave the minute at 25 for the whole-hour America/New_York offset", + ), + ExpressionTestCase( + "tz_olson_dt_tokyo", + expression={ + "$minute": { + "date": datetime(2024, 7, 15, 12, 25, 0, tzinfo=timezone.utc), + "timezone": "Asia/Tokyo", + } + }, + expected=25, + msg="$minute should leave the minute at 25 for the whole-hour Asia/Tokyo offset", + ), + ExpressionTestCase( + "tz_olson_dt_kolkata_on_hour", + expression={ + "$minute": { + "date": datetime(2024, 3, 31, 20, 0, 0, tzinfo=timezone.utc), + "timezone": "Asia/Kolkata", + } + }, + expected=30, + msg="$minute should return 30 for Asia/Kolkata (+05:30) applied on the hour", + ), + ExpressionTestCase( + "tz_olson_dt_kolkata_15", + expression={ + "$minute": { + "date": datetime(2024, 6, 15, 20, 15, 0, tzinfo=timezone.utc), + "timezone": "Asia/Kolkata", + } + }, + expected=45, + msg="$minute should return 45 for Asia/Kolkata (+05:30) at :15", + ), + ExpressionTestCase( + "tz_olson_dt_kolkata_45", + expression={ + "$minute": { + "date": datetime(2024, 6, 15, 20, 45, 0, tzinfo=timezone.utc), + "timezone": "Asia/Kolkata", + } + }, + expected=15, + msg="$minute should return 15 for Asia/Kolkata (+05:30) wrapping past the hour", + ), + ExpressionTestCase( + "tz_olson_dt_kathmandu_on_hour", + expression={ + "$minute": { + "date": datetime(2024, 8, 31, 23, 0, 0, tzinfo=timezone.utc), + "timezone": "Asia/Kathmandu", + } + }, + expected=45, + msg="$minute should return 45 for Asia/Kathmandu (+05:45) applied on the hour", + ), + ExpressionTestCase( + "tz_olson_dt_kathmandu_10", + expression={ + "$minute": { + "date": datetime(2024, 6, 15, 23, 10, 0, tzinfo=timezone.utc), + "timezone": "Asia/Kathmandu", + } + }, + expected=55, + msg="$minute should return 55 for Asia/Kathmandu (+05:45) at :10", + ), + ExpressionTestCase( + "tz_olson_dt_kathmandu_20", + expression={ + "$minute": { + "date": datetime(2024, 6, 15, 23, 20, 0, tzinfo=timezone.utc), + "timezone": "Asia/Kathmandu", + } + }, + expected=5, + msg="$minute should return 5 for Asia/Kathmandu (+05:45) wrapping past the hour", + ), + ExpressionTestCase( + "tz_olson_dt_kolkata_30", + expression={ + "$minute": { + "date": datetime(2024, 9, 30, 22, 30, 0, tzinfo=timezone.utc), + "timezone": "Asia/Kolkata", + } + }, + expected=0, + msg="$minute should return 0 for Asia/Kolkata (+05:30) landing on the hour", + ), + ExpressionTestCase( + "tz_olson_dt_ny_dst_spring", + expression={ + "$minute": { + "date": datetime(2024, 3, 10, 7, 17, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=17, + msg="$minute should leave the minute at 17 across the spring DST transition in New York", + ), + ExpressionTestCase( + "tz_olson_dt_ny_dst_fall", + expression={ + "$minute": { + "date": datetime(2024, 11, 3, 6, 42, 0, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=42, + msg="$minute should leave the minute at 42 across the fall DST transition in New York", + ), + ExpressionTestCase( + "tz_olson_dt_london_bst", + expression={ + "$minute": { + "date": datetime(2024, 7, 15, 12, 25, 0, tzinfo=timezone.utc), + "timezone": "Europe/London", + } + }, + expected=25, + msg="$minute should leave the minute at 25 for Europe/London in summer (BST +1)", + ), + ExpressionTestCase( + "tz_olson_dt_pacific_apia", + expression={ + "$minute": { + "date": datetime(2024, 7, 15, 12, 25, 0, tzinfo=timezone.utc), + "timezone": "Pacific/Apia", + } + }, + expected=25, + msg="$minute should leave the minute at 25 for the whole-hour Pacific/Apia offset", + ), + ExpressionTestCase( + "tz_est_abbreviation", + expression={ + "$minute": { + "date": datetime(2024, 7, 15, 12, 25, 0, tzinfo=timezone.utc), + "timezone": "EST", + } + }, + expected=25, + msg="$minute should accept the EST three-letter abbreviation", + ), + ExpressionTestCase( + "tz_olson_newfoundland_on_hour", + expression={ + "$minute": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "America/St_Johns", + } + }, + expected=30, + msg="$minute should return 30 for America/St_Johns (-03:30) applied on the hour", + ), + ExpressionTestCase( + "tz_olson_newfoundland_15", + expression={ + "$minute": { + "date": datetime(2024, 7, 15, 12, 15, 0, tzinfo=timezone.utc), + "timezone": "America/St_Johns", + } + }, + expected=45, + msg="$minute should return 45 for America/St_Johns (-03:30) at :15", + ), + ExpressionTestCase( + "tz_olson_newfoundland_45_wrap", + expression={ + "$minute": { + "date": datetime(2024, 7, 15, 12, 45, 0, tzinfo=timezone.utc), + "timezone": "America/St_Johns", + } + }, + expected=15, + msg="$minute should return 15 for America/St_Johns (-03:30) wrapping past the hour", + ), + ExpressionTestCase( + "tz_olson_chatham_on_hour", + expression={ + "$minute": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "Pacific/Chatham", + } + }, + expected=45, + msg="$minute should return 45 for Pacific/Chatham (+12:45) applied on the hour", + ), + ExpressionTestCase( + "tz_olson_chatham_20_wrap", + expression={ + "$minute": { + "date": datetime(2024, 7, 15, 12, 20, 0, tzinfo=timezone.utc), + "timezone": "Pacific/Chatham", + } + }, + expected=5, + msg="$minute should return 5 for Pacific/Chatham (+12:45) wrapping past the hour", + ), + ExpressionTestCase( + "tz_olson_marquesas_on_hour", + expression={ + "$minute": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "Pacific/Marquesas", + } + }, + expected=30, + msg="$minute should return 30 for Pacific/Marquesas (-09:30) applied on the hour", + ), + ExpressionTestCase( + "tz_olson_marquesas_40_wrap", + expression={ + "$minute": { + "date": datetime(2024, 7, 15, 12, 40, 0, tzinfo=timezone.utc), + "timezone": "Pacific/Marquesas", + } + }, + expected=10, + msg="$minute should return 10 for Pacific/Marquesas (-09:30) wrapping past the hour", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(MINUTE_OLSON_DATETIME_TESTS)) +def test_minute_timezone_names(collection, test_case: ExpressionTestCase): + """Test $minute named-timezone application across zones, DST, and fractional 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/minute/test_minute_timezone_offsets.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/minute/test_minute_timezone_offsets.py new file mode 100644 index 000000000..2fff8d83d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/minute/test_minute_timezone_offsets.py @@ -0,0 +1,281 @@ +"""Tests for $minute UTC-offset timezone application, including fractional 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; +# whole-hour offsets leave the minute unchanged while fractional offsets change it. +MINUTE_OFFSET_DATETIME_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_offset_dt_plus0", + expression={ + "$minute": { + "date": datetime(2024, 7, 15, 12, 25, 0, tzinfo=timezone.utc), + "timezone": "+00:00", + } + }, + expected=25, + msg="$minute should return 25 for a +00:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus9", + expression={ + "$minute": { + "date": datetime(2024, 7, 15, 12, 25, 0, tzinfo=timezone.utc), + "timezone": "+09:00", + } + }, + expected=25, + msg="$minute should leave the minute at 25 for a whole-hour +09:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_minus8", + expression={ + "$minute": { + "date": datetime(2024, 7, 15, 12, 25, 0, tzinfo=timezone.utc), + "timezone": "-08:00", + } + }, + expected=25, + msg="$minute should leave the minute at 25 for a whole-hour -08:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus14", + expression={ + "$minute": { + "date": datetime(2024, 6, 30, 11, 33, 0, tzinfo=timezone.utc), + "timezone": "+14:00", + } + }, + expected=33, + msg="$minute should leave the minute at 33 for the extreme +14:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_minus12", + expression={ + "$minute": { + "date": datetime(2024, 7, 1, 11, 33, 0, tzinfo=timezone.utc), + "timezone": "-12:00", + } + }, + expected=33, + msg="$minute should leave the minute at 33 for the extreme -12:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus530_on_hour", + expression={ + "$minute": { + "date": datetime(2024, 3, 31, 20, 0, 0, tzinfo=timezone.utc), + "timezone": "+05:30", + } + }, + expected=30, + msg="$minute should return 30 for a +05:30 offset applied on the hour", + ), + ExpressionTestCase( + "tz_offset_dt_plus530_wrap", + expression={ + "$minute": { + "date": datetime(2024, 6, 15, 20, 45, 0, tzinfo=timezone.utc), + "timezone": "+05:30", + } + }, + expected=15, + msg="$minute should return 15 for a +05:30 offset wrapping past the hour", + ), + ExpressionTestCase( + "tz_offset_dt_plus545_on_hour", + expression={ + "$minute": { + "date": datetime(2024, 8, 31, 23, 0, 0, tzinfo=timezone.utc), + "timezone": "+05:45", + } + }, + expected=45, + msg="$minute should return 45 for a +05:45 offset applied on the hour", + ), + ExpressionTestCase( + "tz_offset_dt_plus545_wrap", + expression={ + "$minute": { + "date": datetime(2024, 6, 15, 23, 20, 0, tzinfo=timezone.utc), + "timezone": "+05:45", + } + }, + expected=5, + msg="$minute should return 5 for a +05:45 offset wrapping past the hour", + ), + ExpressionTestCase( + "tz_offset_dt_minus930_on_hour", + expression={ + "$minute": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "-09:30", + } + }, + expected=30, + msg="$minute should return 30 for a -09:30 offset applied on the hour", + ), + ExpressionTestCase( + "tz_offset_dt_minus930_15", + expression={ + "$minute": { + "date": datetime(2024, 6, 15, 12, 15, 0, tzinfo=timezone.utc), + "timezone": "-09:30", + } + }, + expected=45, + msg="$minute should return 45 for a -09:30 offset at :15", + ), + ExpressionTestCase( + "tz_offset_dt_minus930_20", + expression={ + "$minute": { + "date": datetime(2024, 6, 15, 12, 20, 0, tzinfo=timezone.utc), + "timezone": "-09:30", + } + }, + expected=50, + msg="$minute should return 50 for a -09:30 offset at :20", + ), + ExpressionTestCase( + "tz_offset_eucla_on_hour", + expression={ + "$minute": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "+08:45", + } + }, + expected=45, + msg="$minute should return 45 for the +08:45 (Eucla) offset applied on the hour", + ), + ExpressionTestCase( + "tz_offset_eucla_30_wrap", + expression={ + "$minute": { + "date": datetime(2024, 7, 15, 12, 30, 0, tzinfo=timezone.utc), + "timezone": "+08:45", + } + }, + expected=15, + msg="$minute should return 15 for the +08:45 (Eucla) offset wrapping past the hour", + ), + ExpressionTestCase( + "tz_offset_no_colon", + expression={ + "$minute": { + "date": datetime(2024, 6, 15, 12, 30, 0, tzinfo=timezone.utc), + "timezone": "-0500", + } + }, + expected=30, + msg="$minute should accept a compact -0500 offset without a colon", + ), + ExpressionTestCase( + "tz_offset_hour_only", + expression={ + "$minute": { + "date": datetime(2024, 6, 15, 12, 30, 0, tzinfo=timezone.utc), + "timezone": "-08", + } + }, + expected=30, + msg="$minute should accept an hour-only -08 offset", + ), + ExpressionTestCase( + "tz_offset_minus13", + expression={ + "$minute": { + "date": datetime(2024, 7, 15, 12, 25, 0, tzinfo=timezone.utc), + "timezone": "-13:00", + } + }, + expected=25, + msg="$minute should accept a -13:00 offset", + ), + ExpressionTestCase( + "tz_offset_plus15", + expression={ + "$minute": { + "date": datetime(2024, 7, 15, 12, 25, 0, tzinfo=timezone.utc), + "timezone": "+15:00", + } + }, + expected=25, + msg="$minute should accept a +15:00 offset", + ), + ExpressionTestCase( + "tz_offset_minus0330", + expression={ + "$minute": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "-03:30", + } + }, + expected=30, + msg="$minute should accept a -03:30 half-hour west offset", + ), + ExpressionTestCase( + "tz_offset_plus0570", + expression={ + "$minute": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "+05:70", + } + }, + expected=10, + msg="$minute should accept a +05:70 (70-minute) offset", + ), + ExpressionTestCase( + "tz_offset_minus0570", + expression={ + "$minute": { + "date": datetime(2024, 7, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "-05:70", + } + }, + expected=50, + msg="$minute should accept a -05:70 (70-minute) offset", + ), + ExpressionTestCase( + "tz_offset_plus2500", + expression={ + "$minute": { + "date": datetime(2024, 7, 15, 12, 25, 0, tzinfo=timezone.utc), + "timezone": "+25:00", + } + }, + expected=25, + msg="$minute should accept a +25:00 (25-hour) offset", + ), + ExpressionTestCase( + "tz_offset_minus2500", + expression={ + "$minute": { + "date": datetime(2024, 7, 15, 12, 25, 0, tzinfo=timezone.utc), + "timezone": "-25:00", + } + }, + expected=25, + msg="$minute should accept a -25:00 (25-hour) offset", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(MINUTE_OFFSET_DATETIME_TESTS)) +def test_minute_timezone_offsets(collection, test_case: ExpressionTestCase): + """Test $minute 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/minute/test_minute_timezone_validation.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/minute/test_minute_timezone_validation.py new file mode 100644 index 000000000..c8cb08625 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/minute/test_minute_timezone_validation.py @@ -0,0 +1,151 @@ +"""Tests for $minute 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. +MINUTE_TIMEZONE_VALIDATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "invalid_tz_olson", + expression={ + "$minute": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "Not/A_Timezone", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$minute should reject an unparseable Olson-like timezone string", + ), + ExpressionTestCase( + "invalid_tz_nonexistent_olson", + expression={ + "$minute": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "America/Nowhere", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$minute should reject a non-existent Olson timezone", + ), + ExpressionTestCase( + "invalid_tz_offset_format", + expression={ + "$minute": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "25:00", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$minute should reject a bare 25:00 offset without a sign", + ), + ExpressionTestCase( + "invalid_tz_numeric", + expression={ + "$minute": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "123", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$minute should reject a numeric-string timezone", + ), + ExpressionTestCase( + "invalid_tz_empty_string", + expression={ + "$minute": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$minute should reject an empty-string timezone", + ), + ExpressionTestCase( + "invalid_tz_olson_lowercase", + expression={ + "$minute": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "america/new_york", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$minute should reject an all-lowercase Olson name", + ), + ExpressionTestCase( + "invalid_tz_olson_uppercase", + expression={ + "$minute": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "AMERICA/NEW_YORK", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$minute should reject an all-uppercase Olson name", + ), + *[ + ExpressionTestCase( + f"invalid_tz_{tid}", + expression={ + "$minute": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": val, + } + }, + error_code=INVALID_TIMEZONE_TYPE_ERROR, + msg=f"$minute 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={ + "$minute": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": None, + } + }, + expected=None, + msg="$minute should return null for a null timezone", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(MINUTE_TIMEZONE_VALIDATION_TESTS)) +def test_minute_timezone_validation(collection, test_case: ExpressionTestCase): + """Test $minute 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 + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/second/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/second/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/date/second/test_second_clock.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/second/test_second_clock.py new file mode 100644 index 000000000..d24fbdfc1 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/second/test_second_clock.py @@ -0,0 +1,220 @@ +"""Tests for $second extraction across the minute, 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 [Second Extraction]: $second returns the second component (0-59) of a UTC date. +SECOND_EXTRACTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "second_0", + doc={"date": datetime(2024, 6, 15, 12, 30, 0, tzinfo=timezone.utc)}, + expression={"$second": "$date"}, + expected=0, + msg="$second should return 0 for the top of the minute", + ), + ExpressionTestCase( + "second_1", + doc={"date": datetime(2024, 6, 15, 12, 30, 1, tzinfo=timezone.utc)}, + expression={"$second": "$date"}, + expected=1, + msg="$second should return 1 for one second past the minute", + ), + ExpressionTestCase( + "second_15", + doc={"date": datetime(2024, 6, 15, 12, 30, 15, tzinfo=timezone.utc)}, + expression={"$second": "$date"}, + expected=15, + msg="$second should return 15 at quarter past the minute", + ), + ExpressionTestCase( + "second_30", + doc={"date": datetime(2024, 6, 15, 12, 30, 30, tzinfo=timezone.utc)}, + expression={"$second": "$date"}, + expected=30, + msg="$second should return 30 at half past the minute", + ), + ExpressionTestCase( + "second_45", + doc={"date": datetime(2024, 6, 15, 12, 30, 45, tzinfo=timezone.utc)}, + expression={"$second": "$date"}, + expected=45, + msg="$second should return 45 at quarter to the minute", + ), + ExpressionTestCase( + "second_59", + doc={"date": datetime(2024, 6, 15, 12, 59, 59, tzinfo=timezone.utc)}, + expression={"$second": "$date"}, + expected=59, + msg="$second should return 59 for the last second of the minute", + ), +] + +# Property [Calendar Boundaries]: year edges, leap-year Feb 29, and sub-second precision +# resolve to the correct second. +SECOND_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "first_moment_of_year", + doc={"date": datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$second": "$date"}, + expected=0, + msg="$second 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={"$second": "$date"}, + expected=59, + msg="$second should return 59 for the last moment of the year", + ), + ExpressionTestCase( + "leap_year_feb_29", + doc={"date": datetime(2024, 2, 29, 15, 30, 42, tzinfo=timezone.utc)}, + expression={"$second": "$date"}, + expected=42, + msg="$second should return 42 for Feb 29 in a leap year", + ), + ExpressionTestCase( + "non_leap_year_feb_28", + doc={"date": datetime(2023, 2, 28, 15, 30, 42, tzinfo=timezone.utc)}, + expression={"$second": "$date"}, + expected=42, + msg="$second should return 42 for Feb 28 in a non-leap year", + ), + ExpressionTestCase( + "millisecond_before_next_second", + doc={"date": datetime(2024, 6, 15, 12, 30, 29, 999000, tzinfo=timezone.utc)}, + expression={"$second": "$date"}, + expected=29, + msg="$second should return 29 one millisecond before the next second", + ), + ExpressionTestCase( + "millisecond_after_second_start", + doc={"date": datetime(2024, 6, 15, 12, 30, 30, 1000, tzinfo=timezone.utc)}, + expression={"$second": "$date"}, + expected=30, + msg="$second should return 30 one millisecond after the second starts", + ), + ExpressionTestCase( + "millisecond_mid_second", + doc={"date": datetime(2024, 6, 15, 12, 30, 30, 500000, tzinfo=timezone.utc)}, + expression={"$second": "$date"}, + expected=30, + msg="$second should return 30 for a mid-second instant with milliseconds", + ), + ExpressionTestCase( + "millisecond_at_minute_boundary", + doc={"date": datetime(2024, 6, 15, 12, 30, 59, 999000, tzinfo=timezone.utc)}, + expression={"$second": "$date"}, + expected=59, + msg="$second should return 59 one millisecond before the next minute", + ), + ExpressionTestCase( + "millisecond_after_minute_start", + doc={"date": datetime(2024, 6, 15, 12, 31, 0, 1000, tzinfo=timezone.utc)}, + expression={"$second": "$date"}, + expected=0, + msg="$second should return 0 one millisecond after the minute starts", + ), +] + +# Property [Year Range]: the second component is correct across a wide span of years. +SECOND_YEAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "year_2000", + doc={"date": datetime(2000, 1, 1, 8, 45, 33, tzinfo=timezone.utc)}, + expression={"$second": "$date"}, + expected=33, + msg="$second should return 33 for a date in the year 2000", + ), + ExpressionTestCase( + "year_1970_epoch", + doc={"date": DATE_EPOCH}, + expression={"$second": "$date"}, + expected=0, + msg="$second should return 0 for the Unix epoch", + ), + ExpressionTestCase( + "year_1999", + doc={"date": datetime(1999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$second": "$date"}, + expected=59, + msg="$second should return 59 for the last moment of 1999", + ), + ExpressionTestCase( + "year_2099", + doc={"date": datetime(2099, 7, 15, 14, 22, 11, tzinfo=timezone.utc)}, + expression={"$second": "$date"}, + expected=11, + msg="$second should return 11 for a date in the year 2099", + ), + ExpressionTestCase( + "year_9999", + doc={"date": datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)}, + expression={"$second": "$date"}, + expected=59, + msg="$second should return 59 for the last representable year 9999", + ), +] + +# Property [Pre-Epoch]: negative-millisecond dates before 1970 resolve to the correct second. +SECOND_PRE_EPOCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "pre_epoch_1960", + doc={"date": datetime(1960, 3, 15, 14, 37, 22, tzinfo=timezone.utc)}, + expression={"$second": "$date"}, + expected=22, + msg="$second should return 22 for a pre-epoch date in 1960", + ), + ExpressionTestCase( + "pre_epoch_1900", + doc={"date": datetime(1900, 7, 4, 0, 0, 0, tzinfo=timezone.utc)}, + expression={"$second": "$date"}, + expected=0, + msg="$second 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={"$second": "$date"}, + expected=59, + msg="$second should return 59 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={"$second": "$date"}, + expected=0, + msg="$second should return 0 for the first moment of 1969", + ), + ExpressionTestCase( + "pre_epoch_1952_leap", + doc={"date": datetime(1952, 2, 29, 7, 22, 48, tzinfo=timezone.utc)}, + expression={"$second": "$date"}, + expected=48, + msg="$second should return 48 for Feb 29 in the pre-epoch leap year 1952", + ), +] + +SECOND_CLOCK_TESTS: list[ExpressionTestCase] = ( + SECOND_EXTRACTION_TESTS + SECOND_BOUNDARY_TESTS + SECOND_YEAR_TESTS + SECOND_PRE_EPOCH_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(SECOND_CLOCK_TESTS)) +def test_second_clock(collection, test_case: ExpressionTestCase): + """Test $second extraction across the minute, 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/second/test_second_date_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/second/test_second_date_types.py new file mode 100644 index 000000000..0e1980af0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/second/test_second_date_types.py @@ -0,0 +1,187 @@ +"""Tests for $second 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 second. +SECOND_TIMESTAMP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "timestamp_second_0", + doc={"date": ts_from_args(2024, 6, 15, 12, 30, 0)}, + expression={"$second": "$date"}, + expected=0, + msg="$second should return 0 for a Timestamp at the top of the minute", + ), + ExpressionTestCase( + "timestamp_second_30", + doc={"date": ts_from_args(2024, 6, 15, 12, 30, 30)}, + expression={"$second": "$date"}, + expected=30, + msg="$second should return 30 for a Timestamp at half past the minute", + ), + ExpressionTestCase( + "timestamp_second_59", + doc={"date": ts_from_args(2024, 6, 15, 12, 30, 59)}, + expression={"$second": "$date"}, + expected=59, + msg="$second should return 59 for a Timestamp in the last second", + ), + ExpressionTestCase( + "timestamp_zero_increment", + doc={"date": ts_from_args(2024, 6, 15, 8, 15, 42, inc=0)}, + expression={"$second": "$date"}, + expected=42, + msg="$second should return 42 for a Timestamp with a zero increment", + ), + ExpressionTestCase( + "timestamp_epoch", + doc={"date": ts_from_args(1970, 1, 1, 0, 0, 0)}, + expression={"$second": "$date"}, + expected=0, + msg="$second should return 0 for a Timestamp at the epoch", + ), +] + +# Property [ObjectId Input]: an ObjectId is accepted as a date via its embedded timestamp. +SECOND_OBJECTID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "objectid_second_0", + doc={"date": oid_from_args(2024, 6, 15, 12, 30, 0)}, + expression={"$second": "$date"}, + expected=0, + msg="$second should return 0 for an ObjectId at the top of the minute", + ), + ExpressionTestCase( + "objectid_second_30", + doc={"date": oid_from_args(2024, 6, 15, 12, 30, 30)}, + expression={"$second": "$date"}, + expected=30, + msg="$second should return 30 for an ObjectId at half past the minute", + ), + ExpressionTestCase( + "objectid_second_59", + doc={"date": oid_from_args(2024, 6, 15, 12, 30, 59)}, + expression={"$second": "$date"}, + expected=59, + msg="$second should return 59 for an ObjectId in the last second", + ), + ExpressionTestCase( + "objectid_epoch", + doc={"date": oid_from_args(1970, 1, 1, 0, 0, 0)}, + expression={"$second": "$date"}, + expected=0, + msg="$second 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 second. +SECOND_EXTENDED_RANGE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_ms_epoch", + doc={"date": DATE_MS_EPOCH}, + expression={"$second": "$date"}, + expected=0, + msg="$second should return 0 for the epoch as a DatetimeMS", + ), + ExpressionTestCase( + "date_ms_before_epoch", + doc={"date": DATE_MS_BEFORE_EPOCH}, + expression={"$second": "$date"}, + expected=59, + msg="$second should return 59 for a DatetimeMS one millisecond before the epoch", + ), + ExpressionTestCase( + "date_ms_year_10000", + doc={"date": DATE_MS_YEAR_10000}, + expression={"$second": "$date"}, + expected=0, + msg="$second should return 0 for a DatetimeMS at the year-10000 boundary", + ), + ExpressionTestCase( + "date_ms_max", + doc={"date": DATE_MS_MAX}, + expression={"$second": "$date"}, + expected=55, + msg="$second should return 55 for the maximum 64-bit DatetimeMS", + ), + ExpressionTestCase( + "date_ms_min", + doc={"date": DATE_MS_MIN}, + expression={"$second": "$date"}, + expected=4, + msg="$second should return 4 for the minimum 64-bit DatetimeMS", + ), + ExpressionTestCase( + "ts_boundary_max_s32", + doc={"date": TS_MAX_SIGNED32}, + expression={"$second": "$date"}, + expected=7, + msg="$second should return 7 for the max signed 32-bit Timestamp", + ), + ExpressionTestCase( + "ts_boundary_max_u32", + doc={"date": TS_MAX_UNSIGNED32}, + expression={"$second": "$date"}, + expected=15, + msg="$second should return 15 for the max unsigned 32-bit Timestamp", + ), + ExpressionTestCase( + "oid_boundary_max_s32", + doc={"date": OID_MAX_SIGNED32}, + expression={"$second": "$date"}, + expected=7, + msg="$second should return 7 for the max signed 32-bit ObjectId", + ), + ExpressionTestCase( + "oid_boundary_min_s32", + doc={"date": OID_MIN_SIGNED32}, + expression={"$second": "$date"}, + expected=52, + msg="$second should return 52 for the min signed 32-bit ObjectId", + ), + ExpressionTestCase( + "oid_boundary_max_u32", + doc={"date": OID_MAX_UNSIGNED32}, + expression={"$second": "$date"}, + expected=59, + msg="$second should return 59 for the max unsigned 32-bit ObjectId", + ), +] + +SECOND_DATE_TYPES_TESTS: list[ExpressionTestCase] = ( + SECOND_TIMESTAMP_TESTS + SECOND_OBJECTID_TESTS + SECOND_EXTENDED_RANGE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(SECOND_DATE_TYPES_TESTS)) +def test_second_date_types(collection, test_case: ExpressionTestCase): + """Test $second 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/second/test_second_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/second/test_second_expressions.py new file mode 100644 index 000000000..a5ce160c5 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/second/test_second_expressions.py @@ -0,0 +1,170 @@ +"""Tests for $second 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 second. +SECOND_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_date", + expression={"$second": datetime(2024, 6, 15, 12, 0, 45, tzinfo=timezone.utc)}, + expected=45, + msg="$second should return the second 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. +SECOND_ARGUMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_date", + expression={"$second": {"timezone": "UTC"}}, + error_code=ISO_DATE_MISSING_DATE_ERROR, + msg="$second should error when the document form omits the date field", + ), + ExpressionTestCase( + "extra_field", + expression={ + "$second": { + "date": datetime(2024, 6, 15, 12, 0, 45, tzinfo=timezone.utc), + "extra": 1, + } + }, + error_code=ISO_DATE_UNKNOWN_FIELD_ERROR, + msg="$second should error for an unknown field in the document form", + ), + ExpressionTestCase( + "empty_array", + expression={"$second": []}, + error_code=ISO_DATE_INVALID_ARRAY_INPUT_ERROR, + msg="$second should error for an empty operand array", + ), + ExpressionTestCase( + "two_element_array", + expression={ + "$second": [ + datetime(2024, 6, 15, 12, 0, 45, tzinfo=timezone.utc), + datetime(2024, 6, 15, 12, 0, 30, tzinfo=timezone.utc), + ] + }, + error_code=ISO_DATE_INVALID_ARRAY_INPUT_ERROR, + msg="$second should error for a two-element operand array", + ), + ExpressionTestCase( + "single_element_array", + expression={"$second": [datetime(2024, 6, 15, 12, 0, 45, tzinfo=timezone.utc)]}, + expected=45, + msg="$second should accept a single-element operand array as the date", + ), + ExpressionTestCase( + "single_element_array_field_ref", + doc={"date": datetime(2024, 6, 15, 12, 0, 45, tzinfo=timezone.utc)}, + expression={"$second": ["$date"]}, + expected=45, + msg="$second should accept a single-element operand array holding a field reference", + ), + ExpressionTestCase( + "object_expression_input", + doc={"date": datetime(2024, 6, 15, 12, 0, 45, tzinfo=timezone.utc)}, + expression={"$second": {"a": "$date"}}, + error_code=ISO_DATE_UNKNOWN_FIELD_ERROR, + msg="$second 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. +SECOND_FIELD_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_path", + doc={"a": {"b": datetime(2024, 6, 15, 12, 0, 45, tzinfo=timezone.utc)}}, + expression={"$second": "$a.b"}, + expected=45, + msg="$second should accept a date resolved from a nested field path", + ), + ExpressionTestCase( + "array_index_path", + doc={"a": [{"b": datetime(2024, 6, 15, 12, 0, 45, tzinfo=timezone.utc)}]}, + expression={"$second": "$a.0.b"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$second should error when an array-index path resolves to an array", + ), + ExpressionTestCase( + "composite_array_path", + doc={ + "a": [ + {"b": datetime(2024, 6, 15, 12, 0, 45, tzinfo=timezone.utc)}, + {"b": datetime(2024, 6, 15, 12, 0, 30, tzinfo=timezone.utc)}, + ] + }, + expression={"$second": "$a.b"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$second should error when a path over an array of objects resolves to an array", + ), + ExpressionTestCase( + "timezone_from_field", + doc={ + "date": datetime(2024, 6, 15, 12, 0, 45, tzinfo=timezone.utc), + "tz": "America/New_York", + }, + expression={"$second": {"date": "$date", "timezone": "$tz"}}, + expected=45, + msg="$second should apply a timezone resolved from a field reference", + ), + ExpressionTestCase( + "missing_tz_field_ref", + doc={"date": datetime(2024, 6, 15, 12, 0, 45, tzinfo=timezone.utc)}, + expression={"$second": {"date": "$date", "timezone": "$tz"}}, + expected=None, + msg="$second should return null when the timezone field reference is missing", + ), + ExpressionTestCase( + "expression_as_input", + expression={"$second": {"$dateFromString": {"dateString": "2024-06-15T12:00:45Z"}}}, + expected=45, + msg="$second should accept the result of a nested expression as the date", + ), +] + +# Property [Return Type]: $second returns a value of BSON type int. +SECOND_RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type", + doc={"date": datetime(2024, 6, 15, 12, 0, 45, tzinfo=timezone.utc)}, + expression={"$type": {"$second": "$date"}}, + expected="int", + msg="$second should return an int", + ), +] + +SECOND_EXPRESSION_TESTS: list[ExpressionTestCase] = ( + SECOND_LITERAL_TESTS + + SECOND_ARGUMENT_TESTS + + SECOND_FIELD_PATH_TESTS + + SECOND_RETURN_TYPE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(SECOND_EXPRESSION_TESTS)) +def test_second_expressions(collection, test_case: ExpressionTestCase): + """Test $second 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/second/test_second_null_and_type_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/second/test_second_null_and_type_errors.py new file mode 100644 index 000000000..b31472d03 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/second/test_second_null_and_type_errors.py @@ -0,0 +1,211 @@ +"""Tests for $second 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. +SECOND_NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_date", + doc={"date": None}, + expression={"$second": "$date"}, + expected=None, + msg="$second should return null for a null date", + ), + ExpressionTestCase( + "missing_date", + expression={"$second": MISSING}, + expected=None, + msg="$second 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. +SECOND_TYPE_REJECTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_date", + doc={"date": "not-a-date"}, + expression={"$second": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$second should reject a string as the date input", + ), + ExpressionTestCase( + "integer_date", + doc={"date": 42}, + expression={"$second": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$second should reject an int as the date input", + ), + ExpressionTestCase( + "int64_date", + doc={"date": Int64(42)}, + expression={"$second": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$second should reject an int64 as the date input", + ), + ExpressionTestCase( + "double_date", + doc={"date": 3.14}, + expression={"$second": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$second should reject a double as the date input", + ), + ExpressionTestCase( + "decimal128_date", + doc={"date": Decimal128("42")}, + expression={"$second": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$second should reject a decimal128 as the date input", + ), + ExpressionTestCase( + "boolean_date", + doc={"date": True}, + expression={"$second": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$second should reject a boolean as the date input", + ), + ExpressionTestCase( + "array_date", + doc={"date": [1, 2, 3]}, + expression={"$second": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$second should reject an array as the date input", + ), + ExpressionTestCase( + "object_date", + doc={"date": {"a": 1}}, + expression={"$second": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$second should reject an object as the date input", + ), + ExpressionTestCase( + "empty_string_date", + doc={"date": ""}, + expression={"$second": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$second should reject an empty string as the date input", + ), + ExpressionTestCase( + "empty_array_date", + doc={"date": []}, + expression={"$second": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$second should reject an empty array as the date input", + ), + ExpressionTestCase( + "empty_object_date", + doc={"date": {}}, + expression={"$second": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$second should reject an empty object as the date input", + ), + ExpressionTestCase( + "float_nan_date", + doc={"date": FLOAT_NAN}, + expression={"$second": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$second should reject a float NaN as the date input", + ), + ExpressionTestCase( + "decimal128_nan_date", + doc={"date": DECIMAL128_NAN}, + expression={"$second": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$second should reject a decimal128 NaN as the date input", + ), + ExpressionTestCase( + "regex_date", + doc={"date": Regex(".*")}, + expression={"$second": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$second should reject a regex as the date input", + ), + ExpressionTestCase( + "minkey_date", + doc={"date": MinKey()}, + expression={"$second": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$second should reject MinKey as the date input", + ), + ExpressionTestCase( + "maxkey_date", + doc={"date": MaxKey()}, + expression={"$second": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$second should reject MaxKey as the date input", + ), + ExpressionTestCase( + "float_inf_date", + doc={"date": FLOAT_INFINITY}, + expression={"$second": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$second should reject a float infinity as the date input", + ), + ExpressionTestCase( + "float_neg_inf_date", + doc={"date": FLOAT_NEGATIVE_INFINITY}, + expression={"$second": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$second should reject a float negative infinity as the date input", + ), + ExpressionTestCase( + "decimal128_inf_date", + doc={"date": DECIMAL128_INFINITY}, + expression={"$second": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$second should reject a decimal128 infinity as the date input", + ), + ExpressionTestCase( + "decimal128_neg_inf_date", + doc={"date": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$second": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$second should reject a decimal128 negative infinity as the date input", + ), + ExpressionTestCase( + "bindata_date", + doc={"date": Binary(b"")}, + expression={"$second": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$second should reject binary data as the date input", + ), + ExpressionTestCase( + "javascript_date", + doc={"date": Code("function(){}")}, + expression={"$second": "$date"}, + error_code=TYPE_MISMATCH_DATE_ERROR, + msg="$second should reject JavaScript code as the date input", + ), +] + +SECOND_NULL_AND_TYPE_ERROR_TESTS: list[ExpressionTestCase] = ( + SECOND_NULL_TESTS + SECOND_TYPE_REJECTION_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(SECOND_NULL_AND_TYPE_ERROR_TESTS)) +def test_second_null_and_type_errors(collection, test_case: ExpressionTestCase): + """Test $second 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/second/test_second_timezone_input_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/second/test_second_timezone_input_types.py new file mode 100644 index 000000000..4d628ea67 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/second/test_second_timezone_input_types.py @@ -0,0 +1,163 @@ +"""Tests for $second 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 for parsing but +# the second is unchanged, because every zone offset is a whole number of minutes. +SECOND_TIMESTAMP_ZONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_olson_ts_utc", + expression={"$second": {"date": ts_from_args(2024, 7, 15, 12, 25, 37), "timezone": "UTC"}}, + expected=37, + msg="$second should return 37 for a Timestamp in UTC", + ), + ExpressionTestCase( + "tz_olson_ts_tokyo", + expression={ + "$second": {"date": ts_from_args(2024, 7, 15, 12, 25, 37), "timezone": "Asia/Tokyo"} + }, + expected=37, + msg="$second should leave the second at 37 for a Timestamp in Asia/Tokyo", + ), + ExpressionTestCase( + "tz_olson_ts_kolkata", + expression={ + "$second": {"date": ts_from_args(2024, 3, 31, 20, 0, 37), "timezone": "Asia/Kolkata"} + }, + expected=37, + msg="$second should leave the second at 37 for a Timestamp in Asia/Kolkata (+05:30)", + ), + ExpressionTestCase( + "tz_olson_ts_kathmandu", + expression={ + "$second": {"date": ts_from_args(2024, 8, 31, 23, 0, 37), "timezone": "Asia/Kathmandu"} + }, + expected=37, + msg="$second should leave the second at 37 for a Timestamp in Asia/Kathmandu (+05:45)", + ), + ExpressionTestCase( + "tz_offset_ts_plus9", + expression={ + "$second": {"date": ts_from_args(2024, 7, 15, 12, 25, 37), "timezone": "+09:00"} + }, + expected=37, + msg="$second should leave the second at 37 for a Timestamp at a +09:00 offset", + ), + ExpressionTestCase( + "tz_offset_ts_plus530", + expression={ + "$second": {"date": ts_from_args(2024, 3, 31, 20, 0, 37), "timezone": "+05:30"} + }, + expected=37, + msg="$second should leave the second at 37 for a Timestamp at a +05:30 offset", + ), + ExpressionTestCase( + "tz_offset_ts_plus545", + expression={ + "$second": {"date": ts_from_args(2024, 8, 31, 23, 0, 37), "timezone": "+05:45"} + }, + expected=37, + msg="$second should leave the second at 37 for a Timestamp at a +05:45 offset", + ), + ExpressionTestCase( + "tz_offset_ts_minus930", + expression={ + "$second": {"date": ts_from_args(2024, 6, 15, 12, 0, 37), "timezone": "-09:30"} + }, + expected=37, + msg="$second should leave the second at 37 for a Timestamp at a -09:30 offset", + ), +] + +# Property [ObjectId Input with Zones]: an ObjectId input honours the zone for parsing but +# the second is unchanged, because every zone offset is a whole number of minutes. +SECOND_OBJECTID_ZONE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_olson_oid_utc", + expression={"$second": {"date": oid_from_args(2024, 7, 15, 12, 25, 37), "timezone": "UTC"}}, + expected=37, + msg="$second should return 37 for an ObjectId in UTC", + ), + ExpressionTestCase( + "tz_olson_oid_tokyo", + expression={ + "$second": {"date": oid_from_args(2024, 7, 15, 12, 25, 37), "timezone": "Asia/Tokyo"} + }, + expected=37, + msg="$second should leave the second at 37 for an ObjectId in Asia/Tokyo", + ), + ExpressionTestCase( + "tz_olson_oid_kolkata", + expression={ + "$second": {"date": oid_from_args(2024, 3, 31, 20, 0, 37), "timezone": "Asia/Kolkata"} + }, + expected=37, + msg="$second should leave the second at 37 for an ObjectId in Asia/Kolkata (+05:30)", + ), + ExpressionTestCase( + "tz_olson_oid_kathmandu", + expression={ + "$second": {"date": oid_from_args(2024, 8, 31, 23, 0, 37), "timezone": "Asia/Kathmandu"} + }, + expected=37, + msg="$second should leave the second at 37 for an ObjectId in Asia/Kathmandu (+05:45)", + ), + ExpressionTestCase( + "tz_offset_oid_plus9", + expression={ + "$second": {"date": oid_from_args(2024, 7, 15, 12, 25, 37), "timezone": "+09:00"} + }, + expected=37, + msg="$second should leave the second at 37 for an ObjectId at a +09:00 offset", + ), + ExpressionTestCase( + "tz_offset_oid_plus530", + expression={ + "$second": {"date": oid_from_args(2024, 3, 31, 20, 0, 37), "timezone": "+05:30"} + }, + expected=37, + msg="$second should leave the second at 37 for an ObjectId at a +05:30 offset", + ), + ExpressionTestCase( + "tz_offset_oid_plus545", + expression={ + "$second": {"date": oid_from_args(2024, 8, 31, 23, 0, 37), "timezone": "+05:45"} + }, + expected=37, + msg="$second should leave the second at 37 for an ObjectId at a +05:45 offset", + ), + ExpressionTestCase( + "tz_offset_oid_minus930", + expression={ + "$second": {"date": oid_from_args(2024, 6, 15, 12, 0, 37), "timezone": "-09:30"} + }, + expected=37, + msg="$second should leave the second at 37 for an ObjectId at a -09:30 offset", + ), +] + +SECOND_TIMEZONE_INPUT_TYPES_TESTS: list[ExpressionTestCase] = ( + SECOND_TIMESTAMP_ZONE_TESTS + SECOND_OBJECTID_ZONE_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(SECOND_TIMEZONE_INPUT_TYPES_TESTS)) +def test_second_timezone_input_types(collection, test_case: ExpressionTestCase): + """Test $second 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/second/test_second_timezone_names.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/second/test_second_timezone_names.py new file mode 100644 index 000000000..846b308e6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/second/test_second_timezone_names.py @@ -0,0 +1,138 @@ +"""Tests for $second named-timezone application, including DST and fractional-offset zones.""" + +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 for parsing, but the +# second is unchanged across whole-hour, fractional-offset, and DST-transition zones. +SECOND_OLSON_DATETIME_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_olson_dt_utc", + expression={ + "$second": { + "date": datetime(2024, 7, 15, 12, 25, 37, tzinfo=timezone.utc), + "timezone": "UTC", + } + }, + expected=37, + msg="$second should return 37 for UTC", + ), + ExpressionTestCase( + "tz_olson_dt_ny", + expression={ + "$second": { + "date": datetime(2024, 7, 15, 12, 25, 37, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=37, + msg="$second should leave the second at 37 for America/New_York", + ), + ExpressionTestCase( + "tz_olson_dt_tokyo", + expression={ + "$second": { + "date": datetime(2024, 7, 15, 12, 25, 37, tzinfo=timezone.utc), + "timezone": "Asia/Tokyo", + } + }, + expected=37, + msg="$second should leave the second at 37 for Asia/Tokyo", + ), + ExpressionTestCase( + "tz_olson_dt_kolkata", + expression={ + "$second": { + "date": datetime(2024, 3, 31, 20, 0, 37, tzinfo=timezone.utc), + "timezone": "Asia/Kolkata", + } + }, + expected=37, + msg="$second should leave the second at 37 for Asia/Kolkata (+05:30)", + ), + ExpressionTestCase( + "tz_olson_dt_kathmandu", + expression={ + "$second": { + "date": datetime(2024, 8, 31, 23, 0, 37, tzinfo=timezone.utc), + "timezone": "Asia/Kathmandu", + } + }, + expected=37, + msg="$second should leave the second at 37 for Asia/Kathmandu (+05:45)", + ), + ExpressionTestCase( + "tz_olson_dt_ny_dst_spring", + expression={ + "$second": { + "date": datetime(2024, 3, 10, 7, 17, 53, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=53, + msg="$second should leave the second at 53 across the spring DST transition in New York", + ), + ExpressionTestCase( + "tz_olson_dt_ny_dst_fall", + expression={ + "$second": { + "date": datetime(2024, 11, 3, 6, 42, 8, tzinfo=timezone.utc), + "timezone": "America/New_York", + } + }, + expected=8, + msg="$second should leave the second at 8 across the fall DST transition in New York", + ), + ExpressionTestCase( + "tz_olson_dt_london_bst", + expression={ + "$second": { + "date": datetime(2024, 7, 15, 12, 0, 37, tzinfo=timezone.utc), + "timezone": "Europe/London", + } + }, + expected=37, + msg="$second should leave the second at 37 for Europe/London in summer (BST +1)", + ), + ExpressionTestCase( + "tz_olson_dt_pacific_apia", + expression={ + "$second": { + "date": datetime(2024, 7, 15, 12, 0, 37, tzinfo=timezone.utc), + "timezone": "Pacific/Apia", + } + }, + expected=37, + msg="$second should leave the second at 37 for Pacific/Apia", + ), + ExpressionTestCase( + "tz_est_abbreviation", + expression={ + "$second": { + "date": datetime(2024, 7, 15, 12, 0, 37, tzinfo=timezone.utc), + "timezone": "EST", + } + }, + expected=37, + msg="$second should accept the EST three-letter abbreviation", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(SECOND_OLSON_DATETIME_TESTS)) +def test_second_timezone_names(collection, test_case: ExpressionTestCase): + """Test $second named-timezone application across zones, DST, and fractional 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/second/test_second_timezone_offsets.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/second/test_second_timezone_offsets.py new file mode 100644 index 000000000..b404e1490 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/second/test_second_timezone_offsets.py @@ -0,0 +1,215 @@ +"""Tests for $second UTC-offset timezone application, including fractional 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 for +# parsing, but the second is unchanged because every offset is a whole number of minutes. +SECOND_OFFSET_DATETIME_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "tz_offset_dt_plus0", + expression={ + "$second": { + "date": datetime(2024, 7, 15, 12, 25, 37, tzinfo=timezone.utc), + "timezone": "+00:00", + } + }, + expected=37, + msg="$second should return 37 for a +00:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus9", + expression={ + "$second": { + "date": datetime(2024, 7, 15, 12, 25, 37, tzinfo=timezone.utc), + "timezone": "+09:00", + } + }, + expected=37, + msg="$second should leave the second at 37 for a +09:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_minus8", + expression={ + "$second": { + "date": datetime(2024, 7, 15, 12, 25, 37, tzinfo=timezone.utc), + "timezone": "-08:00", + } + }, + expected=37, + msg="$second should leave the second at 37 for a -08:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus530", + expression={ + "$second": { + "date": datetime(2024, 3, 31, 20, 0, 37, tzinfo=timezone.utc), + "timezone": "+05:30", + } + }, + expected=37, + msg="$second should leave the second at 37 for a +05:30 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus545", + expression={ + "$second": { + "date": datetime(2024, 8, 31, 23, 0, 37, tzinfo=timezone.utc), + "timezone": "+05:45", + } + }, + expected=37, + msg="$second should leave the second at 37 for a +05:45 offset", + ), + ExpressionTestCase( + "tz_offset_dt_minus930", + expression={ + "$second": { + "date": datetime(2024, 6, 15, 12, 0, 37, tzinfo=timezone.utc), + "timezone": "-09:30", + } + }, + expected=37, + msg="$second should leave the second at 37 for a -09:30 offset", + ), + ExpressionTestCase( + "tz_offset_dt_plus14", + expression={ + "$second": { + "date": datetime(2024, 6, 30, 11, 33, 37, tzinfo=timezone.utc), + "timezone": "+14:00", + } + }, + expected=37, + msg="$second should leave the second at 37 for the extreme +14:00 offset", + ), + ExpressionTestCase( + "tz_offset_dt_minus12", + expression={ + "$second": { + "date": datetime(2024, 7, 1, 11, 33, 37, tzinfo=timezone.utc), + "timezone": "-12:00", + } + }, + expected=37, + msg="$second should leave the second at 37 for the extreme -12:00 offset", + ), + ExpressionTestCase( + "tz_offset_no_colon", + expression={ + "$second": { + "date": datetime(2024, 6, 15, 12, 0, 45, tzinfo=timezone.utc), + "timezone": "-0500", + } + }, + expected=45, + msg="$second should accept a compact -0500 offset without a colon", + ), + ExpressionTestCase( + "tz_offset_hour_only", + expression={ + "$second": { + "date": datetime(2024, 6, 15, 12, 0, 45, tzinfo=timezone.utc), + "timezone": "-08", + } + }, + expected=45, + msg="$second should accept an hour-only -08 offset", + ), + ExpressionTestCase( + "tz_offset_minus13", + expression={ + "$second": { + "date": datetime(2024, 7, 15, 12, 0, 37, tzinfo=timezone.utc), + "timezone": "-13:00", + } + }, + expected=37, + msg="$second should accept a -13:00 offset", + ), + ExpressionTestCase( + "tz_offset_plus15", + expression={ + "$second": { + "date": datetime(2024, 7, 15, 12, 0, 37, tzinfo=timezone.utc), + "timezone": "+15:00", + } + }, + expected=37, + msg="$second should accept a +15:00 offset", + ), + ExpressionTestCase( + "tz_offset_minus0330", + expression={ + "$second": { + "date": datetime(2024, 7, 15, 12, 0, 37, tzinfo=timezone.utc), + "timezone": "-03:30", + } + }, + expected=37, + msg="$second should accept a -03:30 half-hour west offset", + ), + ExpressionTestCase( + "tz_offset_plus0570", + expression={ + "$second": { + "date": datetime(2024, 7, 15, 12, 0, 37, tzinfo=timezone.utc), + "timezone": "+05:70", + } + }, + expected=37, + msg="$second should accept a +05:70 (70-minute) offset", + ), + ExpressionTestCase( + "tz_offset_minus0570", + expression={ + "$second": { + "date": datetime(2024, 7, 15, 12, 0, 37, tzinfo=timezone.utc), + "timezone": "-05:70", + } + }, + expected=37, + msg="$second should accept a -05:70 (70-minute) offset", + ), + ExpressionTestCase( + "tz_offset_plus2500", + expression={ + "$second": { + "date": datetime(2024, 7, 15, 12, 0, 37, tzinfo=timezone.utc), + "timezone": "+25:00", + } + }, + expected=37, + msg="$second should accept a +25:00 (25-hour) offset", + ), + ExpressionTestCase( + "tz_offset_minus2500", + expression={ + "$second": { + "date": datetime(2024, 7, 15, 12, 0, 37, tzinfo=timezone.utc), + "timezone": "-25:00", + } + }, + expected=37, + msg="$second should accept a -25:00 (25-hour) offset", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(SECOND_OFFSET_DATETIME_TESTS)) +def test_second_timezone_offsets(collection, test_case: ExpressionTestCase): + """Test $second 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/second/test_second_timezone_validation.py b/documentdb_tests/compatibility/tests/core/operator/expressions/date/second/test_second_timezone_validation.py new file mode 100644 index 000000000..dc2e505b7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/date/second/test_second_timezone_validation.py @@ -0,0 +1,151 @@ +"""Tests for $second 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. +SECOND_TIMEZONE_VALIDATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "invalid_tz_olson", + expression={ + "$second": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "Not/A_Timezone", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$second should reject an unparseable Olson-like timezone string", + ), + ExpressionTestCase( + "invalid_tz_nonexistent_olson", + expression={ + "$second": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "America/Nowhere", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$second should reject a non-existent Olson timezone", + ), + ExpressionTestCase( + "invalid_tz_offset_format", + expression={ + "$second": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "25:00", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$second should reject a bare 25:00 offset without a sign", + ), + ExpressionTestCase( + "invalid_tz_numeric", + expression={ + "$second": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "123", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$second should reject a numeric-string timezone", + ), + ExpressionTestCase( + "invalid_tz_empty_string", + expression={ + "$second": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$second should reject an empty-string timezone", + ), + ExpressionTestCase( + "invalid_tz_olson_lowercase", + expression={ + "$second": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "america/new_york", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$second should reject an all-lowercase Olson name", + ), + ExpressionTestCase( + "invalid_tz_olson_uppercase", + expression={ + "$second": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": "AMERICA/NEW_YORK", + } + }, + error_code=INVALID_TIMEZONE_ERROR, + msg="$second should reject an all-uppercase Olson name", + ), + *[ + ExpressionTestCase( + f"invalid_tz_{tid}", + expression={ + "$second": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": val, + } + }, + error_code=INVALID_TIMEZONE_TYPE_ERROR, + msg=f"$second 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={ + "$second": { + "date": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), + "timezone": None, + } + }, + expected=None, + msg="$second should return null for a null timezone", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(SECOND_TIMEZONE_VALIDATION_TESTS)) +def test_second_timezone_validation(collection, test_case: ExpressionTestCase): + """Test $second 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 + )