diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/comparisons/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/comparisons/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/comparisons/cmp/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/comparisons/cmp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/comparisons/cmp/test_cmp_argument_handling.py b/documentdb_tests/compatibility/tests/core/operator/expressions/comparisons/cmp/test_cmp_argument_handling.py new file mode 100644 index 00000000..ffb07927 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/comparisons/cmp/test_cmp_argument_handling.py @@ -0,0 +1,128 @@ +""" +Tests for $cmp argument handling. + +Covers argument validation (error cases), field references, return type verification, +and nested $cmp expressions. +""" + +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 ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import EXPRESSION_TYPE_MISMATCH_ERROR +from documentdb_tests.framework.parametrize import pytest_params + +ARG_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "no_args", + expression={"$cmp": []}, + doc={"a": 1}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="Should error for empty arguments", + ), + ExpressionTestCase( + "single_arg", + expression={"$cmp": ["$a"]}, + doc={"a": 1}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="Should error for single argument", + ), + ExpressionTestCase( + "non_array_int", + expression={"$cmp": 1}, + doc={"a": 1}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="Should error for non-array int argument", + ), + ExpressionTestCase( + "non_array_string", + expression={"$cmp": "string"}, + doc={"a": 1}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="Should error for non-array string argument", + ), + ExpressionTestCase( + "non_array_object", + expression={"$cmp": {"a": 1}}, + doc={"a": 1}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="Should error for non-array object argument", + ), + ExpressionTestCase( + "non_array_boolean", + expression={"$cmp": True}, + doc={"a": 1}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="Should error for non-array boolean argument", + ), + ExpressionTestCase( + "three_args", + expression={"$cmp": ["$a", "$b", "$c"]}, + doc={"a": 1, "b": 2, "c": 3}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="Should error for three arguments", + ), +] + + +FIELD_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "field_ref_lt", + expression={"$cmp": ["$qty", 250]}, + doc={"qty": 200}, + expected=-1, + msg="Field reference less than literal", + ), + ExpressionTestCase( + "field_ref_eq", + expression={"$cmp": ["$qty", 250]}, + doc={"qty": 250}, + expected=0, + msg="Field reference equals literal", + ), + ExpressionTestCase( + "field_ref_gt", + expression={"$cmp": ["$qty", 250]}, + doc={"qty": 300}, + expected=1, + msg="Field reference greater than literal", + ), +] + + +RETURN_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "return_type_neg1", + expression={"$type": {"$cmp": [1, 2]}}, + doc={"a": 1}, + expected="int", + msg="$cmp should return BSON int type", + ), +] + + +NESTED_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_cmp", + expression={"$cmp": [{"$cmp": [1, 2]}, -1]}, + doc={"a": 1}, + expected=0, + msg="Nested $cmp(-1) equals -1", + ), +] + +ALL_TESTS = ARG_TESTS + FIELD_REF_TESTS + RETURN_TYPE_TESTS + NESTED_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_cmp_argument_handling(collection, test): + """Test $cmp argument validation, field references, return type, and nesting.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/comparisons/cmp/test_cmp_boundary_precision.py b/documentdb_tests/compatibility/tests/core/operator/expressions/comparisons/cmp/test_cmp_boundary_precision.py new file mode 100644 index 00000000..97471bb8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/comparisons/cmp/test_cmp_boundary_precision.py @@ -0,0 +1,106 @@ +""" +Tests for $cmp boundary values and precision. + +Covers cross-type boundary values, large number precision at double/long boundary, +and overflow adjacency. +""" + +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 ( # noqa: E501 + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DOUBLE_MAX_SAFE_INTEGER, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_PRECISION_LOSS, + INT32_MAX, + INT32_MIN, + INT32_OVERFLOW, + INT32_UNDERFLOW, + INT64_MAX, + INT64_MIN, +) + +BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_max_self", + expression={"$cmp": [INT32_MAX, INT32_MAX]}, + expected=0, + msg="INT32_MAX equals itself", + ), + ExpressionTestCase( + "int32_max_vs_overflow", + expression={"$cmp": [INT32_MAX, INT32_OVERFLOW]}, + expected=-1, + msg="INT32_MAX < INT32_OVERFLOW (promoted to long)", + ), + ExpressionTestCase( + "int32_min_vs_underflow", + expression={"$cmp": [INT32_MIN, INT32_UNDERFLOW]}, + expected=1, + msg="INT32_MIN > INT32_UNDERFLOW (promoted to long)", + ), + ExpressionTestCase( + "int32_max_vs_int64_max", + expression={"$cmp": [INT32_MAX, INT64_MAX]}, + expected=-1, + msg="INT32_MAX < INT64_MAX", + ), + ExpressionTestCase( + "int64_min_vs_int32_min", + expression={"$cmp": [INT64_MIN, INT32_MIN]}, + expected=-1, + msg="INT64_MIN < INT32_MIN", + ), + ExpressionTestCase( + "int64_max_self", + expression={"$cmp": [INT64_MAX, INT64_MAX]}, + expected=0, + msg="INT64_MAX equals itself", + ), +] + + +LARGE_NUMBER_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "beyond_double_precision", + expression={"$cmp": [Int64(DOUBLE_PRECISION_LOSS), float(DOUBLE_PRECISION_LOSS)]}, + expected=1, + msg="Int64(2^53+1) > double(2^53+1) — beyond double precision", + ), + ExpressionTestCase( + "exactly_representable", + expression={"$cmp": [Int64(DOUBLE_MAX_SAFE_INTEGER), float(DOUBLE_MAX_SAFE_INTEGER)]}, + expected=0, + msg="Int64(2^53) vs double(2^53) — exactly representable", + ), + ExpressionTestCase( + "dec128_preserves_precision", + expression={"$cmp": [Decimal128(str(DOUBLE_PRECISION_LOSS)), Int64(DOUBLE_PRECISION_LOSS)]}, + expected=0, + msg="Decimal128(2^53+1) equals Int64(2^53+1)", + ), + ExpressionTestCase( + "neg_zero_vs_pos_zero", + expression={"$cmp": [DOUBLE_NEGATIVE_ZERO, 0]}, + expected=0, + msg="-0.0 equals 0 (negative zero is equal to positive zero)", + ), +] + + +ALL_TESTS = BOUNDARY_TESTS + LARGE_NUMBER_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_literal(collection, test): + """Test $cmp across numeric boundary values and cross-type precision edges.""" + result = execute_expression(collection, test.expression) + assert_expression_result(result, expected=test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/comparisons/cmp/test_cmp_expression_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/comparisons/cmp/test_cmp_expression_types.py new file mode 100644 index 00000000..ebbaeeb6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/comparisons/cmp/test_cmp_expression_types.py @@ -0,0 +1,83 @@ +""" +Tests for $cmp expression type smoke tests. + +Covers literal, field path, expression operator, and array/object expression inputs. +""" + +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 ( # noqa: E501 + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_lt", + expression={"$cmp": [5, 10]}, + expected=-1, + msg="Literal 5 < 10", + ), + ExpressionTestCase( + "literal_gt", + expression={"$cmp": [10, 5]}, + expected=1, + msg="Literal 10 > 5", + ), + ExpressionTestCase( + "literal_eq", + expression={"$cmp": [7, 7]}, + expected=0, + msg="Literal 7 == 7", + ), + ExpressionTestCase( + "literal_nested_expr", + expression={"$cmp": [{"$add": [1, 2]}, 4]}, + expected=-1, + msg="Literal $add(1,2)=3 < 4", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(LITERAL_TESTS)) +def test_cmp_expression_literal(collection, test): + """Test $cmp with literal expression inputs.""" + result = execute_expression(collection, test.expression) + assert_expression_result(result, expected=test.expected, msg=test.msg) + + +INSERT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "field_path", + expression={"$cmp": ["$a", "$b"]}, + doc={"a": 10, "b": 5}, + expected=1, + msg="Field path $a(10) > $b(5)", + ), + ExpressionTestCase( + "expression_operator", + expression={"$cmp": [{"$abs": "$x"}, 3]}, + doc={"x": -5}, + expected=1, + msg="abs(-5)=5 > 3", + ), + ExpressionTestCase( + "array_expression", + expression={"$cmp": [["$x", "$y"], [1, 2]]}, + doc={"x": 1, "y": 2}, + expected=0, + msg="Array expression [1,2] == [1,2]", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(INSERT_TESTS)) +def test_cmp_expression_with_insert(collection, test): + """Test $cmp with field path and expression inputs.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result(result, expected=test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/comparisons/cmp/test_cmp_field_lookup.py b/documentdb_tests/compatibility/tests/core/operator/expressions/comparisons/cmp/test_cmp_field_lookup.py new file mode 100644 index 00000000..ccf0063a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/comparisons/cmp/test_cmp_field_lookup.py @@ -0,0 +1,54 @@ +""" +Tests for $cmp field lookup and array index paths. + +Covers array vs scalar in BSON order and deep nested path resolution. +""" + +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 ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +ALL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "array_vs_scalar", + expression={"$cmp": ["$a", "$b"]}, + doc={"a": [1, 2, 3], "b": 5}, + expected=1, + msg="Array > number in BSON order", + ), + ExpressionTestCase( + "array_element_by_element", + expression={"$cmp": ["$a", "$b"]}, + doc={"a": [1, 2, 3], "b": [1, 2, 4]}, + expected=-1, + msg="[1,2,3] < [1,2,4] element-by-element", + ), + ExpressionTestCase( + "deep_nested_path", + expression={"$cmp": ["$a.b.c.d", 1]}, + doc={"a": {"b": {"c": {"d": 1}}}}, + expected=0, + msg="Deep nested path $a.b.c.d resolves to 1", + ), + ExpressionTestCase( + "deep_nested_missing", + expression={"$cmp": ["$a.b.c.d", 1]}, + doc={"a": {"x": 1}}, + expected=-1, + msg="Missing intermediate field in deep path treated as null", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_cmp_field_lookup(collection, test): + """Test $cmp field lookup and array index paths.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result(result, expected=test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/comparisons/cmp/test_cmp_nan_infinity.py b/documentdb_tests/compatibility/tests/core/operator/expressions/comparisons/cmp/test_cmp_nan_infinity.py new file mode 100644 index 00000000..a2ac8235 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/comparisons/cmp/test_cmp_nan_infinity.py @@ -0,0 +1,114 @@ +""" +Tests for $cmp numeric edge cases. + +Covers sign handling, Infinity comparisons, NaN ordering, and cross-type Decimal128 Infinity. +""" + +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 ( # noqa: E501 + assert_expression_result, + execute_expression, +) +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, +) + +SIGN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "neg_neg", expression={"$cmp": [-1, -1]}, expected=0, msg="Negative equals negative" + ), + ExpressionTestCase( + "pos_neg", expression={"$cmp": [1, -1]}, expected=1, msg="Positive > negative" + ), + ExpressionTestCase( + "neg_pos", expression={"$cmp": [-1, 1]}, expected=-1, msg="Negative < positive" + ), +] + + +NAN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "float_nan_vs_0", expression={"$cmp": [FLOAT_NAN, 0]}, expected=-1, msg="float NaN < 0" + ), + ExpressionTestCase( + "dec_nan_vs_0", + expression={"$cmp": [DECIMAL128_NAN, 0]}, + expected=-1, + msg="Decimal128 NaN < 0", + ), + ExpressionTestCase( + "float_nan_vs_neg_inf", + expression={"$cmp": [FLOAT_NAN, FLOAT_NEGATIVE_INFINITY]}, + expected=-1, + msg="float NaN < -Infinity", + ), + ExpressionTestCase( + "float_nan_vs_null", + expression={"$cmp": [FLOAT_NAN, None]}, + expected=1, + msg="NaN (numeric type) > null", + ), +] + + +INFINITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "inf_vs_neg_inf", + expression={"$cmp": [FLOAT_INFINITY, FLOAT_NEGATIVE_INFINITY]}, + expected=1, + msg="Infinity > -Infinity", + ), + ExpressionTestCase( + "inf_vs_one", + expression={"$cmp": [FLOAT_INFINITY, 1]}, + expected=1, + msg="Infinity > 1", + ), + ExpressionTestCase( + "neg_inf_vs_one", + expression={"$cmp": [FLOAT_NEGATIVE_INFINITY, 1]}, + expected=-1, + msg="-Infinity < 1", + ), +] + + +DEC128_CROSS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "float_inf_dec_inf", + expression={"$cmp": [FLOAT_INFINITY, DECIMAL128_INFINITY]}, + expected=0, + msg="Float Inf equals Decimal128 Inf", + ), + ExpressionTestCase( + "float_neg_inf_dec_neg_inf", + expression={"$cmp": [FLOAT_NEGATIVE_INFINITY, DECIMAL128_NEGATIVE_INFINITY]}, + expected=0, + msg="Float -Inf equals Decimal128 -Inf", + ), + ExpressionTestCase( + "float_inf_dec_neg_inf", + expression={"$cmp": [FLOAT_INFINITY, DECIMAL128_NEGATIVE_INFINITY]}, + expected=1, + msg="Float Inf > Decimal128 -Inf", + ), +] + +ALL_TESTS = SIGN_TESTS + NAN_TESTS + INFINITY_TESTS + DEC128_CROSS_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_cmp_numeric_edge_cases(collection, test): + """Test $cmp sign handling, NaN ordering, Infinity, and cross-type Decimal128 Infinity.""" + result = execute_expression(collection, test.expression) + assert_expression_result(result, expected=test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/comparisons/cmp/test_cmp_null_missing.py b/documentdb_tests/compatibility/tests/core/operator/expressions/comparisons/cmp/test_cmp_null_missing.py new file mode 100644 index 00000000..81c81a7b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/comparisons/cmp/test_cmp_null_missing.py @@ -0,0 +1,108 @@ +""" +Tests for $cmp null and missing field handling. + +Covers missing field behavior, explicit null field values, null vs missing distinctions, +and self-referential comparisons. +""" + +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 ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import FLOAT_NAN + +CMP_MISSING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_vs_null", + expression={"$cmp": ["$missing_field", None]}, + doc={"a": 1}, + expected=-1, + msg="Missing field < null literal", + ), + ExpressionTestCase( + "null_vs_missing", + expression={"$cmp": [None, "$missing_field"]}, + doc={"a": 1}, + expected=1, + msg="Null literal > missing field", + ), + ExpressionTestCase( + "both_missing", + expression={"$cmp": ["$missing1", "$missing2"]}, + doc={"a": 1}, + expected=0, + msg="Both missing fields are equal", + ), + ExpressionTestCase( + "missing_vs_int", + expression={"$cmp": ["$missing_field", 1]}, + doc={"a": 1}, + expected=-1, + msg="Missing field < int", + ), +] + + +CMP_NULL_FIELD_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "explicit_null_vs_null_literal", + expression={"$cmp": ["$a", None]}, + doc={"a": None}, + expected=0, + msg="Explicit null field equals null literal", + ), + ExpressionTestCase( + "explicit_null_vs_missing", + expression={"$cmp": ["$a", "$missing_field"]}, + doc={"a": None}, + expected=1, + msg="Explicit null field > missing field", + ), + ExpressionTestCase( + "explicit_null_vs_int", + expression={"$cmp": ["$a", 1]}, + doc={"a": None}, + expected=-1, + msg="Explicit null field < int", + ), +] + + +CMP_SELF_REF_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "self_ref_int", + expression={"$cmp": ["$a", "$a"]}, + doc={"a": 1}, + expected=0, + msg="Field compared to itself should be equal", + ), + ExpressionTestCase( + "self_ref_null", + expression={"$cmp": ["$a", "$a"]}, + doc={"a": None}, + expected=0, + msg="Null field compared to itself should be equal", + ), + ExpressionTestCase( + "self_ref_nan", + expression={"$cmp": ["$a", "$a"]}, + doc={"a": FLOAT_NAN}, + expected=0, + msg="NaN field compared to itself should be equal", + ), +] + +ALL_TESTS = CMP_MISSING_TESTS + CMP_NULL_FIELD_TESTS + CMP_SELF_REF_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_cmp_null_missing(collection, test): + """Test $cmp null, missing, and self-referential field handling.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result(result, expected=test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/comparisons/cmp/test_cmp_same_type_comparisons.py b/documentdb_tests/compatibility/tests/core/operator/expressions/comparisons/cmp/test_cmp_same_type_comparisons.py new file mode 100644 index 00000000..cca687c6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/comparisons/cmp/test_cmp_same_type_comparisons.py @@ -0,0 +1,214 @@ +""" +Tests for $cmp same-type comparisons. + +Covers date, timestamp, ObjectId, BinData, regex, string, object, +and MinKey/MaxKey comparisons. +""" + +from datetime import datetime + +import pytest +from bson import SON, Binary, MaxKey, MinKey, ObjectId, Regex, Timestamp + +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 ( # noqa: E501 + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.parametrize import pytest_params + +DATE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "same_date", + expression={"$cmp": [datetime(2024, 1, 1), datetime(2024, 1, 1)]}, + expected=0, + msg="Same dates equal", + ), + ExpressionTestCase( + "diff_date", + expression={"$cmp": [datetime(2024, 1, 1), datetime(2024, 1, 2)]}, + expected=-1, + msg="Earlier date < later date", + ), + ExpressionTestCase( + "ms_precision", + expression={ + "$cmp": [datetime(2024, 1, 1, 0, 0, 0, 0), datetime(2024, 1, 1, 0, 0, 0, 1000)] + }, + expected=-1, + msg="Millisecond precision matters", + ), +] + + +TIMESTAMP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "same_timestamp", + expression={"$cmp": [Timestamp(1, 1), Timestamp(1, 1)]}, + expected=0, + msg="Same timestamps equal", + ), + ExpressionTestCase( + "diff_ordinal", + expression={"$cmp": [Timestamp(1, 1), Timestamp(1, 2)]}, + expected=-1, + msg="Different ordinal", + ), + ExpressionTestCase( + "diff_seconds", + expression={"$cmp": [Timestamp(1, 1), Timestamp(2, 1)]}, + expected=-1, + msg="Different seconds", + ), +] + + +OBJECTID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "same_objectid", + expression={ + "$cmp": [ObjectId("aaaaaaaaaaaaaaaaaaaaaaaa"), ObjectId("aaaaaaaaaaaaaaaaaaaaaaaa")] + }, + expected=0, + msg="Same ObjectIds equal", + ), + ExpressionTestCase( + "different", + expression={ + "$cmp": [ObjectId("aaaaaaaaaaaaaaaaaaaaaaaa"), ObjectId("bbbbbbbbbbbbbbbbbbbbbbbb")] + }, + expected=-1, + msg="Lower ObjectId < higher ObjectId", + ), +] + + +BINDATA_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "same_bindata", + expression={"$cmp": [Binary(b"\x00\x00\x00", 0), Binary(b"\x00\x00\x00", 0)]}, + expected=0, + msg="Same BinData equal", + ), + ExpressionTestCase( + "diff_data", + expression={"$cmp": [Binary(b"\x00\x00\x00", 0), Binary(b"\x00\x00\x01", 0)]}, + expected=-1, + msg="Different BinData data", + ), + ExpressionTestCase( + "diff_subtype", + expression={"$cmp": [Binary(b"\x00\x00\x00", 0), Binary(b"\x00\x00\x00", 1)]}, + expected=-1, + msg="Different BinData subtype", + ), +] + + +REGEX_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "same_regex", + expression={"$cmp": [Regex("abc"), Regex("abc")]}, + expected=0, + msg="Same regex equal", + ), + ExpressionTestCase( + "diff_pattern", + expression={"$cmp": [Regex("abc"), Regex("def")]}, + expected=-1, + msg="Different regex patterns", + ), + ExpressionTestCase( + "diff_flags", + expression={"$cmp": [Regex("abc", "i"), Regex("abc")]}, + expected=1, + msg="Regex with flags > without flags", + ), +] + + +STRING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "same_string", + expression={"$cmp": ["abc", "abc"]}, + expected=0, + msg="Same strings equal", + ), + ExpressionTestCase( + "diff_string", + expression={"$cmp": ["abc", "def"]}, + expected=-1, + msg="Lexicographically smaller string < larger", + ), + ExpressionTestCase( + "empty_vs_nonempty", + expression={"$cmp": ["", "a"]}, + expected=-1, + msg="Empty string < non-empty string", + ), +] + + +OBJECT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "same_object", + expression={"$cmp": [SON([("x", 1)]), SON([("x", 1)])]}, + expected=0, + msg="Same objects equal", + ), + ExpressionTestCase( + "diff_object_value", + expression={"$cmp": [SON([("x", 1)]), SON([("x", 2)])]}, + expected=-1, + msg="Object with smaller field value < larger", + ), + ExpressionTestCase( + "empty_vs_nonempty_object", + expression={"$cmp": [SON(), SON([("x", 1)])]}, + expected=-1, + msg="Empty object < non-empty object", + ), +] + + +MINKEY_MAXKEY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "minkey_vs_minkey", + expression={"$cmp": [MinKey(), MinKey()]}, + expected=0, + msg="MinKey equals MinKey", + ), + ExpressionTestCase( + "maxkey_vs_maxkey", + expression={"$cmp": [MaxKey(), MaxKey()]}, + expected=0, + msg="MaxKey equals MaxKey", + ), + ExpressionTestCase( + "minkey_vs_maxkey", + expression={"$cmp": [MinKey(), MaxKey()]}, + expected=-1, + msg="MinKey < MaxKey", + ), +] + + +ALL_TESTS = ( + DATE_TESTS + + TIMESTAMP_TESTS + + OBJECTID_TESTS + + BINDATA_TESTS + + REGEX_TESTS + + STRING_TESTS + + OBJECT_TESTS + + MINKEY_MAXKEY_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_cmp_same_type_comparisons(collection, test): + """Test $cmp same-type comparisons.""" + result = execute_expression(collection, test.expression) + assert_expression_result(result, expected=test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/comparisons/test_comparisons_common.py b/documentdb_tests/compatibility/tests/core/operator/expressions/comparisons/test_comparisons_common.py new file mode 100644 index 00000000..a6849706 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/comparisons/test_comparisons_common.py @@ -0,0 +1,209 @@ +""" +Common tests for comparison operators. + +Covers behaviors shared across $eq, $ne, $gt, $gte, $lt, $lte, $cmp — +such as array index path resolution in aggregation expressions. +""" + +import pytest +from bson import Decimal128 + +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, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +ARRAY_INDEX_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "eq_arr_dot_0_resolves_empty", + expression={"$eq": ["$arr.0", 10]}, + doc={"arr": [10, 20, 30]}, + expected=False, + msg="$eq: $arr.0 resolves to [] not index 0, [] != 10", + ), + ExpressionTestCase( + "ne_arr_dot_0_resolves_empty", + expression={"$ne": ["$arr.0", 10]}, + doc={"arr": [10, 20, 30]}, + expected=True, + msg="$ne: $arr.0 resolves to [] not index 0, [] != 10", + ), + ExpressionTestCase( + "gt_arr_dot_0_resolves_empty", + expression={"$gt": ["$arr.0", 10]}, + doc={"arr": [10, 20, 30]}, + expected=True, + msg="$gt: $arr.0 resolves to [] not index 0, array > number in BSON order", + ), + ExpressionTestCase( + "gte_arr_dot_0_resolves_empty", + expression={"$gte": ["$arr.0", 10]}, + doc={"arr": [10, 20, 30]}, + expected=True, + msg="$gte: $arr.0 resolves to [] not index 0, array >= number in BSON order", + ), + ExpressionTestCase( + "lt_arr_dot_0_resolves_empty", + expression={"$lt": ["$arr.0", 10]}, + doc={"arr": [10, 20, 30]}, + expected=False, + msg="$lt: $arr.0 resolves to [] not index 0, array not < number in BSON order", + ), + ExpressionTestCase( + "lte_arr_dot_0_resolves_empty", + expression={"$lte": ["$arr.0", 10]}, + doc={"arr": [10, 20, 30]}, + expected=False, + msg="$lte: $arr.0 resolves to [] not index 0, array not <= number in BSON order", + ), + ExpressionTestCase( + "cmp_arr_dot_0_resolves_empty", + expression={"$cmp": ["$arr.0", 10]}, + doc={"arr": [10, 20, 30]}, + expected=1, + msg="$cmp: $arr.0 resolves to [] not index 0, array > number in BSON order", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ARRAY_INDEX_PATH_TESTS)) +def test_comparisons_array_index_path(collection, test): + """Test that numeric path components don't index arrays in aggregation expressions.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result(result, expected=test.expected, msg=test.msg) + + +DECIMAL128_CROSS_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dec_nan_cmp_dec_neg_nan", + expression={"$cmp": [Decimal128("NaN"), Decimal128("-NaN")]}, + expected=0, + msg="Decimal128 NaN and -NaN are equal", + ), + ExpressionTestCase( + "dec_inf_eq_double_inf", + expression={"$eq": [Decimal128("Infinity"), float("inf")]}, + expected=True, + msg="Decimal128 Infinity == double Infinity", + ), + ExpressionTestCase( + "dec_inf_cmp_double_inf", + expression={"$cmp": [Decimal128("Infinity"), float("inf")]}, + expected=0, + msg="Decimal128 Infinity and double Infinity are equal", + ), + ExpressionTestCase( + "dec_neg_inf_eq_double_neg_inf", + expression={"$eq": [Decimal128("-Infinity"), float("-inf")]}, + expected=True, + msg="Decimal128 -Infinity == double -Infinity", + ), + ExpressionTestCase( + "dec_nan_cmp_float_nan", + expression={"$cmp": [Decimal128("NaN"), float("nan")]}, + expected=0, + msg="Decimal128 NaN and float NaN are equal", + ), + ExpressionTestCase( + "dec_nan_lt_int", + expression={"$lt": [Decimal128("NaN"), 1]}, + expected=True, + msg="Decimal128 NaN < int", + ), + ExpressionTestCase( + "int_gt_dec_nan", + expression={"$gt": [1, Decimal128("NaN")]}, + expected=True, + msg="int > Decimal128 NaN", + ), + ExpressionTestCase( + "int_gte_dec_nan", + expression={"$gte": [1, Decimal128("NaN")]}, + expected=True, + msg="int >= Decimal128 NaN", + ), + ExpressionTestCase( + "dec_nan_lte_int", + expression={"$lte": [Decimal128("NaN"), 1]}, + expected=True, + msg="Decimal128 NaN <= int", + ), + ExpressionTestCase( + "dec_nan_ne_int", + expression={"$ne": [Decimal128("NaN"), 1]}, + expected=True, + msg="Decimal128 NaN != int", + ), + ExpressionTestCase( + "dec_nan_lt_dec_inf", + expression={"$lt": [Decimal128("NaN"), Decimal128("Infinity")]}, + expected=True, + msg="Decimal128 NaN < Decimal128 Infinity", + ), + ExpressionTestCase( + "dec_inf_gt_dec_nan", + expression={"$gt": [Decimal128("Infinity"), Decimal128("NaN")]}, + expected=True, + msg="Decimal128 Infinity > Decimal128 NaN", + ), + ExpressionTestCase( + "dec_inf_gte_dec_nan", + expression={"$gte": [Decimal128("Infinity"), Decimal128("NaN")]}, + expected=True, + msg="Decimal128 Infinity >= Decimal128 NaN", + ), + ExpressionTestCase( + "dec_nan_lte_dec_inf", + expression={"$lte": [Decimal128("NaN"), Decimal128("Infinity")]}, + expected=True, + msg="Decimal128 NaN <= Decimal128 Infinity", + ), + ExpressionTestCase( + "dec_nan_ne_dec_inf", + expression={"$ne": [Decimal128("NaN"), Decimal128("Infinity")]}, + expected=True, + msg="Decimal128 NaN != Decimal128 Infinity", + ), + ExpressionTestCase( + "dec_nan_lt_double_neg_inf", + expression={"$lt": [Decimal128("NaN"), float("-inf")]}, + expected=True, + msg="Decimal128 NaN < double -Infinity", + ), + ExpressionTestCase( + "double_neg_inf_gt_dec_nan", + expression={"$gt": [float("-inf"), Decimal128("NaN")]}, + expected=True, + msg="double -Infinity > Decimal128 NaN", + ), + ExpressionTestCase( + "double_neg_inf_gte_dec_nan", + expression={"$gte": [float("-inf"), Decimal128("NaN")]}, + expected=True, + msg="double -Infinity >= Decimal128 NaN", + ), + ExpressionTestCase( + "dec_nan_lte_double_neg_inf", + expression={"$lte": [Decimal128("NaN"), float("-inf")]}, + expected=True, + msg="Decimal128 NaN <= double -Infinity", + ), + ExpressionTestCase( + "dec_nan_ne_double_neg_inf", + expression={"$ne": [Decimal128("NaN"), float("-inf")]}, + expected=True, + msg="Decimal128 NaN != double -Infinity", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(DECIMAL128_CROSS_TYPE_TESTS)) +def test_comparisons_decimal128_cross_type(collection, test): + """Test Decimal128 NaN/Infinity cross-type comparisons.""" + result = execute_expression(collection, test.expression) + assert_expression_result(result, expected=test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_comparison_operators.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_comparison_operators.py new file mode 100644 index 00000000..da13fe38 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_comparison_operators.py @@ -0,0 +1,676 @@ +""" +Tests for comparison operators combined with other expression operators. + +Covers $eq/$ne edge cases, $gt/$gte/$lt/$lte/$cmp operator compositions, +and cross-operator combinations. +""" + +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, + execute_expression_with_insert, +) +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +EQ_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "eq_add_int_vs_double", + expression={"$eq": [{"$add": [1, 2]}, 3.0]}, + expected=True, + msg="$add returns int, compared to double — cross-type numeric equality", + ), + ExpressionTestCase( + "eq_subtract_negative_zero", + expression={"$eq": [{"$multiply": [-1, 0.0]}, 0]}, + expected=True, + msg="$subtract producing -0.0 equals int 0", + ), + ExpressionTestCase( + "eq_multiply_overflow_to_double", + expression={"$eq": [{"$multiply": [2147483647, 2]}, 4294967294]}, + expected=True, + msg="$multiply overflowing int32 promotes correctly", + ), + ExpressionTestCase( + "eq_ifNull_null_propagation", + expression={"$eq": [{"$ifNull": [None, None]}, None]}, + expected=True, + msg="$ifNull with both null returns null, equals null literal", + ), +] + +NE_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "ne_concat_empty_vs_literal", + expression={"$ne": [{"$concat": ["", ""]}, ""]}, + expected=False, + msg="$concat of empty strings equals empty string", + ), +] + +EQ_INSERT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "eq_ifNull_missing_field_fallback", + expression={"$eq": [{"$ifNull": ["$missing", "default"]}, "default"]}, + doc={"a": 1}, + expected=True, + msg="$ifNull falls back to default for missing field", + ), + ExpressionTestCase( + "eq_ifNull_null_field_fallback", + expression={"$eq": [{"$ifNull": ["$a", "default"]}, "default"]}, + doc={"a": None}, + expected=True, + msg="$ifNull falls back to default for null field", + ), + ExpressionTestCase( + "eq_ifNull_existing_field_no_fallback", + expression={"$eq": [{"$ifNull": ["$a", "default"]}, 42]}, + doc={"a": 42}, + expected=True, + msg="$ifNull returns existing non-null field, not default", + ), + ExpressionTestCase( + "eq_let_null_vs_missing", + expression={"$let": {"vars": {"x": None}, "in": {"$eq": ["$$x", "$missing"]}}}, + doc={"a": 1}, + expected=False, + msg="$let null variable not equal to missing field", + ), + ExpressionTestCase( + "eq_filter_null_elements", + expression={ + "$filter": { + "input": [1, None, 2, None, 3], + "as": "val", + "cond": {"$ne": ["$$val", None]}, + } + }, + doc={"a": 1}, + expected=[1, 2, 3], + msg="$filter with $ne null removes null elements", + ), + ExpressionTestCase( + "eq_root", + expression={"$eq": ["$$ROOT.a", "$$ROOT.b"]}, + doc={"a": 5, "b": 5}, + expected=True, + msg="$$ROOT.a == $$ROOT.b", + ), + ExpressionTestCase( + "eq_current", + expression={"$eq": ["$$CURRENT.a", "$$CURRENT.b"]}, + doc={"a": 5, "b": 5}, + expected=True, + msg="$$CURRENT.a == $$CURRENT.b", + ), + ExpressionTestCase( + "eq_add", + expression={"$eq": [{"$add": ["$a", "$b"]}, "$c"]}, + doc={"a": 3, "b": 4, "c": 7}, + expected=True, + msg="$eq: add(3,4)=7 == 7", + ), + ExpressionTestCase( + "eq_subtract", + expression={"$eq": [{"$subtract": ["$a", "$b"]}, "$c"]}, + doc={"a": 10, "b": 3, "c": 7}, + expected=True, + msg="$eq: subtract(10,3)=7 == 7", + ), +] + +NE_INSERT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "ne_cond_nested_eq_as_condition", + expression={"$ne": [{"$cond": [{"$eq": ["$a", 1]}, "match", "no"]}, "match"]}, + doc={"a": 1}, + expected=False, + msg="$cond with $eq condition feeds into $ne", + ), + ExpressionTestCase( + "ne_filter", + expression={"$filter": {"input": "$arr", "as": "x", "cond": {"$ne": ["$$x", 2]}}}, + doc={"arr": [1, 2, 3, 4]}, + expected=[1, 3, 4], + msg="$filter with $ne != 2", + ), + ExpressionTestCase( + "ne_root", + expression={"$ne": ["$$ROOT.a", "$$ROOT.b"]}, + doc={"a": 10, "b": 5}, + expected=True, + msg="$$ROOT.a != $$ROOT.b", + ), + ExpressionTestCase( + "ne_current", + expression={"$ne": ["$$CURRENT.a", "$$CURRENT.b"]}, + doc={"a": 10, "b": 5}, + expected=True, + msg="$$CURRENT.a != $$CURRENT.b", + ), + ExpressionTestCase( + "ne_add", + expression={"$ne": [{"$add": ["$a", "$b"]}, "$c"]}, + doc={"a": 3, "b": 4, "c": 8}, + expected=True, + msg="$ne: add(3,4)=7 != 8", + ), + ExpressionTestCase( + "ne_subtract", + expression={"$ne": [{"$subtract": ["$a", "$b"]}, "$c"]}, + doc={"a": 10, "b": 3, "c": 8}, + expected=True, + msg="$ne: subtract(10,3)=7 != 8", + ), +] + +GT_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "gt_let", + expression={"$let": {"vars": {"x": 10, "y": 5}, "in": {"$gt": ["$$x", "$$y"]}}}, + expected=True, + msg="$gt with $let: 10 > 5", + ), + ExpressionTestCase( + "gt_cond", + expression={"$cond": [{"$gt": [10, 5]}, "yes", "no"]}, + expected="yes", + msg="$gt inside $cond", + ), + ExpressionTestCase( + "gt_switch", + expression={ + "$switch": { + "branches": [{"case": {"$gt": [10, 5]}, "then": "matched"}], + "default": "no", + } + }, + expected="matched", + msg="$gt inside $switch", + ), + ExpressionTestCase( + "gt_ifnull", + expression={"$gt": [{"$ifNull": [None, 10]}, 5]}, + expected=True, + msg="$gt with $ifNull(null,10)=10 > 5", + ), +] + +GT_INSERT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "gt_filter", + expression={"$filter": {"input": "$arr", "as": "x", "cond": {"$gt": ["$$x", 2]}}}, + doc={"arr": [1, 2, 3, 4]}, + expected=[3, 4], + msg="$filter with $gt > 2", + ), + ExpressionTestCase( + "gt_root", + expression={"$gt": ["$$ROOT.a", "$$ROOT.b"]}, + doc={"a": 10, "b": 5}, + expected=True, + msg="$$ROOT.a > $$ROOT.b", + ), + ExpressionTestCase( + "gt_current", + expression={"$gt": ["$$CURRENT.a", "$$CURRENT.b"]}, + doc={"a": 10, "b": 5}, + expected=True, + msg="$$CURRENT.a > $$CURRENT.b", + ), + ExpressionTestCase( + "gt_add", + expression={"$gt": [{"$add": ["$a", "$b"]}, "$c"]}, + doc={"a": 3, "b": 4, "c": 6}, + expected=True, + msg="$gt: add(3,4)=7 > 6", + ), + ExpressionTestCase( + "gt_subtract", + expression={"$gt": [{"$subtract": ["$a", "$b"]}, "$c"]}, + doc={"a": 10, "b": 3, "c": 6}, + expected=True, + msg="$gt: subtract(10,3)=7 > 6", + ), +] + +GTE_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "gte_let", + expression={"$let": {"vars": {"x": 10, "y": 5}, "in": {"$gte": ["$$x", "$$y"]}}}, + expected=True, + msg="$gte with $let: 10 >= 5", + ), + ExpressionTestCase( + "gte_cond", + expression={"$cond": [{"$gte": [10, 5]}, "yes", "no"]}, + expected="yes", + msg="$gte inside $cond", + ), + ExpressionTestCase( + "gte_switch", + expression={ + "$switch": { + "branches": [{"case": {"$gte": [10, 10]}, "then": "matched"}], + "default": "no", + } + }, + expected="matched", + msg="$gte inside $switch (equal)", + ), + ExpressionTestCase( + "gte_ifnull", + expression={"$gte": [{"$ifNull": [None, 10]}, 10]}, + expected=True, + msg="$gte with $ifNull(null,10)=10 >= 10", + ), +] + +GTE_INSERT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "gte_filter", + expression={"$filter": {"input": "$arr", "as": "x", "cond": {"$gte": ["$$x", 3]}}}, + doc={"arr": [1, 2, 3, 4]}, + expected=[3, 4], + msg="$filter with $gte >= 3", + ), + ExpressionTestCase( + "gte_root", + expression={"$gte": ["$$ROOT.a", "$$ROOT.b"]}, + doc={"a": 10, "b": 5}, + expected=True, + msg="$$ROOT.a >= $$ROOT.b", + ), + ExpressionTestCase( + "gte_current", + expression={"$gte": ["$$CURRENT.a", "$$CURRENT.b"]}, + doc={"a": 10, "b": 5}, + expected=True, + msg="$$CURRENT.a >= $$CURRENT.b", + ), + ExpressionTestCase( + "gte_add", + expression={"$gte": [{"$add": ["$a", "$b"]}, "$c"]}, + doc={"a": 3, "b": 4, "c": 7}, + expected=True, + msg="$gte: add(3,4)=7 >= 7", + ), + ExpressionTestCase( + "gte_subtract", + expression={"$gte": [{"$subtract": ["$a", "$b"]}, "$c"]}, + doc={"a": 10, "b": 3, "c": 7}, + expected=True, + msg="$gte: subtract(10,3)=7 >= 7", + ), +] + +LT_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "lt_let", + expression={"$let": {"vars": {"x": 5, "y": 10}, "in": {"$lt": ["$$x", "$$y"]}}}, + expected=True, + msg="$lt with $let: 5 < 10", + ), + ExpressionTestCase( + "lt_cond", + expression={"$cond": [{"$lt": [3, 5]}, "yes", "no"]}, + expected="yes", + msg="$lt inside $cond", + ), + ExpressionTestCase( + "lt_switch", + expression={ + "$switch": { + "branches": [{"case": {"$lt": [3, 5]}, "then": "matched"}], + "default": "no", + } + }, + expected="matched", + msg="$lt inside $switch", + ), + ExpressionTestCase( + "lt_ifnull", + expression={"$lt": [{"$ifNull": [None, 3]}, 5]}, + expected=True, + msg="$lt with $ifNull(null,3)=3 < 5", + ), +] + +LT_INSERT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "lt_filter", + expression={"$filter": {"input": "$arr", "as": "x", "cond": {"$lt": ["$$x", 3]}}}, + doc={"arr": [1, 2, 3, 4]}, + expected=[1, 2], + msg="$filter with $lt < 3", + ), + ExpressionTestCase( + "lt_root", + expression={"$lt": ["$$ROOT.a", "$$ROOT.b"]}, + doc={"a": 5, "b": 10}, + expected=True, + msg="$$ROOT.a < $$ROOT.b", + ), + ExpressionTestCase( + "lt_current", + expression={"$lt": ["$$CURRENT.a", "$$CURRENT.b"]}, + doc={"a": 5, "b": 10}, + expected=True, + msg="$$CURRENT.a < $$CURRENT.b", + ), + ExpressionTestCase( + "lt_add", + expression={"$lt": [{"$add": ["$a", "$b"]}, "$c"]}, + doc={"a": 3, "b": 4, "c": 8}, + expected=True, + msg="$lt: add(3,4)=7 < 8", + ), + ExpressionTestCase( + "lt_subtract", + expression={"$lt": [{"$subtract": ["$a", "$b"]}, "$c"]}, + doc={"a": 10, "b": 3, "c": 8}, + expected=True, + msg="$lt: subtract(10,3)=7 < 8", + ), +] + +LTE_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "lte_let", + expression={"$let": {"vars": {"x": 5, "y": 10}, "in": {"$lte": ["$$x", "$$y"]}}}, + expected=True, + msg="$lte with $let: 5 <= 10", + ), + ExpressionTestCase( + "lte_cond", + expression={"$cond": [{"$lte": [3, 5]}, "yes", "no"]}, + expected="yes", + msg="$lte inside $cond", + ), + ExpressionTestCase( + "lte_switch", + expression={ + "$switch": { + "branches": [{"case": {"$lte": [5, 5]}, "then": "matched"}], + "default": "no", + } + }, + expected="matched", + msg="$lte inside $switch (equal)", + ), + ExpressionTestCase( + "lte_ifnull", + expression={"$lte": [{"$ifNull": [None, 3]}, 5]}, + expected=True, + msg="$lte with $ifNull(null,3)=3 <= 5", + ), +] + +LTE_INSERT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "lte_filter", + expression={"$filter": {"input": "$arr", "as": "x", "cond": {"$lte": ["$$x", 2]}}}, + doc={"arr": [1, 2, 3, 4]}, + expected=[1, 2], + msg="$filter with $lte <= 2", + ), + ExpressionTestCase( + "lte_root", + expression={"$lte": ["$$ROOT.a", "$$ROOT.b"]}, + doc={"a": 5, "b": 10}, + expected=True, + msg="$$ROOT.a <= $$ROOT.b", + ), + ExpressionTestCase( + "lte_current", + expression={"$lte": ["$$CURRENT.a", "$$CURRENT.b"]}, + doc={"a": 5, "b": 10}, + expected=True, + msg="$$CURRENT.a <= $$CURRENT.b", + ), + ExpressionTestCase( + "lte_add", + expression={"$lte": [{"$add": ["$a", "$b"]}, "$c"]}, + doc={"a": 3, "b": 4, "c": 7}, + expected=True, + msg="$lte: add(3,4)=7 <= 7", + ), + ExpressionTestCase( + "lte_subtract", + expression={"$lte": [{"$subtract": ["$a", "$b"]}, "$c"]}, + doc={"a": 10, "b": 3, "c": 7}, + expected=True, + msg="$lte: subtract(10,3)=7 <= 7", + ), +] + +CMP_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "cmp_let_gt", + expression={"$let": {"vars": {"x": 10, "y": 5}, "in": {"$cmp": ["$$x", "$$y"]}}}, + expected=1, + msg="$cmp with $let: 10 > 5 returns 1", + ), + ExpressionTestCase( + "cmp_let_eq", + expression={"$let": {"vars": {"x": 5, "y": 5}, "in": {"$cmp": ["$$x", "$$y"]}}}, + expected=0, + msg="$cmp with $let: 5 == 5 returns 0", + ), + ExpressionTestCase( + "cmp_let_lt", + expression={"$let": {"vars": {"x": 3, "y": 10}, "in": {"$cmp": ["$$x", "$$y"]}}}, + expected=-1, + msg="$cmp with $let: 3 < 10 returns -1", + ), + ExpressionTestCase( + "cmp_cond", + expression={"$cond": [{"$eq": [{"$cmp": [10, 5]}, 1]}, "yes", "no"]}, + expected="yes", + msg="$cmp inside $cond: 10 > 5 yields 1", + ), + ExpressionTestCase( + "cmp_switch", + expression={ + "$switch": { + "branches": [{"case": {"$eq": [{"$cmp": [3, 5]}, -1]}, "then": "less"}], + "default": "other", + } + }, + expected="less", + msg="$cmp inside $switch: 3 < 5 yields -1", + ), + ExpressionTestCase( + "cmp_ifnull", + expression={"$cmp": [{"$ifNull": [None, 10]}, 5]}, + expected=1, + msg="$cmp with $ifNull(null,10)=10 > 5 returns 1", + ), +] + +CMP_INSERT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "cmp_root", + expression={"$cmp": ["$$ROOT.a", "$$ROOT.b"]}, + doc={"a": 10, "b": 5}, + expected=1, + msg="$cmp $$ROOT.a > $$ROOT.b returns 1", + ), + ExpressionTestCase( + "cmp_current", + expression={"$cmp": ["$$CURRENT.a", "$$CURRENT.b"]}, + doc={"a": 5, "b": 10}, + expected=-1, + msg="$cmp $$CURRENT.a < $$CURRENT.b returns -1", + ), + ExpressionTestCase( + "cmp_field_refs_equal", + expression={"$cmp": ["$a", "$b"]}, + doc={"a": 7, "b": 7}, + expected=0, + msg="$cmp equal field refs returns 0", + ), + ExpressionTestCase( + "cmp_filter", + expression={ + "$filter": { + "input": "$arr", + "as": "x", + "cond": {"$eq": [{"$cmp": ["$$x", 2]}, 1]}, + } + }, + doc={"arr": [1, 2, 3, 4]}, + expected=[3, 4], + msg="$filter with $cmp > 2", + ), + ExpressionTestCase( + "cmp_add", + expression={"$cmp": [{"$add": ["$a", "$b"]}, "$c"]}, + doc={"a": 3, "b": 4, "c": 7}, + expected=0, + msg="$cmp: add(3,4)=7 == 7 returns 0", + ), + ExpressionTestCase( + "cmp_subtract", + expression={"$cmp": [{"$subtract": ["$a", "$b"]}, "$c"]}, + doc={"a": 10, "b": 3, "c": 8}, + expected=-1, + msg="$cmp: subtract(10,3)=7 < 8 returns -1", + ), +] + +CROSS_OP_INSERT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "gt_and_lt_range_true", + expression={"$and": [{"$gt": ["$a", 1]}, {"$lt": ["$a", 10]}]}, + doc={"a": 5}, + expected=True, + msg="1 < 5 < 10", + ), + ExpressionTestCase( + "gt_and_lt_range_false_low", + expression={"$and": [{"$gt": ["$a", 1]}, {"$lt": ["$a", 10]}]}, + doc={"a": 0}, + expected=False, + msg="0 not > 1", + ), + ExpressionTestCase( + "gte_and_lte_range_boundary", + expression={"$and": [{"$gte": ["$a", 1]}, {"$lte": ["$a", 10]}]}, + doc={"a": 1}, + expected=True, + msg="1 >= 1 and 1 <= 10 (inclusive boundary)", + ), + ExpressionTestCase( + "gt_and_lte_range_boundary_excluded", + expression={"$and": [{"$gt": ["$a", 1]}, {"$lte": ["$a", 10]}]}, + doc={"a": 1}, + expected=False, + msg="1 not > 1 (exclusive lower)", + ), + ExpressionTestCase( + "or_gt_lt_false", + expression={"$or": [{"$gt": ["$a", 10]}, {"$lt": ["$a", 1]}]}, + doc={"a": 5}, + expected=False, + msg="5 not > 10 and 5 not < 1", + ), + ExpressionTestCase( + "or_gt_lt_true", + expression={"$or": [{"$gt": ["$a", 10]}, {"$lt": ["$a", 1]}]}, + doc={"a": 20}, + expected=True, + msg="20 > 10", + ), +] + +ALL_LITERAL_TESTS = ( + EQ_LITERAL_TESTS + + NE_LITERAL_TESTS + + GT_LITERAL_TESTS + + GTE_LITERAL_TESTS + + LT_LITERAL_TESTS + + LTE_LITERAL_TESTS + + CMP_LITERAL_TESTS +) + +ALL_INSERT_TESTS = ( + EQ_INSERT_TESTS + + NE_INSERT_TESTS + + GT_INSERT_TESTS + + GTE_INSERT_TESTS + + LT_INSERT_TESTS + + LTE_INSERT_TESTS + + CMP_INSERT_TESTS + + CROSS_OP_INSERT_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_LITERAL_TESTS)) +def test_combination_literal(collection, test): + """Test comparison operator compositions with literal inputs.""" + result = execute_expression(collection, test.expression) + assert_expression_result(result, expected=test.expected, msg=test.msg) + + +@pytest.mark.parametrize("test", pytest_params(ALL_INSERT_TESTS)) +def test_combination_insert(collection, test): + """Test comparison operator compositions requiring document insertion.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result(result, expected=test.expected, msg=test.msg) + + +def test_eq_remove_in_project(collection): + """Test that $$REMOVE via $cond with $eq omits field from $project output.""" + collection.insert_one({"a": None, "b": "keep"}) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$project": { + "_id": 0, + "b": 1, + "check": {"$cond": [{"$eq": ["$a", None]}, "$$REMOVE", "kept"]}, + } + } + ], + "cursor": {}, + }, + ) + assertSuccess(result, [{"b": "keep"}], "$$REMOVE omits field when $eq triggers it") + + +def test_ne_remove_in_project(collection): + """Test that $$REMOVE in $cond else branch with $ne omits field.""" + collection.insert_one({"a": 1, "b": "keep"}) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$project": { + "_id": 0, + "b": 1, + "check": {"$cond": [{"$ne": ["$a", 1]}, "kept", "$$REMOVE"]}, + } + } + ], + "cursor": {}, + }, + ) + assertSuccess( + result, + [{"b": "keep"}], + "$$REMOVE omits field when $ne else branch selected", + )