Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
"""Tests for $add date arithmetic including numeric offsets, rounding boundaries, operand
position, and sign handling.
"""

from datetime import datetime, timedelta, timezone

import pytest
from bson import Decimal128, Int64

from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501
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 [Date Arithmetic]: $add accepts exactly one date operand and one or more numeric
# operands (in milliseconds). The date may appear in any position.
ADD_DATE_NUMERIC_TESTS: list[ExpressionTestCase] = [
ExpressionTestCase(
"date_int32",
doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 86400000},
expression={"$add": ["$a", "$b"]},
expected=datetime(2026, 1, 2, tzinfo=timezone.utc),
msg="$add should add int32 milliseconds to a date",
),
ExpressionTestCase(
"date_int64",
doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": Int64(86400000)},
expression={"$add": ["$a", "$b"]},
expected=datetime(2026, 1, 2, tzinfo=timezone.utc),
msg="$add should add int64 milliseconds to a date",
),
ExpressionTestCase(
"date_decimal",
doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": Decimal128("1.5")},
expression={"$add": ["$a", "$b"]},
expected=datetime(2026, 1, 1, 0, 0, 0, 2000, tzinfo=timezone.utc),
msg="$add should round a decimal128 fractional millisecond value when adding to a date",
),
ExpressionTestCase(
"date_double_round_up",
doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 2.5},
expression={"$add": ["$a", "$b"]},
expected=datetime(2026, 1, 1, 0, 0, 0, 3000, tzinfo=timezone.utc),
msg="$add should round up a double fractional millisecond value (.5) when adding to a date",
),
ExpressionTestCase(
"date_double_truncates",
doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 4.4},
expression={"$add": ["$a", "$b"]},
expected=datetime(2026, 1, 1, 0, 0, 0, 4000, tzinfo=timezone.utc),
msg="$add should truncate a double fractional millisecond value (<.5) when adding to a date", # noqa: E501
),
]

# Property [Date Rounding Boundaries]: $add rounds fractional millisecond offsets using
# round-half-away-from-zero. Values with |frac| < 0.5 truncate toward zero; values with
# |frac| >= 0.5 round away from zero.
ADD_DATE_ROUNDING_TESTS: list[ExpressionTestCase] = [
ExpressionTestCase(
"date_double_0_1",
doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 0.1},
expression={"$add": ["$a", "$b"]},
expected=datetime(2026, 1, 1, tzinfo=timezone.utc),
msg="$add should truncate 0.1ms and leave the date unchanged",
),
ExpressionTestCase(
"date_double_0_49",
doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 0.49},
expression={"$add": ["$a", "$b"]},
expected=datetime(2026, 1, 1, tzinfo=timezone.utc),
msg="$add should truncate 0.49ms and leave the date unchanged",
),
ExpressionTestCase(
"date_double_0_51",
doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 0.51},
expression={"$add": ["$a", "$b"]},
expected=datetime(2026, 1, 1, 0, 0, 0, 1000, tzinfo=timezone.utc),
msg="$add should round up 0.51ms to 1ms when adding to a date",
),
ExpressionTestCase(
"date_double_0_6",
doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 0.6},
expression={"$add": ["$a", "$b"]},
expected=datetime(2026, 1, 1, 0, 0, 0, 1000, tzinfo=timezone.utc),
msg="$add should round up 0.6ms to 1ms when adding to a date",
),
ExpressionTestCase(
"date_double_neg_0_5",
doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": -0.5},
expression={"$add": ["$a", "$b"]},
expected=datetime(2026, 1, 1, tzinfo=timezone.utc) - timedelta(milliseconds=1),
msg="$add should round -0.5ms away from zero to -1ms when adding to a date",
),
ExpressionTestCase(
"date_double_neg_0_51",
doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": -0.51},
expression={"$add": ["$a", "$b"]},
expected=datetime(2026, 1, 1, tzinfo=timezone.utc) - timedelta(milliseconds=1),
msg="$add should round -0.51ms away from zero to -1ms when adding to a date",
),
]

# Property [Date Operand Position]: the date operand may appear in any position among the
# operands; only one date is permitted.
ADD_DATE_POSITION_TESTS: list[ExpressionTestCase] = [
ExpressionTestCase(
"number_then_date",
doc={"a": 86400000, "b": datetime(2026, 1, 1, tzinfo=timezone.utc)},
expression={"$add": ["$a", "$b"]},
expected=datetime(2026, 1, 2, tzinfo=timezone.utc),
msg="$add should add a date when the numeric operand appears before the date",
),
ExpressionTestCase(
"date_in_middle",
doc={"a": 1, "b": datetime(2026, 1, 1, tzinfo=timezone.utc), "c": 1000},
expression={"$add": ["$a", "$b", "$c"]},
expected=datetime(2026, 1, 1, 0, 0, 1, 1000, tzinfo=timezone.utc),
msg="$add should add a date when it appears in the middle of the operand list",
),
]

