diff --git a/documentdb_tests/compatibility/tests/core/operator/query/comparison/lt/__init__.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/lt/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/documentdb_tests/compatibility/tests/core/operator/query/comparison/lt/test_lt_bson_wiring.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/lt/test_lt_bson_wiring.py new file mode 100644 index 00000000..9a26f8d3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/lt/test_lt_bson_wiring.py @@ -0,0 +1,152 @@ +""" +Tests for $lt BSON type wiring. + +A representative sample of types to confirm $lt is wired up to the +BSON comparison engine correctly (not exhaustive cross-type matrix). +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Timestamp +from bson.codec_options import CodecOptions + +from documentdb_tests.compatibility.tests.core.operator.query.utils.query_test_case import ( + QueryTestCase, +) +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="double", + filter={"a": {"$lt": 7.0}}, + doc=[{"_id": 1, "a": 1.0}, {"_id": 2, "a": 5.0}, {"_id": 3, "a": 10.0}], + expected=[{"_id": 1, "a": 1.0}, {"_id": 2, "a": 5.0}], + msg="$lt with double returns docs with value < 7.0", + ), + QueryTestCase( + id="int", + filter={"a": {"$lt": 7}}, + doc=[{"_id": 1, "a": 1}, {"_id": 2, "a": 5}, {"_id": 3, "a": 10}], + expected=[{"_id": 1, "a": 1}, {"_id": 2, "a": 5}], + msg="$lt with int returns docs with value < 7", + ), + QueryTestCase( + id="long", + filter={"a": {"$lt": Int64(7)}}, + doc=[ + {"_id": 1, "a": Int64(1)}, + {"_id": 2, "a": Int64(5)}, + {"_id": 3, "a": Int64(10)}, + ], + expected=[{"_id": 1, "a": Int64(1)}, {"_id": 2, "a": Int64(5)}], + msg="$lt with long returns docs with value < 7", + ), + QueryTestCase( + id="decimal128", + filter={"a": {"$lt": Decimal128("7")}}, + doc=[ + {"_id": 1, "a": Decimal128("1")}, + {"_id": 2, "a": Decimal128("5")}, + {"_id": 3, "a": Decimal128("10")}, + ], + expected=[ + {"_id": 1, "a": Decimal128("1")}, + {"_id": 2, "a": Decimal128("5")}, + ], + msg="$lt with decimal128 returns docs with value < 7", + ), + QueryTestCase( + id="string", + filter={"a": {"$lt": "cherry"}}, + doc=[ + {"_id": 1, "a": "apple"}, + {"_id": 2, "a": "banana"}, + {"_id": 3, "a": "cherry"}, + ], + expected=[{"_id": 1, "a": "apple"}, {"_id": 2, "a": "banana"}], + msg="$lt with string returns docs with value < 'cherry'", + ), + QueryTestCase( + id="date", + filter={"a": {"$lt": datetime(2024, 1, 1, tzinfo=timezone.utc)}}, + doc=[ + {"_id": 1, "a": datetime(2020, 1, 1, tzinfo=timezone.utc)}, + {"_id": 2, "a": datetime(2023, 1, 1, tzinfo=timezone.utc)}, + {"_id": 3, "a": datetime(2025, 1, 1, tzinfo=timezone.utc)}, + ], + expected=[ + {"_id": 1, "a": datetime(2020, 1, 1, tzinfo=timezone.utc)}, + {"_id": 2, "a": datetime(2023, 1, 1, tzinfo=timezone.utc)}, + ], + msg="$lt with date returns docs with earlier dates", + ), + QueryTestCase( + id="timestamp", + filter={"a": {"$lt": Timestamp(2000, 1)}}, + doc=[ + {"_id": 1, "a": Timestamp(1000, 1)}, + {"_id": 2, "a": Timestamp(3000, 1)}, + ], + expected=[{"_id": 1, "a": Timestamp(1000, 1)}], + msg="$lt with timestamp returns docs with smaller timestamp", + ), + QueryTestCase( + id="objectid", + filter={"a": {"$lt": ObjectId("507f1f77bcf86cd799439012")}}, + doc=[ + {"_id": 1, "a": ObjectId("507f1f77bcf86cd799439011")}, + {"_id": 2, "a": ObjectId("507f1f77bcf86cd799439013")}, + ], + expected=[{"_id": 1, "a": ObjectId("507f1f77bcf86cd799439011")}], + msg="$lt with ObjectId returns docs with earlier ObjectId", + ), + QueryTestCase( + id="boolean", + filter={"a": {"$lt": True}}, + doc=[{"_id": 1, "a": False}, {"_id": 2, "a": True}], + expected=[{"_id": 1, "a": False}], + msg="$lt with boolean true returns doc with false", + ), + QueryTestCase( + id="bindata", + filter={"a": {"$lt": Binary(b"\x05\x06", 1)}}, + doc=[ + {"_id": 1, "a": Binary(b"\x01\x02", 1)}, + {"_id": 2, "a": Binary(b"\x09\x0a", 1)}, + ], + expected=[{"_id": 1, "a": Binary(b"\x01\x02", 1)}], + msg="$lt with BinData returns docs with smaller binary", + ), + QueryTestCase( + id="minkey", + filter={"a": {"$lt": MinKey()}}, + doc=[{"_id": 1, "a": MinKey()}, {"_id": 2, "a": 1}], + expected=[], + msg="$lt with MinKey returns no matches", + ), + QueryTestCase( + id="maxkey", + filter={"a": {"$lt": MaxKey()}}, + doc=[ + {"_id": 1, "a": 1}, + {"_id": 2, "a": "hello"}, + {"_id": 3, "a": MaxKey()}, + ], + expected=[{"_id": 1, "a": 1}, {"_id": 2, "a": "hello"}], + msg="$lt with MaxKey returns all non-MaxKey typed docs", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(TESTS)) +def test_lt_bson_wiring(collection, test): + """Parametrized test for $lt BSON type wiring.""" + collection.insert_many(test.doc) + codec = CodecOptions(tz_aware=True, tzinfo=timezone.utc) + result = execute_command( + collection, {"find": collection.name, "filter": test.filter}, codec_options=codec + ) + assertSuccess(result, test.expected, ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/comparison/lt/test_lt_edge_cases.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/lt/test_lt_edge_cases.py new file mode 100644 index 00000000..1acd1789 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/lt/test_lt_edge_cases.py @@ -0,0 +1,68 @@ +""" +Edge case tests for $lt operator. + +Covers deeply nested field paths with NaN, large array element matching, +empty string ordering, null/missing field handling. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.query.utils.query_test_case import ( + QueryTestCase, +) +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +_ARR_WITH_ZERO = list(range(1, 1001)) + [0] +_ARR_WITHOUT = list(range(1, 1001)) + +EDGE_CASE_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="deeply_nested_field_with_nan", + filter={"a.b.c.d.e": {"$lt": 10}}, + doc=[ + {"_id": 1, "a": {"b": {"c": {"d": {"e": 5}}}}}, + {"_id": 2, "a": {"b": {"c": {"d": {"e": 15}}}}}, + {"_id": 3, "a": {"b": {"c": {"d": {"e": float("nan")}}}}}, + ], + expected=[{"_id": 1, "a": {"b": {"c": {"d": {"e": 5}}}}}], + msg="$lt on deeply nested field; NaN does not satisfy $lt", + ), + QueryTestCase( + id="large_array_element_match", + filter={"a": {"$lt": 1}}, + doc=[{"_id": 1, "a": _ARR_WITH_ZERO}, {"_id": 2, "a": _ARR_WITHOUT}], + expected=[{"_id": 1, "a": _ARR_WITH_ZERO}], + msg="$lt matches element in a large (1001-element) array", + ), + QueryTestCase( + id="empty_string_lt_nonempty", + filter={"a": {"$lt": "a"}}, + doc=[{"_id": 1, "a": ""}, {"_id": 2, "a": "b"}], + expected=[{"_id": 1, "a": ""}], + msg="empty string is less than any non-empty string", + ), + QueryTestCase( + id="null_query_no_match", + filter={"a": {"$lt": None}}, + doc=[{"_id": 1, "a": 5}, {"_id": 2, "a": None}, {"_id": 3}], + expected=[], + msg="$lt null returns no documents", + ), + QueryTestCase( + id="null_field_not_lt_numeric", + filter={"a": {"$lt": 5}}, + doc=[{"_id": 1, "a": None}], + expected=[], + msg="null field does not match $lt with numeric query", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(EDGE_CASE_TESTS)) +def test_lt_edge_cases(collection, test): + """Parametrized test for $lt edge cases.""" + collection.insert_many(test.doc) + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertSuccess(result, test.expected, ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/comparison/lt/test_lt_field_lookup.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/lt/test_lt_field_lookup.py new file mode 100644 index 00000000..f577097f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/lt/test_lt_field_lookup.py @@ -0,0 +1,143 @@ +""" +Tests for $lt field lookup and array value comparison. + +Covers array element matching, array index access, +numeric key disambiguation, _id with ObjectId, array of embedded documents, +whole-array comparison, empty array behavior, and embedded document dot-notation. +""" + +import pytest +from bson import ObjectId + +from documentdb_tests.compatibility.tests.core.operator.query.utils.query_test_case import ( + QueryTestCase, +) +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +FIELD_LOOKUP_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="array_element_matching", + filter={"a": {"$lt": 5}}, + doc=[{"_id": 1, "a": [3, 7, 12]}, {"_id": 2, "a": [10, 20]}], + expected=[{"_id": 1, "a": [3, 7, 12]}], + msg="$lt matches if any array element satisfies condition", + ), + QueryTestCase( + id="array_no_element_match", + filter={"a": {"$lt": 5}}, + doc=[{"_id": 1, "a": [10, 20]}], + expected=[], + msg="$lt on array with no element less than query", + ), + QueryTestCase( + id="array_index_zero", + filter={"arr.0": {"$lt": 20}}, + doc=[{"_id": 1, "arr": [10, 30]}, {"_id": 2, "arr": [25, 5]}], + expected=[{"_id": 1, "arr": [10, 30]}], + msg="$lt on array index 0", + ), + QueryTestCase( + id="numeric_key_on_object", + filter={"a.0.b": {"$lt": 5}}, + doc=[{"_id": 1, "a": {"0": {"b": 3}}}, {"_id": 2, "a": {"0": {"b": 10}}}], + expected=[{"_id": 1, "a": {"0": {"b": 3}}}], + msg="$lt with numeric key on object (not array)", + ), + QueryTestCase( + id="id_objectid", + filter={"_id": {"$lt": ObjectId("507f1f77bcf86cd799439012")}}, + doc=[ + {"_id": ObjectId("507f1f77bcf86cd799439011"), "a": 1}, + {"_id": ObjectId("507f1f77bcf86cd799439013"), "a": 2}, + ], + expected=[{"_id": ObjectId("507f1f77bcf86cd799439011"), "a": 1}], + msg="$lt on _id with ObjectId", + ), + QueryTestCase( + id="array_of_embedded_docs_dot_notation", + filter={"a.b": {"$lt": 5}}, + doc=[ + {"_id": 1, "a": [{"b": 3}, {"b": 7}]}, + {"_id": 2, "a": [{"b": 10}, {"b": 20}]}, + ], + expected=[{"_id": 1, "a": [{"b": 3}, {"b": 7}]}], + msg="$lt on array of embedded docs via dot notation", + ), +] + +ARRAY_VALUE_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="array_lt_array_element_comparison", + filter={"a": {"$lt": [1, 3]}}, + doc=[{"_id": 1, "a": [1, 2]}], + expected=[{"_id": 1, "a": [1, 2]}], + msg="$lt array [1,2] < [1,3] via array comparison", + ), + QueryTestCase( + id="shorter_array_prefix_lt_longer", + filter={"a": {"$lt": [1, 2, 3]}}, + doc=[{"_id": 1, "a": [1, 2]}], + expected=[{"_id": 1, "a": [1, 2]}], + msg="$lt shorter array prefix is less than longer", + ), + QueryTestCase( + id="empty_array_lt_nonempty_array", + filter={"a": {"$lt": [1]}}, + doc=[{"_id": 1, "a": []}], + expected=[{"_id": 1, "a": []}], + msg="$lt empty array is less than non-empty array", + ), + QueryTestCase( + id="array_with_null_element_lt_scalar", + filter={"a": {"$lt": 10}}, + doc=[{"_id": 1, "a": [None, 5]}], + expected=[{"_id": 1, "a": [None, 5]}], + msg="$lt matches array with element 5 < 10", + ), + QueryTestCase( + id="empty_array_not_lt_scalar", + filter={"a": {"$lt": 5}}, + doc=[{"_id": 1, "a": []}], + expected=[], + msg="$lt empty array does not match scalar query", + ), +] + +EMBEDDED_DOC_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="embedded_field_lt", + filter={"carrier.fee": {"$lt": 4}}, + doc=[ + {"_id": 1, "item": "nuts", "carrier": {"name": "Shipit", "fee": 3}}, + {"_id": 2, "item": "bolts", "carrier": {"name": "Shipit", "fee": 4}}, + {"_id": 3, "item": "washers", "carrier": {"name": "Shipit", "fee": 1}}, + ], + expected=[ + {"_id": 1, "item": "nuts", "carrier": {"name": "Shipit", "fee": 3}}, + {"_id": 3, "item": "washers", "carrier": {"name": "Shipit", "fee": 1}}, + ], + msg="$lt on embedded field returns docs with fee < 4", + ), + QueryTestCase( + id="embedded_field_missing_excluded", + filter={"carrier.fee": {"$lt": 10}}, + doc=[ + {"_id": 1, "carrier": {"fee": 3}}, + {"_id": 2, "item": "no carrier"}, + ], + expected=[{"_id": 1, "carrier": {"fee": 3}}], + msg="$lt on embedded field excludes docs missing the embedded path", + ), +] + +ALL_TESTS = FIELD_LOOKUP_TESTS + ARRAY_VALUE_TESTS + EMBEDDED_DOC_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_lt_field_lookup(collection, test): + """Parametrized test for $lt field lookup and array comparison.""" + collection.insert_many(test.doc) + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertSuccess(result, test.expected, ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/comparison/lt/test_lt_numeric_edge_cases.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/lt/test_lt_numeric_edge_cases.py new file mode 100644 index 00000000..0c5b98f9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/lt/test_lt_numeric_edge_cases.py @@ -0,0 +1,292 @@ +""" +Tests for $lt numeric edge cases. + +Covers all cross-type numeric comparisons (int, long, double, decimal128), +non-matching cross-type comparison, INT32 and INT64 boundary values, +float and Decimal128 NaN, infinity, negative zero, precision loss, +and Decimal128 infinity. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.query.utils.query_test_case import ( + QueryTestCase, +) +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DOUBLE_MAX_SAFE_INTEGER, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_PRECISION_LOSS, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT64_MAX, +) + +CROSS_TYPE_NUMERIC_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="int_lt_double", + filter={"a": {"$lt": 5.5}}, + doc=[{"_id": 1, "a": 5}, {"_id": 2, "a": 6}], + expected=[{"_id": 1, "a": 5}], + msg="int field < double query via type bracketing", + ), + QueryTestCase( + id="double_lt_int", + filter={"a": {"$lt": 6}}, + doc=[{"_id": 1, "a": 5.5}, {"_id": 2, "a": 6.5}], + expected=[{"_id": 1, "a": 5.5}], + msg="double field < int query via type bracketing", + ), + QueryTestCase( + id="int_lt_long", + filter={"a": {"$lt": Int64(10)}}, + doc=[{"_id": 1, "a": 5}, {"_id": 2, "a": 15}], + expected=[{"_id": 1, "a": 5}], + msg="int field < long query", + ), + QueryTestCase( + id="long_lt_int", + filter={"a": {"$lt": 10}}, + doc=[{"_id": 1, "a": Int64(5)}, {"_id": 2, "a": Int64(15)}], + expected=[{"_id": 1, "a": Int64(5)}], + msg="long field < int query", + ), + QueryTestCase( + id="int_lt_decimal128", + filter={"a": {"$lt": Decimal128("10.5")}}, + doc=[{"_id": 1, "a": 5}, {"_id": 2, "a": 15}], + expected=[{"_id": 1, "a": 5}], + msg="int field < decimal128 query", + ), + QueryTestCase( + id="decimal128_lt_int", + filter={"a": {"$lt": 10}}, + doc=[{"_id": 1, "a": Decimal128("5")}, {"_id": 2, "a": Decimal128("15")}], + expected=[{"_id": 1, "a": Decimal128("5")}], + msg="decimal128 field < int query", + ), + QueryTestCase( + id="long_lt_double", + filter={"a": {"$lt": 10.5}}, + doc=[{"_id": 1, "a": Int64(5)}, {"_id": 2, "a": Int64(15)}], + expected=[{"_id": 1, "a": Int64(5)}], + msg="long field < double query", + ), + QueryTestCase( + id="double_lt_long", + filter={"a": {"$lt": Int64(10)}}, + doc=[{"_id": 1, "a": 5.5}, {"_id": 2, "a": 15.5}], + expected=[{"_id": 1, "a": 5.5}], + msg="double field < long query", + ), + QueryTestCase( + id="long_lt_decimal128", + filter={"a": {"$lt": Decimal128("10.5")}}, + doc=[{"_id": 1, "a": Int64(5)}, {"_id": 2, "a": Int64(15)}], + expected=[{"_id": 1, "a": Int64(5)}], + msg="long field < decimal128 query", + ), + QueryTestCase( + id="decimal128_lt_long", + filter={"a": {"$lt": Int64(10)}}, + doc=[{"_id": 1, "a": Decimal128("5")}, {"_id": 2, "a": Decimal128("15")}], + expected=[{"_id": 1, "a": Decimal128("5")}], + msg="decimal128 field < long query", + ), + QueryTestCase( + id="double_lt_decimal128", + filter={"a": {"$lt": Decimal128("10.5")}}, + doc=[{"_id": 1, "a": 5.5}, {"_id": 2, "a": 15.5}], + expected=[{"_id": 1, "a": 5.5}], + msg="double field < decimal128 query", + ), + QueryTestCase( + id="decimal128_lt_double", + filter={"a": {"$lt": 10.5}}, + doc=[{"_id": 1, "a": Decimal128("5.5")}, {"_id": 2, "a": Decimal128("15.5")}], + expected=[{"_id": 1, "a": Decimal128("5.5")}], + msg="decimal128 field < double query", + ), +] + +NON_MATCHING_CROSS_TYPE_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="string_not_lt_int", + filter={"a": {"$lt": 10}}, + doc=[{"_id": 1, "a": "hello"}], + expected=[], + msg="string field does not match $lt with int query", + ), +] + +BOUNDARY_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="boundary_int64_max_equal", + filter={"a": {"$lt": INT64_MAX}}, + doc=[{"_id": 1, "a": INT64_MAX}], + expected=[], + msg="$lt with INT64_MAX equal value does not match", + ), + QueryTestCase( + id="boundary_int64_max_less", + filter={"a": {"$lt": INT64_MAX}}, + doc=[{"_id": 1, "a": Int64(INT64_MAX - 1)}], + expected=[{"_id": 1, "a": Int64(INT64_MAX - 1)}], + msg="$lt with INT64_MAX matches value one less", + ), + QueryTestCase( + id="boundary_int32_max_equal", + filter={"a": {"$lt": INT32_MAX}}, + doc=[{"_id": 1, "a": INT32_MAX}], + expected=[], + msg="$lt with INT32_MAX equal value does not match", + ), + QueryTestCase( + id="boundary_int32_max_less", + filter={"a": {"$lt": INT32_MAX}}, + doc=[{"_id": 1, "a": INT32_MAX - 1}], + expected=[{"_id": 1, "a": INT32_MAX - 1}], + msg="$lt with INT32_MAX - 1 matches", + ), + QueryTestCase( + id="boundary_int32_min_equal", + filter={"a": {"$lt": INT32_MIN}}, + doc=[{"_id": 1, "a": INT32_MIN}], + expected=[], + msg="$lt with INT32_MIN equal value does not match", + ), + QueryTestCase( + id="boundary_int32_min_plus1", + filter={"a": {"$lt": INT32_MIN + 1}}, + doc=[{"_id": 1, "a": INT32_MIN}], + expected=[{"_id": 1, "a": INT32_MIN}], + msg="$lt with INT32_MIN + 1 matches INT32_MIN", + ), + QueryTestCase( + id="boundary_int32_max_cross_to_long", + filter={"a": {"$lt": Int64(INT32_MAX + 1)}}, + doc=[{"_id": 1, "a": INT32_MAX}, {"_id": 2, "a": Int64(INT32_MAX + 2)}], + expected=[{"_id": 1, "a": INT32_MAX}], + msg="$lt INT32_MAX < INT32_MAX+1 (as long) cross-boundary", + ), +] + +NAN_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="nan_field_not_lt_number", + filter={"a": {"$lt": 5}}, + doc=[{"_id": 1, "a": FLOAT_NAN}], + expected=[], + msg="NaN field does not match $lt 5", + ), + QueryTestCase( + id="number_not_lt_nan_query", + filter={"a": {"$lt": FLOAT_NAN}}, + doc=[{"_id": 1, "a": 5}], + expected=[], + msg="numeric field does not match $lt NaN", + ), + QueryTestCase( + id="decimal128_nan_field_not_lt_number", + filter={"a": {"$lt": Decimal128("5")}}, + doc=[{"_id": 1, "a": DECIMAL128_NAN}], + expected=[], + msg="Decimal128 NaN field does not match $lt decimal128 numeric", + ), + QueryTestCase( + id="decimal128_number_not_lt_decimal128_nan", + filter={"a": {"$lt": DECIMAL128_NAN}}, + doc=[{"_id": 1, "a": Decimal128("5")}], + expected=[], + msg="Decimal128 numeric field does not match $lt Decimal128 NaN", + ), +] + +INFINITY_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="number_lt_infinity", + filter={"a": {"$lt": FLOAT_INFINITY}}, + doc=[{"_id": 1, "a": 999999}], + expected=[{"_id": 1, "a": 999999}], + msg="large number is less than Infinity", + ), + QueryTestCase( + id="number_not_lt_neg_infinity", + filter={"a": {"$lt": FLOAT_NEGATIVE_INFINITY}}, + doc=[{"_id": 1, "a": -999999}], + expected=[], + msg="negative number is not less than -Infinity", + ), +] + +NEGATIVE_ZERO_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="neg_zero_not_lt_pos_zero", + filter={"a": {"$lt": 0.0}}, + doc=[{"_id": 1, "a": DOUBLE_NEGATIVE_ZERO}], + expected=[], + msg="-0.0 is not less than 0.0 (they are equal)", + ), +] + +PRECISION_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="long_2e53_plus1_not_lt_double_2e53", + filter={"a": {"$lt": float(DOUBLE_MAX_SAFE_INTEGER)}}, + doc=[{"_id": 1, "a": Int64(DOUBLE_PRECISION_LOSS)}], + expected=[], + msg="Long(2^53+1) is not less than double(2^53) — precision loss boundary", + ), + QueryTestCase( + id="int64_max_lt_double_rounded_up", + filter={"a": {"$lt": float(INT64_MAX)}}, + doc=[{"_id": 1, "a": INT64_MAX}], + expected=[{"_id": 1, "a": INT64_MAX}], + msg="INT64_MAX is less than rounded-up double representation", + ), +] + +DECIMAL128_INFINITY_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="decimal128_number_lt_inf", + filter={"a": {"$lt": DECIMAL128_INFINITY}}, + doc=[{"_id": 1, "a": Decimal128("0")}, {"_id": 2, "a": DECIMAL128_INFINITY}], + expected=[{"_id": 1, "a": Decimal128("0")}], + msg="Decimal128 0 is less than Decimal128 Infinity", + ), + QueryTestCase( + id="decimal128_number_not_lt_neg_inf", + filter={"a": {"$lt": DECIMAL128_NEGATIVE_INFINITY}}, + doc=[{"_id": 1, "a": Decimal128("-999999")}], + expected=[], + msg="Decimal128 number is not less than Decimal128 -Infinity", + ), +] + +ALL_TESTS = ( + CROSS_TYPE_NUMERIC_TESTS + + NON_MATCHING_CROSS_TYPE_TESTS + + BOUNDARY_TESTS + + NAN_TESTS + + INFINITY_TESTS + + NEGATIVE_ZERO_TESTS + + PRECISION_TESTS + + DECIMAL128_INFINITY_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_lt_numeric_edge_cases(collection, test): + """Parametrized test for $lt numeric edge cases.""" + collection.insert_many(test.doc) + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertSuccess(result, test.expected, ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/comparison/lt/test_lt_value_matching.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/lt/test_lt_value_matching.py new file mode 100644 index 00000000..73d024fb --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/lt/test_lt_value_matching.py @@ -0,0 +1,125 @@ +""" +Tests for $lt value matching — arrays, objects, dates, and timestamps. + +Covers array comparison semantics (first-element-wins, nested traversal), +object/subdocument comparison (field values, empty, NaN sort order), +date ordering across epoch boundary, timestamp ordering, +and Date vs Timestamp type distinction. +""" + +import pytest +from bson import SON, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.query.utils.query_test_case import ( + QueryTestCase, +) +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DATE_BEFORE_EPOCH, + DATE_EPOCH, + TS_EPOCH, +) + +ARRAY_COMPARISON_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="array_first_element_decides", + filter={"a": {"$lt": [3, 2, 1]}}, + doc=[{"_id": 1, "a": [1, 2, 3]}], + expected=[{"_id": 1, "a": [1, 2, 3]}], + msg="$lt [1,2,3] < [3,2,1] because first element 1 < 3", + ), + QueryTestCase( + id="nested_array_not_traversed", + filter={"a": {"$lt": 5}}, + doc=[{"_id": 1, "a": [[1, 2], [3, 4]]}], + expected=[], + msg="$lt scalar does not traverse nested arrays", + ), +] + +OBJECT_COMPARISON_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="subdocument_field_value_lt", + filter={"a": {"$lt": SON([("x", 2), ("y", 1)])}}, + doc=[ + {"_id": 1, "a": SON([("x", 1), ("y", 1)])}, + {"_id": 2, "a": SON([("x", 3), ("y", 1)])}, + ], + expected=[{"_id": 1, "a": {"x": 1, "y": 1}}], + msg="$lt subdocument compares field values in order", + ), + QueryTestCase( + id="empty_doc_lt_nonempty", + filter={"a": {"$lt": {"x": 1}}}, + doc=[{"_id": 1, "a": {}}, {"_id": 2, "a": {"x": 2}}], + expected=[{"_id": 1, "a": {}}], + msg="$lt empty document is less than non-empty document", + ), + QueryTestCase( + id="subdocument_nothing_lt_nan_subdocument", + filter={"a": {"$lt": SON([("x", float("nan"))])}}, + doc=[ + {"_id": 1, "a": SON([("x", 5)])}, + {"_id": 2, "a": SON([("x", float("nan"))])}, + ], + expected=[], + msg="$lt nothing is less than subdocument containing NaN (NaN sorts lowest)", + ), +] + +DATE_COMPARISON_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="pre_epoch_lt_epoch", + filter={"a": {"$lt": DATE_EPOCH}}, + doc=[{"_id": 1, "a": DATE_BEFORE_EPOCH}, {"_id": 2, "a": DATE_EPOCH}], + expected=[{"_id": 1, "a": DATE_BEFORE_EPOCH}], + msg="pre-epoch date is less than epoch", + ), + QueryTestCase( + id="date_equal_not_lt", + filter={"a": {"$lt": DATE_BEFORE_EPOCH}}, + doc=[{"_id": 1, "a": DATE_BEFORE_EPOCH}], + expected=[], + msg="equal date does not match $lt", + ), + QueryTestCase( + id="ts_seconds_then_increment", + filter={"a": {"$lt": Timestamp(100, 2)}}, + doc=[ + {"_id": 1, "a": Timestamp(100, 1)}, + {"_id": 2, "a": Timestamp(100, 2)}, + {"_id": 3, "a": Timestamp(99, 999)}, + ], + expected=[ + {"_id": 1, "a": Timestamp(100, 1)}, + {"_id": 3, "a": Timestamp(99, 999)}, + ], + msg="Timestamp orders by seconds first, then increment", + ), + QueryTestCase( + id="date_not_lt_timestamp", + filter={"a": {"$lt": Timestamp(2000000000, 1)}}, + doc=[{"_id": 1, "a": DATE_EPOCH}], + expected=[], + msg="Date field does not match $lt with Timestamp query (different BSON types)", + ), + QueryTestCase( + id="timestamp_not_lt_date", + filter={"a": {"$lt": DATE_EPOCH}}, + doc=[{"_id": 1, "a": TS_EPOCH}], + expected=[], + msg="Timestamp field does not match $lt with Date query (different BSON types)", + ), +] + +ALL_TESTS = ARRAY_COMPARISON_TESTS + OBJECT_COMPARISON_TESTS + DATE_COMPARISON_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_lt_value_matching(collection, test): + """Parametrized test for $lt value matching.""" + collection.insert_many(test.doc) + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertSuccess(result, test.expected, ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/comparison/test_comparison_combinations.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/test_comparison_combinations.py index c7a83d8f..84cb4178 100644 --- a/documentdb_tests/compatibility/tests/core/operator/query/comparison/test_comparison_combinations.py +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/test_comparison_combinations.py @@ -103,6 +103,18 @@ expected=[{"_id": 3, "a": 4}, {"_id": 4, "a": 5}], msg="$in with $gt on same level", ), + QueryTestCase( + id="in_with_lt_same_level", + filter={"a": {"$in": [1, 2, 3, 4, 5], "$lt": 3}}, + doc=[ + {"_id": 1, "a": 1}, + {"_id": 2, "a": 3}, + {"_id": 3, "a": 2}, + {"_id": 4, "a": 5}, + ], + expected=[{"_id": 1, "a": 1}, {"_id": 3, "a": 2}], + msg="$in with $lt on same level", + ), QueryTestCase( id="nin_with_range_same_level", filter={"a": {"$nin": [3, 5], "$gte": 1, "$lte": 7}}, @@ -168,6 +180,30 @@ expected=[{"_id": 2, "a": 7}, {"_id": 3}], msg="$not $lt matches gte AND missing", ), + QueryTestCase( + id="not_lte", + filter={"a": {"$not": {"$lte": 5}}}, + doc=[ + {"_id": 1, "a": 3}, + {"_id": 2, "a": 5}, + {"_id": 3, "a": 7}, + {"_id": 4}, + ], + expected=[{"_id": 3, "a": 7}, {"_id": 4}], + msg="$not $lte matches gt AND missing", + ), + QueryTestCase( + id="not_gte", + filter={"a": {"$not": {"$gte": 5}}}, + doc=[ + {"_id": 1, "a": 3}, + {"_id": 2, "a": 5}, + {"_id": 3, "a": 7}, + {"_id": 4}, + ], + expected=[{"_id": 1, "a": 3}, {"_id": 4}], + msg="$not $gte matches lt AND missing", + ), QueryTestCase( id="not_in", filter={"a": {"$not": {"$in": [1, 2]}}}, @@ -273,6 +309,17 @@ expected=[{"_id": 2, "a": 2, "b": "y"}], msg="$in with $ne on different fields", ), + QueryTestCase( + id="ne_with_lt_different_fields", + filter={"a": {"$ne": 2}, "b": {"$lt": 10}}, + doc=[ + {"_id": 1, "a": 2, "b": 5}, + {"_id": 2, "a": 3, "b": 5}, + {"_id": 3, "a": 3, "b": 15}, + ], + expected=[{"_id": 2, "a": 3, "b": 5}], + msg="$ne with $lt on different fields", + ), QueryTestCase( id="in_and_nin_different_fields", filter={"a": {"$in": [1, 2, 3]}, "b": {"$nin": [10, 20]}},