# Property [Date Sign Handling]: adding zero or a negative number of milliseconds to a date
# returns the date unchanged or subtracted.
ADD_DATE_SIGN_TESTS: list[ExpressionTestCase] = [
ExpressionTestCase(
"date_negative",
doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": -86400000},
expression={"$add": ["$a", "$b"]},
expected=datetime(2025, 12, 31, tzinfo=timezone.utc),
msg="$add should subtract milliseconds from a date when adding a negative number",
),
ExpressionTestCase(
"date_zero",
doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": 0},
expression={"$add": ["$a", "$b"]},
expected=datetime(2026, 1, 1, tzinfo=timezone.utc),
msg="$add should return the same date when adding zero milliseconds",
),
ExpressionTestCase(
"date_negative_zero",
doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": -0.0},
expression={"$add": ["$a", "$b"]},
expected=datetime(2026, 1, 1, tzinfo=timezone.utc),
msg="$add should return the same date when adding negative zero",
),
]

ADD_DATE_ALL_TESTS = (
ADD_DATE_NUMERIC_TESTS + ADD_DATE_POSITION_TESTS + ADD_DATE_SIGN_TESTS + ADD_DATE_ROUNDING_TESTS
)


@pytest.mark.parametrize("test_case", pytest_params(ADD_DATE_ALL_TESTS))
def test_add_date(collection, test_case: ExpressionTestCase):
"""Test $add date arithmetic: numeric types, operand position, and sign handling."""
result = execute_expression_with_insert(collection, test_case.expression, test_case.doc)
assert_expression_result(
result,
expected=test_case.expected,
error_code=test_case.error_code,
msg=test_case.msg,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
"""Tests for $add error cases including invalid operand types, multiple dates, and date overflow."""

from datetime import datetime, timezone

import pytest

from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501
ExpressionTestCase,
)
from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import (
assert_expression_result,
execute_expression_with_insert,
)
from documentdb_tests.framework.assertions import assertFailureCode
from documentdb_tests.framework.bson_type_validator import (
BsonType,
BsonTypeTestCase,
generate_bson_rejection_test_cases,
)
from documentdb_tests.framework.error_codes import (
MORE_THAN_ONE_DATE_ERROR,
OVERFLOW_ERROR,
TYPE_MISMATCH_ERROR,
)
from documentdb_tests.framework.parametrize import pytest_params
from documentdb_tests.framework.test_constants import (
DECIMAL128_INFINITY,
DECIMAL128_NAN,
FLOAT_INFINITY,
FLOAT_NAN,
INT64_MAX,
)

_NUMERIC_DATE_AND_NULL_TYPES = [
BsonType.DOUBLE,
BsonType.INT,
BsonType.LONG,
BsonType.DECIMAL,
BsonType.DATE,
BsonType.NULL,
]

# Property [Type Strictness]: $add rejects non-numeric, non-date operand types.
ADD_TYPE_SPEC = BsonTypeTestCase(
id="add_operand",
msg="$add should reject non-numeric, non-date operand",
keyword="$add",
valid_types=_NUMERIC_DATE_AND_NULL_TYPES,
default_error_code=TYPE_MISMATCH_ERROR,
)

ADD_TYPE_REJECTION_CASES = generate_bson_rejection_test_cases([ADD_TYPE_SPEC])
_ADD_TYPE_REJECTION_EXPR = {"$add": [1, "$b"]}


@pytest.mark.parametrize("bson_type,sample_value,spec", ADD_TYPE_REJECTION_CASES)
def test_add_rejects_invalid_operand_type(collection, bson_type, sample_value, spec):
"""Test $add rejects non-numeric, non-date BSON types as operands."""
result = execute_expression_with_insert(
collection, _ADD_TYPE_REJECTION_EXPR, {"b": sample_value}
)
assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg)


# Property [Mixed Valid and Invalid]: $add rejects an invalid operand when it appears among
# valid numeric operands.
ADD_MIXED_VALID_INVALID_TESTS: list[ExpressionTestCase] = [
ExpressionTestCase(
"mixed_valid_invalid",
doc={"a": 1, "b": 2, "c": "string"},
expression={"$add": ["$a", "$b", "$c"]},
error_code=TYPE_MISMATCH_ERROR,
msg="$add should error when a string appears among numeric operands",
),
]

# Property [Single Invalid Operand]: $add rejects a single operand of an invalid type.
ADD_SINGLE_TYPE_ERROR_TESTS: list[ExpressionTestCase] = [
ExpressionTestCase(
"single_string",
doc={"a": "string"},
expression={"$add": ["$a"]},
error_code=TYPE_MISMATCH_ERROR,
msg="$add should reject a single string operand",
),
ExpressionTestCase(
"single_boolean",
doc={"a": True},
expression={"$add": ["$a"]},
error_code=TYPE_MISMATCH_ERROR,
msg="$add should reject a single boolean operand",
),
ExpressionTestCase(
"single_array",
doc={"a": [1, 2]},
expression={"$add": ["$a"]},
error_code=TYPE_MISMATCH_ERROR,
msg="$add should reject a single array operand",
),
ExpressionTestCase(
"single_object",
doc={"a": {"x": 1}},
expression={"$add": ["$a"]},
error_code=TYPE_MISMATCH_ERROR,
msg="$add should reject a single object operand",
),
]

# Property [Multiple Dates]: $add rejects expressions with more than one date operand.
ADD_MULTIPLE_DATE_TESTS: list[ExpressionTestCase] = [
ExpressionTestCase(
"add_two_identical_dates",
doc={
"a": datetime(2026, 1, 1, tzinfo=timezone.utc),
"b": datetime(2026, 1, 1, tzinfo=timezone.utc),
},
expression={"$add": ["$a", "$b"]},
error_code=MORE_THAN_ONE_DATE_ERROR,
msg="$add should error when adding two identical date operands",
),
ExpressionTestCase(
"two_different_dates",
doc={
"a": datetime(2026, 1, 1, tzinfo=timezone.utc),
"b": datetime(2026, 1, 2, tzinfo=timezone.utc),
},
expression={"$add": ["$a", "$b"]},
error_code=MORE_THAN_ONE_DATE_ERROR,
msg="$add should error when adding two different date operands",
),
ExpressionTestCase(
"two_dates_with_numbers",
doc={
"a": datetime(2026, 1, 1, tzinfo=timezone.utc),
"b": datetime(2026, 1, 2, tzinfo=timezone.utc),
},
expression={"$add": [1, 2, 3, "$a", "$b"]},
error_code=MORE_THAN_ONE_DATE_ERROR,
msg="$add should error when two dates appear among numeric operands",
),
ExpressionTestCase(
"dates_separated_by_number",
doc={
"a": datetime(2026, 1, 1, tzinfo=timezone.utc),
"b": datetime(2026, 1, 2, tzinfo=timezone.utc),
},
expression={"$add": ["$a", 1, "$b"]},
error_code=MORE_THAN_ONE_DATE_ERROR,
msg="$add should error when two dates are separated by a numeric operand",
),
]

# Property [Date with Non-Finite]: $add rejects NaN and Infinity as numeric operands when a
# date is also present, since the resulting date would be non-representable.
ADD_DATE_NON_FINITE_ERROR_TESTS: list[ExpressionTestCase] = [
ExpressionTestCase(
"date_nan",
doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": FLOAT_NAN},
expression={"$add": ["$a", "$b"]},
error_code=OVERFLOW_ERROR,
msg="$add should error when adding a date and float NaN",
),
ExpressionTestCase(
"date_infinity",
doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": FLOAT_INFINITY},
expression={"$add": ["$a", "$b"]},
error_code=OVERFLOW_ERROR,
msg="$add should error when adding a date and float infinity",
),
ExpressionTestCase(
"date_decimal_nan",
doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": DECIMAL128_NAN},
expression={"$add": ["$a", "$b"]},
error_code=OVERFLOW_ERROR,
msg="$add should error when adding a date and decimal128 NaN",
),
ExpressionTestCase(
"date_decimal_infinity",
doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": DECIMAL128_INFINITY},
expression={"$add": ["$a", "$b"]},
error_code=OVERFLOW_ERROR,
msg="$add should error when adding a date and decimal128 infinity",
),
]

# Property [Date Overflow]: $add errors when the millisecond offset would push the date result
# beyond the representable date range.
ADD_DATE_OVERFLOW_TESTS: list[ExpressionTestCase] = [
ExpressionTestCase(
"date_int64_max",
doc={"a": datetime(2026, 1, 1, tzinfo=timezone.utc), "b": INT64_MAX},
expression={"$add": ["$a", "$b"]},
error_code=OVERFLOW_ERROR,
msg="$add should error when adding INT64_MAX milliseconds to a date overflows the date range", # noqa: E501
),
]

ADD_REMAINING_ERROR_TESTS = (
ADD_MIXED_VALID_INVALID_TESTS
+ ADD_SINGLE_TYPE_ERROR_TESTS
+ ADD_MULTIPLE_DATE_TESTS
+ ADD_DATE_NON_FINITE_ERROR_TESTS
+ ADD_DATE_OVERFLOW_TESTS
)


@pytest.mark.parametrize("test_case", pytest_params(ADD_REMAINING_ERROR_TESTS))
def test_add_errors(collection, test_case: ExpressionTestCase):
"""Test $add multiple-date, single-invalid, and date non-finite error cases."""
result = execute_expression_with_insert(collection, test_case.expression, test_case.doc)
assert_expression_result(
result,
expected=test_case.expected,
error_code=test_case.error_code,
msg=test_case.msg,
)
Loading
Loading