diff --git a/documentdb_tests/compatibility/tests/core/operator/query/comparison/in/__init__.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/in/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/documentdb_tests/compatibility/tests/core/operator/query/comparison/in/test_in_argument_handling.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/in/test_in_argument_handling.py new file mode 100644 index 00000000..6f1e5da7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/in/test_in_argument_handling.py @@ -0,0 +1,142 @@ +""" +Tests for $in query operator argument handling. + +Covers empty array, single element, many elements, duplicates, mixed types, +dollar-prefixed string literals, large arrays, equivalence to $or, +and invalid (non-array) arguments. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.query.utils.query_test_case import ( + QueryTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccess +from documentdb_tests.framework.error_codes import BAD_VALUE_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +SUCCESS_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="empty_array_no_results", + filter={"x": {"$in": []}}, + doc=[{"_id": 1, "x": 1}], + expected=[], + msg="$in with empty array returns nothing", + ), + QueryTestCase( + id="single_element_equality", + filter={"x": {"$in": [1]}}, + doc=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + expected=[{"_id": 1, "x": 1}], + msg="$in with single element array is equivalent to equality", + ), + QueryTestCase( + id="many_elements_match_any", + filter={"x": {"$in": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]}}, + doc=[{"_id": i, "x": i} for i in range(1, 11)], + expected=[{"_id": i, "x": i} for i in range(1, 11)], + msg="$in with many elements matches any", + ), + QueryTestCase( + id="duplicate_values_in_array", + filter={"x": {"$in": [1, 1, 1]}}, + doc=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + expected=[{"_id": 1, "x": 1}], + msg="$in with duplicate values in array matches docs where x is 1", + ), + QueryTestCase( + id="mixed_types_in_array", + filter={"x": {"$in": [1, "hello", True, None]}}, + doc=[ + {"_id": 1, "x": 1}, + {"_id": 2, "x": "hello"}, + {"_id": 3, "x": True}, + {"_id": 4, "x": None}, + {"_id": 5, "x": 99}, + ], + expected=[ + {"_id": 1, "x": 1}, + {"_id": 2, "x": "hello"}, + {"_id": 3, "x": True}, + {"_id": 4, "x": None}, + ], + msg="$in with mixed types in array matches docs where x is any of those values", + ), + QueryTestCase( + id="dollar_prefixed_string_as_literal", + filter={"x": {"$in": ["$abc"]}}, + doc=[{"_id": 1, "x": "$abc"}, {"_id": 2, "x": "abc"}], + expected=[{"_id": 1, "x": "$abc"}], + msg="$in treats dollar-prefixed string as literal value, not operator", + ), + QueryTestCase( + id="large_array_100_elements", + filter={"x": {"$in": list(range(100))}}, + doc=[{"_id": 1, "x": 50}, {"_id": 2, "x": 200}], + expected=[{"_id": 1, "x": 50}], + msg="$in with 100 elements matches correctly", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(SUCCESS_TESTS)) +def test_in_argument_handling(collection, test_case): + """Parametrized test for $in valid argument handling.""" + collection.insert_many(test_case.doc) + result = execute_command(collection, {"find": collection.name, "filter": test_case.filter}) + assertSuccess(result, test_case.expected, msg=test_case.msg, ignore_doc_order=True) + + +ERROR_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="non_array_int", + filter={"x": {"$in": 1}}, + doc=[{"_id": 1, "x": 1}], + error_code=BAD_VALUE_ERROR, + msg="$in with non-array int argument returns error", + ), + QueryTestCase( + id="non_array_string", + filter={"x": {"$in": "hello"}}, + doc=[{"_id": 1, "x": 1}], + error_code=BAD_VALUE_ERROR, + msg="$in with non-array string argument returns error", + ), + QueryTestCase( + id="non_array_object", + filter={"x": {"$in": {"a": 1}}}, + doc=[{"_id": 1, "x": 1}], + error_code=BAD_VALUE_ERROR, + msg="$in with non-array object argument returns error", + ), + QueryTestCase( + id="non_array_null", + filter={"x": {"$in": None}}, + doc=[{"_id": 1, "x": 1}], + error_code=BAD_VALUE_ERROR, + msg="$in with non-array null argument returns error", + ), + QueryTestCase( + id="non_array_boolean", + filter={"x": {"$in": True}}, + doc=[{"_id": 1, "x": 1}], + error_code=BAD_VALUE_ERROR, + msg="$in with non-array boolean argument returns error", + ), + QueryTestCase( + id="query_operator_in_array", + filter={"x": {"$in": [{"$gt": 1}]}}, + doc=[{"_id": 1, "x": 2}], + error_code=BAD_VALUE_ERROR, + msg="$in with query operator object in array returns error", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(ERROR_TESTS)) +def test_in_argument_handling_errors(collection, test_case): + """Parametrized test for $in invalid argument handling.""" + collection.insert_many(test_case.doc) + result = execute_command(collection, {"find": collection.name, "filter": test_case.filter}) + assertFailureCode(result, test_case.error_code, test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/comparison/in/test_in_array_matching.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/in/test_in_array_matching.py new file mode 100644 index 00000000..be3c1679 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/in/test_in_array_matching.py @@ -0,0 +1,212 @@ +""" +Tests for $in query operator array and document matching behavior. + +Covers scalar matching, result set behavior, _id field queries, array element +matching, nested arrays, exact array match, sub-array match, object equality +semantics, empty array/object, dotted path traversal, and array order independence. +""" + +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 + +OID1 = ObjectId("507f1f77bcf86cd799439011") +OID2 = ObjectId("507f1f77bcf86cd799439012") +OID3 = ObjectId("507f1f77bcf86cd799439013") + +TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="scalar_all_matching_docs_returned", + filter={"x": {"$in": [1]}}, + doc=[ + {"_id": 1, "x": 1}, + {"_id": 2, "x": 1}, + {"_id": 3, "x": 1}, + {"_id": 4, "x": 1}, + {"_id": 5, "x": 1}, + ], + expected=[ + {"_id": 1, "x": 1}, + {"_id": 2, "x": 1}, + {"_id": 3, "x": 1}, + {"_id": 4, "x": 1}, + {"_id": 5, "x": 1}, + ], + msg="$in returns all matching documents, not just first", + ), + QueryTestCase( + id="scalar_multiple_values_union", + filter={"x": {"$in": [1, 3]}}, + doc=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}, {"_id": 3, "x": 3}], + expected=[{"_id": 1, "x": 1}, {"_id": 3, "x": 3}], + msg="$in with multiple matching values returns union", + ), + QueryTestCase( + id="id_objectids", + filter={"_id": {"$in": [OID1, OID3]}}, + doc=[{"_id": OID1, "x": 1}, {"_id": OID2, "x": 2}, {"_id": OID3, "x": 3}], + expected=[{"_id": OID1, "x": 1}, {"_id": OID3, "x": 3}], + msg="$in on _id with ObjectIds", + ), + QueryTestCase( + id="id_mixed_types", + filter={"_id": {"$in": [1, "abc", OID1]}}, + doc=[{"_id": 1, "x": "a"}, {"_id": "abc", "x": "b"}, {"_id": OID1, "x": "c"}], + expected=[{"_id": 1, "x": "a"}, {"_id": "abc", "x": "b"}, {"_id": OID1, "x": "c"}], + msg="$in on _id with mixed types", + ), + QueryTestCase( + id="id_single_value", + filter={"_id": {"$in": [OID1]}}, + doc=[{"_id": OID1, "x": 1}, {"_id": OID2, "x": 2}], + expected=[{"_id": OID1, "x": 1}], + msg="$in on _id with single value is equivalent to equality", + ), + QueryTestCase( + id="id_null", + filter={"_id": {"$in": [None]}}, + doc=[{"_id": None, "x": 1}, {"_id": 1, "x": 2}], + expected=[{"_id": None, "x": 1}], + msg="$in on _id with null matches docs with null _id", + ), + QueryTestCase( + id="array_empty_no_match", + filter={"x": {"$in": [1]}}, + doc=[{"_id": 1, "x": []}], + expected=[], + msg="$in does NOT match empty array field", + ), + QueryTestCase( + id="array_one_element_match", + filter={"x": {"$in": [2]}}, + doc=[{"_id": 1, "x": [1, 2, 3]}, {"_id": 2, "x": [4, 5]}], + expected=[{"_id": 1, "x": [1, 2, 3]}], + msg="$in matches array field with one matching element", + ), + QueryTestCase( + id="array_partial_overlap_matches", + filter={"x": {"$in": [2, 4]}}, + doc=[{"_id": 1, "x": [1, 2, 3]}], + expected=[{"_id": 1, "x": [1, 2, 3]}], + msg="$in on array field with partial overlap still matches", + ), + QueryTestCase( + id="array_no_element_match", + filter={"x": {"$in": [4, 5]}}, + doc=[{"_id": 1, "x": [1, 2, 3]}], + expected=[], + msg="$in does NOT match array field with no matching elements", + ), + QueryTestCase( + id="array_null_element_match", + filter={"x": {"$in": [None]}}, + doc=[{"_id": 1, "x": [1, None, 3]}, {"_id": 2, "x": [1, 2]}], + expected=[{"_id": 1, "x": [1, None, 3]}], + msg="$in with null matches array containing null", + ), + QueryTestCase( + id="nested_sub_array_match", + filter={"x": {"$in": [[1, 2]]}}, + doc=[{"_id": 1, "x": [[1, 2], [3, 4]]}], + expected=[{"_id": 1, "x": [[1, 2], [3, 4]]}], + msg="$in with array value matches nested sub-array element", + ), + QueryTestCase( + id="nested_scalar_no_match", + filter={"x": {"$in": [1]}}, + doc=[{"_id": 1, "x": [[1, 2], [3, 4]]}], + expected=[], + msg="$in scalar does NOT match nested array (1 is not a top-level element)", + ), + QueryTestCase( + id="mixed_scalar_and_nested_array", + filter={"x": {"$in": [1]}}, + doc=[{"_id": 1, "x": [1, [2, 3]]}], + expected=[{"_id": 1, "x": [1, [2, 3]]}], + msg="$in scalar matches top-level scalar in mixed array", + ), + QueryTestCase( + id="array_partial_value_no_match", + filter={"x": {"$in": [[1]]}}, + doc=[{"_id": 1, "x": [1, 2]}], + expected=[], + msg="$in with partial array value does NOT match", + ), + QueryTestCase( + id="array_exact_value_match", + filter={"x": {"$in": [[1, 2]]}}, + doc=[{"_id": 1, "x": [1, 2]}, {"_id": 2, "x": [3, 4]}], + expected=[{"_id": 1, "x": [1, 2]}], + msg="$in with array value matches exact array field", + ), + QueryTestCase( + id="object_exact_element_match", + filter={"x": {"$in": [{"a": 1}]}}, + doc=[{"_id": 1, "x": [{"a": 1}, {"a": 2}]}], + expected=[{"_id": 1, "x": [{"a": 1}, {"a": 2}]}], + msg="$in with object matches exact object element in array", + ), + QueryTestCase( + id="object_key_order_no_match", + filter={"x": {"$in": [{"b": 2, "a": 1}]}}, + doc=[{"_id": 1, "x": [{"a": 1, "b": 2}]}], + expected=[], + msg="$in with object different key order does NOT match (BSON key order matters)", + ), + QueryTestCase( + id="object_extra_keys_no_match", + filter={"x": {"$in": [{"a": 1}]}}, + doc=[{"_id": 1, "x": [{"a": 1, "b": 2}]}], + expected=[], + msg="$in with object does NOT match object with extra keys", + ), + QueryTestCase( + id="empty_object_match", + filter={"x": {"$in": [{}]}}, + doc=[{"_id": 1, "x": {}}, {"_id": 2, "x": {"a": 1}}], + expected=[{"_id": 1, "x": {}}], + msg="$in with empty object matches empty object", + ), + QueryTestCase( + id="empty_array_value_match", + filter={"x": {"$in": [[]]}}, + doc=[{"_id": 1, "x": []}, {"_id": 2, "x": [1]}], + expected=[{"_id": 1, "x": []}], + msg="$in with empty array value matches empty array field", + ), + QueryTestCase( + id="object_key_order_scalar_no_match", + filter={"x": {"$in": [{"b": 2, "a": 1}]}}, + doc=[{"_id": 1, "x": {"a": 1, "b": 2}}], + expected=[], + msg="$in with object different key order on scalar field does NOT match", + ), + QueryTestCase( + id="in_array_order_independence", + filter={"x": {"$in": [3, 1]}}, + doc=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}, {"_id": 3, "x": 3}], + expected=[{"_id": 1, "x": 1}, {"_id": 3, "x": 3}], + msg="$in matches regardless of value order in the array", + ), + QueryTestCase( + id="dotted_path_array_of_objects", + filter={"a.b": {"$in": [1]}}, + doc=[{"_id": 1, "a": [{"b": 1}, {"b": 2}]}, {"_id": 2, "a": [{"b": 3}]}], + expected=[{"_id": 1, "a": [{"b": 1}, {"b": 2}]}], + msg="$in on dotted path into array of objects matches", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(TESTS)) +def test_in_matching(collection, test_case): + """Parametrized test for $in operator array and document matching behavior.""" + collection.insert_many(test_case.doc) + result = execute_command(collection, {"find": collection.name, "filter": test_case.filter}) + assertSuccess(result, test_case.expected, msg=test_case.msg, ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/comparison/in/test_in_bson_wiring.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/in/test_in_bson_wiring.py new file mode 100644 index 00000000..85c66f3a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/in/test_in_bson_wiring.py @@ -0,0 +1,248 @@ +""" +Tests for $in query operator BSON type wiring and type distinction. + +Covers all major BSON types (string, double, int32, Int64, Decimal128, bool, +datetime, ObjectId, embedded document, array, null, regex, Binary, Timestamp, +MinKey, MaxKey), numeric cross-type equivalence (int/long/double/Decimal128), +and type distinction (bool vs int, empty string vs null, datetime vs Timestamp). +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, 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 + +BSON_TYPE_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="bson_string", + filter={"a": {"$in": ["hello"]}}, + doc=[{"_id": 1, "a": "hello"}, {"_id": 2, "a": "world"}], + expected=[{"_id": 1, "a": "hello"}], + msg="$in with string", + ), + QueryTestCase( + id="bson_double", + filter={"a": {"$in": [3.14]}}, + doc=[{"_id": 1, "a": 3.14}, {"_id": 2, "a": 2.71}], + expected=[{"_id": 1, "a": 3.14}], + msg="$in with double", + ), + QueryTestCase( + id="bson_int64", + filter={"a": {"$in": [Int64(9999999999)]}}, + doc=[{"_id": 1, "a": Int64(9999999999)}, {"_id": 2, "a": Int64(1)}], + expected=[{"_id": 1, "a": Int64(9999999999)}], + msg="$in with Int64 (long)", + ), + QueryTestCase( + id="bson_decimal128", + filter={"a": {"$in": [Decimal128("1.5")]}}, + doc=[{"_id": 1, "a": Decimal128("1.5")}, {"_id": 2, "a": Decimal128("2.5")}], + expected=[{"_id": 1, "a": Decimal128("1.5")}], + msg="$in with Decimal128", + ), + QueryTestCase( + id="bson_bool_true", + filter={"a": {"$in": [True]}}, + doc=[{"_id": 1, "a": True}, {"_id": 2, "a": False}], + expected=[{"_id": 1, "a": True}], + msg="$in with bool true", + ), + QueryTestCase( + id="bson_bool_false", + filter={"a": {"$in": [False]}}, + doc=[{"_id": 1, "a": False}, {"_id": 2, "a": True}], + expected=[{"_id": 1, "a": False}], + msg="$in with bool false", + ), + QueryTestCase( + id="bson_datetime", + filter={"a": {"$in": [datetime(2024, 1, 1, tzinfo=timezone.utc)]}}, + doc=[ + {"_id": 1, "a": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + {"_id": 2, "a": datetime(2025, 1, 1, tzinfo=timezone.utc)}, + ], + expected=[{"_id": 1, "a": datetime(2024, 1, 1)}], + msg="$in with datetime", + ), + QueryTestCase( + id="bson_binary", + filter={"a": {"$in": [Binary(b"\x01\x02\x03")]}}, + doc=[{"_id": 1, "a": Binary(b"\x01\x02\x03")}, {"_id": 2, "a": Binary(b"\x04\x05")}], + expected=[{"_id": 1, "a": b"\x01\x02\x03"}], + msg="$in with Binary (binData)", + ), + QueryTestCase( + id="bson_timestamp", + filter={"a": {"$in": [Timestamp(1234567890, 1)]}}, + doc=[{"_id": 1, "a": Timestamp(1234567890, 1)}, {"_id": 2, "a": Timestamp(1, 1)}], + expected=[{"_id": 1, "a": Timestamp(1234567890, 1)}], + msg="$in with Timestamp", + ), + QueryTestCase( + id="bson_minkey", + filter={"a": {"$in": [MinKey()]}}, + doc=[{"_id": 1, "a": MinKey()}, {"_id": 2, "a": 1}], + expected=[{"_id": 1, "a": MinKey()}], + msg="$in with MinKey", + ), + QueryTestCase( + id="bson_maxkey", + filter={"a": {"$in": [MaxKey()]}}, + doc=[{"_id": 1, "a": MaxKey()}, {"_id": 2, "a": 1}], + expected=[{"_id": 1, "a": MaxKey()}], + msg="$in with MaxKey", + ), + QueryTestCase( + id="bson_int32", + filter={"a": {"$in": [42]}}, + doc=[{"_id": 1, "a": 42}, {"_id": 2, "a": 99}], + expected=[{"_id": 1, "a": 42}], + msg="$in with int32", + ), + QueryTestCase( + id="bson_objectid", + filter={"a": {"$in": [ObjectId("507f1f77bcf86cd799439011")]}}, + doc=[ + {"_id": 1, "a": ObjectId("507f1f77bcf86cd799439011")}, + {"_id": 2, "a": ObjectId("507f1f77bcf86cd799439012")}, + ], + expected=[{"_id": 1, "a": ObjectId("507f1f77bcf86cd799439011")}], + msg="$in with ObjectId", + ), + QueryTestCase( + id="bson_embedded_document", + filter={"a": {"$in": [{"x": 1, "y": 2}]}}, + doc=[{"_id": 1, "a": {"x": 1, "y": 2}}, {"_id": 2, "a": {"x": 3}}], + expected=[{"_id": 1, "a": {"x": 1, "y": 2}}], + msg="$in with embedded document", + ), + QueryTestCase( + id="bson_null", + filter={"a": {"$in": [None]}}, + doc=[{"_id": 1, "a": None}, {"_id": 2, "a": 1}], + expected=[{"_id": 1, "a": None}], + msg="$in with null", + ), +] + +NUMERIC_CROSS_TYPE_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="cross_type_int_matches_long", + filter={"a": {"$in": [1]}}, + doc=[{"_id": 1, "a": Int64(1)}, {"_id": 2, "a": Int64(2)}], + expected=[{"_id": 1, "a": Int64(1)}], + msg="$in int(1) matches long(1)", + ), + QueryTestCase( + id="cross_type_int_matches_double", + filter={"a": {"$in": [1]}}, + doc=[{"_id": 1, "a": 1.0}, {"_id": 2, "a": 2.0}], + expected=[{"_id": 1, "a": 1.0}], + msg="$in int(1) matches double(1.0)", + ), + QueryTestCase( + id="cross_type_int_matches_decimal128", + filter={"a": {"$in": [1]}}, + doc=[{"_id": 1, "a": Decimal128("1")}, {"_id": 2, "a": Decimal128("2")}], + expected=[{"_id": 1, "a": Decimal128("1")}], + msg="$in int(1) matches Decimal128('1')", + ), + QueryTestCase( + id="cross_type_long_matches_double", + filter={"a": {"$in": [Int64(1)]}}, + doc=[{"_id": 1, "a": 1.0}, {"_id": 2, "a": 2.0}], + expected=[{"_id": 1, "a": 1.0}], + msg="$in long(1) matches double(1.0)", + ), + QueryTestCase( + id="cross_type_double_matches_decimal128", + filter={"a": {"$in": [1.0]}}, + doc=[{"_id": 1, "a": Decimal128("1")}, {"_id": 2, "a": Decimal128("2")}], + expected=[{"_id": 1, "a": Decimal128("1")}], + msg="$in double(1.0) matches Decimal128('1')", + ), + QueryTestCase( + id="cross_type_all_numeric_types", + filter={"a": {"$in": [1]}}, + doc=[ + {"_id": 1, "a": 1}, + {"_id": 2, "a": Int64(1)}, + {"_id": 3, "a": 1.0}, + {"_id": 4, "a": Decimal128("1")}, + {"_id": 5, "a": 2}, + ], + expected=[ + {"_id": 1, "a": 1}, + {"_id": 2, "a": Int64(1)}, + {"_id": 3, "a": 1.0}, + {"_id": 4, "a": Decimal128("1")}, + ], + msg="$in int(1) matches all equivalent numeric types", + ), +] + +TYPE_DISTINCTION_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="distinction_false_not_zero", + filter={"a": {"$in": [False]}}, + doc=[{"_id": 1, "a": 0}, {"_id": 2, "a": False}], + expected=[{"_id": 2, "a": False}], + msg="$in false does NOT match int 0", + ), + QueryTestCase( + id="distinction_zero_not_false", + filter={"a": {"$in": [0]}}, + doc=[{"_id": 1, "a": False}, {"_id": 2, "a": 0}], + expected=[{"_id": 2, "a": 0}], + msg="$in int 0 does NOT match bool false", + ), + QueryTestCase( + id="distinction_true_not_one", + filter={"a": {"$in": [True]}}, + doc=[{"_id": 1, "a": 1}, {"_id": 2, "a": True}], + expected=[{"_id": 2, "a": True}], + msg="$in true does NOT match int 1", + ), + QueryTestCase( + id="distinction_one_not_true", + filter={"a": {"$in": [1]}}, + doc=[{"_id": 1, "a": True}, {"_id": 2, "a": 1}], + expected=[{"_id": 2, "a": 1}], + msg="$in int 1 does NOT match bool true", + ), + QueryTestCase( + id="distinction_empty_string_not_null", + filter={"a": {"$in": [""]}}, + doc=[{"_id": 1, "a": None}, {"_id": 2, "a": ""}], + expected=[{"_id": 2, "a": ""}], + msg="$in empty string does NOT match null", + ), + QueryTestCase( + id="distinction_datetime_not_timestamp", + filter={"a": {"$in": [datetime(2024, 1, 1, tzinfo=timezone.utc)]}}, + doc=[ + {"_id": 1, "a": Timestamp(1704067200, 0)}, + {"_id": 2, "a": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + ], + expected=[{"_id": 2, "a": datetime(2024, 1, 1)}], + msg="$in datetime does NOT match Timestamp with same epoch seconds", + ), +] + +TESTS = BSON_TYPE_TESTS + NUMERIC_CROSS_TYPE_TESTS + TYPE_DISTINCTION_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(TESTS)) +def test_in_bson_wiring(collection, test_case): + """Parametrized test for $in BSON type wiring and type distinction.""" + collection.insert_many(test_case.doc) + result = execute_command(collection, {"find": collection.name, "filter": test_case.filter}) + assertSuccess(result, test_case.expected, msg=test_case.msg, ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/comparison/in/test_in_nan_infinity.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/in/test_in_nan_infinity.py new file mode 100644 index 00000000..3a786050 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/in/test_in_nan_infinity.py @@ -0,0 +1,151 @@ +""" +Tests for $in query operator NaN and Infinity matching. + +Covers NaN self-matching, cross-type NaN, Infinity matching, +negative zero vs positive zero, and NaN non-matching with regular numbers. +""" + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.query.utils.query_test_case import ( + QueryTestCase, +) +from documentdb_tests.framework.assertions import assertSuccess, assertSuccessNaN +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, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +NAN_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="double_nan_matches_double_nan", + filter={"a": {"$in": [FLOAT_NAN]}}, + doc=[{"_id": 1, "a": FLOAT_NAN}, {"_id": 2, "a": 1}], + expected=[{"_id": 1, "a": FLOAT_NAN}], + msg="$in with double NaN matches double NaN field", + ), + QueryTestCase( + id="decimal128_nan_matches_decimal128_nan", + filter={"a": {"$in": [DECIMAL128_NAN]}}, + doc=[ + {"_id": 1, "a": DECIMAL128_NAN}, + {"_id": 2, "a": Decimal128("1")}, + ], + expected=[{"_id": 1, "a": DECIMAL128_NAN}], + msg="$in with Decimal128 NaN matches Decimal128 NaN field", + ), + QueryTestCase( + id="double_nan_matches_decimal128_nan", + filter={"a": {"$in": [FLOAT_NAN]}}, + doc=[{"_id": 1, "a": DECIMAL128_NAN}, {"_id": 2, "a": 1}], + expected=[{"_id": 1, "a": DECIMAL128_NAN}], + msg="$in with double NaN matches Decimal128 NaN (cross-type NaN matching)", + ), + QueryTestCase( + id="nan_does_not_match_regular_number", + filter={"a": {"$in": [FLOAT_NAN]}}, + doc=[{"_id": 1, "a": 0}, {"_id": 2, "a": 1}, {"_id": 3, "a": -1}], + expected=[], + msg="$in with NaN does NOT match regular numbers", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(NAN_TESTS)) +def test_in_nan(collection, test_case): + """Parametrized test for $in NaN matching.""" + collection.insert_many(test_case.doc) + result = execute_command( + collection, + {"find": collection.name, "filter": test_case.filter}, + ) + assertSuccessNaN(result, test_case.expected, msg=test_case.msg, ignore_doc_order=True) + + +INFINITY_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="double_infinity_matches", + filter={"a": {"$in": [FLOAT_INFINITY]}}, + doc=[{"_id": 1, "a": FLOAT_INFINITY}, {"_id": 2, "a": 1}], + expected=[{"_id": 1, "a": FLOAT_INFINITY}], + msg="$in with double Infinity matches double Infinity field", + ), + QueryTestCase( + id="double_negative_infinity_matches", + filter={"a": {"$in": [FLOAT_NEGATIVE_INFINITY]}}, + doc=[{"_id": 1, "a": FLOAT_NEGATIVE_INFINITY}, {"_id": 2, "a": 1}], + expected=[{"_id": 1, "a": FLOAT_NEGATIVE_INFINITY}], + msg="$in with double -Infinity matches double -Infinity field", + ), + QueryTestCase( + id="decimal128_infinity_matches", + filter={"a": {"$in": [DECIMAL128_INFINITY]}}, + doc=[{"_id": 1, "a": DECIMAL128_INFINITY}, {"_id": 2, "a": Decimal128("1")}], + expected=[{"_id": 1, "a": DECIMAL128_INFINITY}], + msg="$in with Decimal128 Infinity matches Decimal128 Infinity field", + ), + QueryTestCase( + id="decimal128_negative_infinity_matches", + filter={"a": {"$in": [DECIMAL128_NEGATIVE_INFINITY]}}, + doc=[{"_id": 1, "a": DECIMAL128_NEGATIVE_INFINITY}, {"_id": 2, "a": Decimal128("1")}], + expected=[{"_id": 1, "a": DECIMAL128_NEGATIVE_INFINITY}], + msg="$in with Decimal128 -Infinity matches Decimal128 -Infinity field", + ), + QueryTestCase( + id="infinity_does_not_match_negative_infinity", + filter={"a": {"$in": [FLOAT_INFINITY]}}, + doc=[{"_id": 1, "a": FLOAT_NEGATIVE_INFINITY}], + expected=[], + msg="$in with Infinity does NOT match -Infinity", + ), + QueryTestCase( + id="double_infinity_matches_decimal128_infinity", + filter={"a": {"$in": [FLOAT_INFINITY]}}, + doc=[{"_id": 1, "a": DECIMAL128_INFINITY}, {"_id": 2, "a": 1}], + expected=[{"_id": 1, "a": DECIMAL128_INFINITY}], + msg="$in with double Infinity matches Decimal128 Infinity (cross-type)", + ), +] + +NEGATIVE_ZERO_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="double_negative_zero_matches_positive_zero", + filter={"a": {"$in": [DOUBLE_NEGATIVE_ZERO]}}, + doc=[{"_id": 1, "a": DOUBLE_ZERO}, {"_id": 2, "a": 1}], + expected=[{"_id": 1, "a": DOUBLE_ZERO}], + msg="$in with double -0.0 matches double 0.0", + ), + QueryTestCase( + id="double_positive_zero_matches_negative_zero", + filter={"a": {"$in": [DOUBLE_ZERO]}}, + doc=[{"_id": 1, "a": DOUBLE_NEGATIVE_ZERO}, {"_id": 2, "a": 1}], + expected=[{"_id": 1, "a": DOUBLE_NEGATIVE_ZERO}], + msg="$in with double 0.0 matches double -0.0", + ), + QueryTestCase( + id="decimal128_negative_zero_matches_positive_zero", + filter={"a": {"$in": [DECIMAL128_NEGATIVE_ZERO]}}, + doc=[{"_id": 1, "a": DECIMAL128_ZERO}, {"_id": 2, "a": Decimal128("1")}], + expected=[{"_id": 1, "a": DECIMAL128_ZERO}], + msg="$in with Decimal128 -0 matches Decimal128 0", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(INFINITY_TESTS + NEGATIVE_ZERO_TESTS)) +def test_in_infinity_and_zero(collection, test_case): + """Parametrized test for $in Infinity and negative zero matching.""" + collection.insert_many(test_case.doc) + result = execute_command(collection, {"find": collection.name, "filter": test_case.filter}) + assertSuccess(result, test_case.expected, msg=test_case.msg, ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/comparison/in/test_in_null_missing.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/in/test_in_null_missing.py new file mode 100644 index 00000000..7b3b5fc8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/in/test_in_null_missing.py @@ -0,0 +1,80 @@ +""" +Tests for $in query operator null and missing field handling. + +Covers null matching, missing field matching, null vs falsy value distinction, +and combined null with other values. +""" + +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 + +TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="null_matches_null_field", + filter={"x": {"$in": [None]}}, + doc=[{"_id": 1, "x": None}, {"_id": 2, "x": 1}], + expected=[{"_id": 1, "x": None}], + msg="$in with [null] matches document where field is null", + ), + QueryTestCase( + id="null_matches_missing_field", + filter={"x": {"$in": [None]}}, + doc=[{"_id": 1, "y": 1}, {"_id": 2, "x": 1}], + expected=[{"_id": 1, "y": 1}], + msg="$in with [null] matches document where field is missing", + ), + QueryTestCase( + id="null_skips_int_zero", + filter={"x": {"$in": [None]}}, + doc=[{"_id": 1, "x": 0}, {"_id": 2, "x": None}], + expected=[{"_id": 2, "x": None}], + msg="$in with [null] does NOT match int 0", + ), + QueryTestCase( + id="null_skips_bool_false", + filter={"x": {"$in": [None]}}, + doc=[{"_id": 1, "x": False}, {"_id": 2, "x": None}], + expected=[{"_id": 2, "x": None}], + msg="$in with [null] does NOT match bool false", + ), + QueryTestCase( + id="null_skips_empty_string", + filter={"x": {"$in": [None]}}, + doc=[{"_id": 1, "x": ""}, {"_id": 2, "x": None}], + expected=[{"_id": 2, "x": None}], + msg="$in with [null] does NOT match empty string", + ), + QueryTestCase( + id="null_and_value_matches_null_missing_and_value", + filter={"x": {"$in": [None, 1]}}, + doc=[ + {"_id": 1, "x": None}, + {"_id": 2, "y": 1}, + {"_id": 3, "x": 1}, + {"_id": 4, "x": 2}, + ], + expected=[{"_id": 1, "x": None}, {"_id": 2, "y": 1}, {"_id": 3, "x": 1}], + msg="$in with [null, 1] matches null, missing, and 1", + ), + QueryTestCase( + id="missing_field_no_null_in_array", + filter={"x": {"$in": [1, 2]}}, + doc=[{"_id": 1, "y": 1}, {"_id": 2, "x": 1}], + expected=[{"_id": 2, "x": 1}], + msg="$in without null in array does NOT match missing field", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(TESTS)) +def test_in_null_missing(collection, test_case): + """Parametrized test for $in null and missing field handling.""" + collection.insert_many(test_case.doc) + result = execute_command(collection, {"find": collection.name, "filter": test_case.filter}) + assertSuccess(result, test_case.expected, msg=test_case.msg, ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/comparison/in/test_in_numeric_edge_cases.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/in/test_in_numeric_edge_cases.py new file mode 100644 index 00000000..048ecece --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/in/test_in_numeric_edge_cases.py @@ -0,0 +1,116 @@ +""" +Tests for $in query operator numeric boundary and edge cases. + +Covers INT32/INT64 boundaries, Decimal128 precision boundaries, +trailing zeros equivalence, and scientific notation equivalence. +""" + +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_LARGE_EXPONENT, + DECIMAL128_MAX, + DECIMAL128_MIN, + DECIMAL128_MIN_POSITIVE, + DECIMAL128_SMALL_EXPONENT, + DECIMAL128_TRAILING_ZERO, + INT32_MAX, + INT32_MIN, + INT64_MAX, + INT64_MIN, +) + +TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="int32_max", + filter={"a": {"$in": [INT32_MAX]}}, + doc=[{"_id": 1, "a": INT32_MAX}, {"_id": 2, "a": 0}], + expected=[{"_id": 1, "a": INT32_MAX}], + msg="$in matches INT32_MAX", + ), + QueryTestCase( + id="int32_min", + filter={"a": {"$in": [INT32_MIN]}}, + doc=[{"_id": 1, "a": INT32_MIN}, {"_id": 2, "a": 0}], + expected=[{"_id": 1, "a": INT32_MIN}], + msg="$in matches INT32_MIN", + ), + QueryTestCase( + id="int64_max", + filter={"a": {"$in": [INT64_MAX]}}, + doc=[{"_id": 1, "a": INT64_MAX}, {"_id": 2, "a": Int64(0)}], + expected=[{"_id": 1, "a": INT64_MAX}], + msg="$in matches INT64_MAX", + ), + QueryTestCase( + id="int64_min", + filter={"a": {"$in": [INT64_MIN]}}, + doc=[{"_id": 1, "a": INT64_MIN}, {"_id": 2, "a": Int64(0)}], + expected=[{"_id": 1, "a": INT64_MIN}], + msg="$in matches INT64_MIN", + ), + QueryTestCase( + id="decimal128_max", + filter={"a": {"$in": [DECIMAL128_MAX]}}, + doc=[{"_id": 1, "a": DECIMAL128_MAX}, {"_id": 2, "a": Decimal128("0")}], + expected=[{"_id": 1, "a": DECIMAL128_MAX}], + msg="$in matches DECIMAL128_MAX", + ), + QueryTestCase( + id="decimal128_min", + filter={"a": {"$in": [DECIMAL128_MIN]}}, + doc=[{"_id": 1, "a": DECIMAL128_MIN}, {"_id": 2, "a": Decimal128("0")}], + expected=[{"_id": 1, "a": DECIMAL128_MIN}], + msg="$in matches DECIMAL128_MIN", + ), + QueryTestCase( + id="decimal128_min_positive", + filter={"a": {"$in": [DECIMAL128_MIN_POSITIVE]}}, + doc=[{"_id": 1, "a": DECIMAL128_MIN_POSITIVE}, {"_id": 2, "a": Decimal128("0")}], + expected=[{"_id": 1, "a": DECIMAL128_MIN_POSITIVE}], + msg="$in matches DECIMAL128_MIN_POSITIVE", + ), + QueryTestCase( + id="decimal128_small_exponent", + filter={"a": {"$in": [DECIMAL128_SMALL_EXPONENT]}}, + doc=[{"_id": 1, "a": DECIMAL128_SMALL_EXPONENT}, {"_id": 2, "a": Decimal128("0")}], + expected=[{"_id": 1, "a": DECIMAL128_SMALL_EXPONENT}], + msg="$in matches DECIMAL128_SMALL_EXPONENT", + ), + QueryTestCase( + id="decimal128_large_exponent", + filter={"a": {"$in": [DECIMAL128_LARGE_EXPONENT]}}, + doc=[{"_id": 1, "a": DECIMAL128_LARGE_EXPONENT}, {"_id": 2, "a": Decimal128("0")}], + expected=[{"_id": 1, "a": DECIMAL128_LARGE_EXPONENT}], + msg="$in matches DECIMAL128_LARGE_EXPONENT", + ), + QueryTestCase( + id="decimal128_trailing_zeros_equivalence", + filter={"a": {"$in": [DECIMAL128_TRAILING_ZERO]}}, + doc=[{"_id": 1, "a": Decimal128("1")}, {"_id": 2, "a": Decimal128("2")}], + expected=[{"_id": 1, "a": Decimal128("1")}], + msg="$in with Decimal128('1.0') matches Decimal128('1') (numeric equivalence)", + ), + QueryTestCase( + id="decimal128_scientific_notation_equivalence", + filter={"a": {"$in": [Decimal128("1.0E+2")]}}, + doc=[{"_id": 1, "a": 100}, {"_id": 2, "a": 200}], + expected=[{"_id": 1, "a": 100}], + msg="$in with Decimal128('1.0E+2') matches int 100 (numeric equivalence)", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(TESTS)) +def test_in_numeric_edge_cases(collection, test_case): + """Parametrized test for $in numeric boundary and edge cases.""" + collection.insert_many(test_case.doc) + result = execute_command(collection, {"find": collection.name, "filter": test_case.filter}) + assertSuccess(result, test_case.expected, msg=test_case.msg, ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/comparison/in/test_in_regex.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/in/test_in_regex.py new file mode 100644 index 00000000..915cc9ba --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/in/test_in_regex.py @@ -0,0 +1,112 @@ +""" +Tests for $in query operator with regular expressions. + +Covers basic regex matching, case-insensitive flag, multiline flag, +no-flag default behavior, multiple regexes, mixed regex/literal, +regex on array fields, and $regex expression rejection. +""" + +import pytest +from bson import Regex + +from documentdb_tests.compatibility.tests.core.operator.query.utils.query_test_case import ( + QueryTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccess +from documentdb_tests.framework.error_codes import BAD_VALUE_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +SUCCESS_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="regex_basic_match", + filter={"x": {"$in": [Regex("^abc")]}}, + doc=[{"_id": 1, "x": "abcdef"}, {"_id": 2, "x": "xyz"}], + expected=[{"_id": 1, "x": "abcdef"}], + msg="$in with regex matches string", + ), + QueryTestCase( + id="regex_basic_no_match", + filter={"x": {"$in": [Regex("^abc")]}}, + doc=[{"_id": 1, "x": "defabc"}], + expected=[], + msg="$in with regex does NOT match non-matching string", + ), + QueryTestCase( + id="regex_case_insensitive_flag", + filter={"x": {"$in": [Regex("^abc", "i")]}}, + doc=[{"_id": 1, "x": "ABCdef"}, {"_id": 2, "x": "xyz"}], + expected=[{"_id": 1, "x": "ABCdef"}], + msg="$in with case-insensitive regex matches", + ), + QueryTestCase( + id="regex_no_flag_is_case_sensitive", + filter={"x": {"$in": [Regex("^abc")]}}, + doc=[{"_id": 1, "x": "abcdef"}, {"_id": 2, "x": "ABCdef"}], + expected=[{"_id": 1, "x": "abcdef"}], + msg="$in with regex without flags is case-sensitive by default", + ), + QueryTestCase( + id="regex_multiline_flag", + filter={"x": {"$in": [Regex("^abc", "m")]}}, + doc=[{"_id": 1, "x": "hello\nabcdef"}, {"_id": 2, "x": "hello\nxyz"}], + expected=[{"_id": 1, "x": "hello\nabcdef"}], + msg="$in with regex using multiline flag matches after newline", + ), + QueryTestCase( + id="regex_multiple_patterns", + filter={"x": {"$in": [Regex("^a"), Regex("^b")]}}, + doc=[ + {"_id": 1, "x": "apple"}, + {"_id": 2, "x": "banana"}, + {"_id": 3, "x": "cherry"}, + ], + expected=[{"_id": 1, "x": "apple"}, {"_id": 2, "x": "banana"}], + msg="$in with multiple regexes", + ), + QueryTestCase( + id="regex_mixed_with_literal", + filter={"x": {"$in": [Regex("^a"), "banana"]}}, + doc=[ + {"_id": 1, "x": "apple"}, + {"_id": 2, "x": "banana"}, + {"_id": 3, "x": "cherry"}, + ], + expected=[{"_id": 1, "x": "apple"}, {"_id": 2, "x": "banana"}], + msg="$in with mix of regex and literal", + ), + QueryTestCase( + id="regex_matches_array_element", + filter={"x": {"$in": [Regex("^abc")]}}, + doc=[{"_id": 1, "x": ["abcdef", "xyz"]}, {"_id": 2, "x": ["xyz"]}], + expected=[{"_id": 1, "x": ["abcdef", "xyz"]}], + msg="$in with regex matches element in array field", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(SUCCESS_TESTS)) +def test_in_regex(collection, test_case): + """Parametrized test for $in operator regex matching.""" + collection.insert_many(test_case.doc) + result = execute_command(collection, {"find": collection.name, "filter": test_case.filter}) + assertSuccess(result, test_case.expected, msg=test_case.msg, ignore_doc_order=True) + + +ERROR_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="dollar_regex_expression_rejected", + filter={"x": {"$in": [{"$regex": "^abc"}]}}, + doc=[{"_id": 1, "x": "abcdef"}], + error_code=BAD_VALUE_ERROR, + msg="$in with $regex expression inside array returns error", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(ERROR_TESTS)) +def test_in_regex_errors(collection, test_case): + """Parametrized test for $in operator regex error cases.""" + collection.insert_many(test_case.doc) + result = execute_command(collection, {"find": collection.name, "filter": test_case.filter}) + assertFailureCode(result, test_case.error_code, test_case.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/comparison/nin/__init__.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/nin/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/documentdb_tests/compatibility/tests/core/operator/query/comparison/nin/test_nin_argument_handling.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/nin/test_nin_argument_handling.py new file mode 100644 index 00000000..a54314c7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/nin/test_nin_argument_handling.py @@ -0,0 +1,143 @@ +""" +Tests for $nin argument handling. + +Covers valid array argument variations, invalid argument formats, +duplicate values, empty arrays, and large arrays. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.query.utils.query_test_case import ( + QueryTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccess +from documentdb_tests.framework.error_codes import BAD_VALUE_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +SUCCESS_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="empty_array_matches_all", + filter={"a": {"$nin": []}}, + doc=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + expected=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + msg="$nin with empty array matches ALL documents", + ), + QueryTestCase( + id="single_element", + filter={"a": {"$nin": [1]}}, + doc=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + expected=[{"_id": 2, "a": 2}], + msg="$nin with single element array", + ), + QueryTestCase( + id="two_elements", + filter={"a": {"$nin": [1, 2]}}, + doc=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}, {"_id": 3, "a": 3}], + expected=[{"_id": 3, "a": 3}], + msg="$nin with two element array", + ), + QueryTestCase( + id="duplicate_values_same_as_single", + filter={"a": {"$nin": [1, 1, 1]}}, + doc=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + expected=[{"_id": 2, "a": 2}], + msg="$nin with duplicate values behaves same as single value", + ), + QueryTestCase( + id="large_array_1000_elements", + filter={"a": {"$nin": list(range(1000))}}, + doc=[{"_id": 1, "a": 9999}, {"_id": 2, "a": 500}], + expected=[{"_id": 1, "a": 9999}], + msg="$nin with 1000 elements in array", + ), + QueryTestCase( + id="many_elements_excludes_range", + filter={"a": {"$nin": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]}}, + doc=[{"_id": i, "a": i} for i in range(1, 12)], + expected=[{"_id": 11, "a": 11}], + msg="$nin with many elements excludes all matching values", + ), + QueryTestCase( + id="mixed_types_in_array", + filter={"a": {"$nin": [1, "hello", True, None]}}, + doc=[ + {"_id": 1, "a": 1}, + {"_id": 2, "a": "hello"}, + {"_id": 3, "a": True}, + {"_id": 4, "a": None}, + {"_id": 5, "a": 99}, + ], + expected=[{"_id": 5, "a": 99}], + msg="$nin with mixed types in array excludes all matching docs", + ), + QueryTestCase( + id="dollar_prefixed_string_as_literal", + filter={"a": {"$nin": ["$abc"]}}, + doc=[{"_id": 1, "a": "$abc"}, {"_id": 2, "a": "abc"}], + expected=[{"_id": 2, "a": "abc"}], + msg="$nin treats dollar-prefixed string as literal value, not operator", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(SUCCESS_TESTS)) +def test_nin_argument_handling(collection, test_case): + """Parametrized test for $nin valid argument handling.""" + collection.insert_many(test_case.doc) + result = execute_command(collection, {"find": collection.name, "filter": test_case.filter}) + assertSuccess(result, test_case.expected, ignore_doc_order=True) + + +ERROR_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="integer_argument_errors", + filter={"a": {"$nin": 1}}, + doc=[{"_id": 1, "a": 1}], + error_code=BAD_VALUE_ERROR, + msg="$nin with integer argument returns error code 2", + ), + QueryTestCase( + id="string_argument_errors", + filter={"a": {"$nin": "string"}}, + doc=[{"_id": 1, "a": 1}], + error_code=BAD_VALUE_ERROR, + msg="$nin with string argument returns error code 2", + ), + QueryTestCase( + id="boolean_argument_errors", + filter={"a": {"$nin": True}}, + doc=[{"_id": 1, "a": 1}], + error_code=BAD_VALUE_ERROR, + msg="$nin with boolean argument returns error code 2", + ), + QueryTestCase( + id="null_argument_errors", + filter={"a": {"$nin": None}}, + doc=[{"_id": 1, "a": 1}], + error_code=BAD_VALUE_ERROR, + msg="$nin with null argument returns error code 2", + ), + QueryTestCase( + id="object_argument_errors", + filter={"a": {"$nin": {"a": 1}}}, + doc=[{"_id": 1, "a": 1}], + error_code=BAD_VALUE_ERROR, + msg="$nin with object argument returns error code 2", + ), + QueryTestCase( + id="query_operator_in_array_errors", + filter={"a": {"$nin": [{"$gt": 1}]}}, + doc=[{"_id": 1, "a": 1}], + error_code=BAD_VALUE_ERROR, + msg="$nin with query operator object in array returns error code 2", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(ERROR_TESTS)) +def test_nin_argument_handling_errors(collection, test_case): + """Parametrized test for $nin invalid argument handling.""" + collection.insert_many(test_case.doc) + result = execute_command(collection, {"find": collection.name, "filter": test_case.filter}) + assertFailureCode(result, test_case.error_code) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/comparison/nin/test_nin_array_matching.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/nin/test_nin_array_matching.py new file mode 100644 index 00000000..8e6542f0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/nin/test_nin_array_matching.py @@ -0,0 +1,200 @@ +""" +Tests for $nin array matching behavior. + +Covers element match exclusion, nested arrays, empty arrays, +array element order, mixed scalar/array fields, null in arrays, +and embedded document matching in arrays. +""" + +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 + +TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="array_no_element_match_includes", + filter={"a": {"$nin": [4, 5]}}, + doc=[{"_id": 1, "a": [1, 2, 3]}], + expected=[{"_id": 1, "a": [1, 2, 3]}], + msg="$nin includes doc when array has no matching element", + ), + QueryTestCase( + id="array_partial_overlap_excludes", + filter={"a": {"$nin": [3, 4, 5]}}, + doc=[{"_id": 1, "a": [1, 2, 3]}], + expected=[], + msg="$nin excludes doc when array has partial overlap with $nin values", + ), + QueryTestCase( + id="array_multiple_values_one_matches", + filter={"a": {"$nin": [1, 4]}}, + doc=[{"_id": 1, "a": [1, 2, 3]}], + expected=[], + msg="$nin excludes doc when any $nin value matches array element", + ), + QueryTestCase( + id="nested_array_scalar_no_match", + filter={"a": {"$nin": [1]}}, + doc=[{"_id": 1, "a": [[1, 2], [3, 4]]}], + expected=[{"_id": 1, "a": [[1, 2], [3, 4]]}], + msg="$nin scalar against nested array — no top-level element equals scalar", + ), + QueryTestCase( + id="array_order_matters_different_order_matches", + filter={"a": {"$nin": [[2, 1]]}}, + doc=[{"_id": 1, "a": [[1, 2]]}], + expected=[{"_id": 1, "a": [[1, 2]]}], + msg="$nin: [1,2] != [2,1] — different order matches", + ), + QueryTestCase( + id="array_order_matters_same_order_excluded", + filter={"a": {"$nin": [[1, 2]]}}, + doc=[{"_id": 1, "a": [[1, 2]]}], + expected=[], + msg="$nin: [1,2] == [1,2] — same order excluded", + ), + QueryTestCase( + id="array_without_null_matches_null_nin", + filter={"a": {"$nin": [None]}}, + doc=[{"_id": 1, "a": [1, 2, 3]}], + expected=[{"_id": 1, "a": [1, 2, 3]}], + msg="$nin [null] matches doc when array does not contain null", + ), + QueryTestCase( + id="array_element_match_excludes", + filter={"a": {"$nin": [2]}}, + doc=[{"_id": 1, "a": [1, 2, 3]}, {"_id": 2, "a": [4, 5]}], + expected=[{"_id": 2, "a": [4, 5]}], + msg="$nin excludes doc when array contains matching element", + ), + QueryTestCase( + id="empty_array_field_matches", + filter={"a": {"$nin": [1, 2]}}, + doc=[{"_id": 1, "a": []}, {"_id": 2, "a": [1]}], + expected=[{"_id": 1, "a": []}], + msg="$nin matches doc with empty array field (no elements to match)", + ), + QueryTestCase( + id="empty_array_in_nin_list_excludes_empty_array", + filter={"a": {"$nin": [[]]}}, + doc=[{"_id": 1, "a": []}, {"_id": 2, "a": [1]}], + expected=[{"_id": 2, "a": [1]}], + msg="$nin with empty array in list excludes doc with empty array field", + ), + QueryTestCase( + id="nested_array_element_match", + filter={"a": {"$nin": [[1, 2]]}}, + doc=[{"_id": 1, "a": [[1, 2], [3, 4]]}, {"_id": 2, "a": [[5, 6]]}], + expected=[{"_id": 2, "a": [[5, 6]]}], + msg="$nin with nested array — excludes when top-level element matches", + ), + QueryTestCase( + id="array_with_null_element_excluded_by_null", + filter={"a": {"$nin": [None]}}, + doc=[{"_id": 1, "a": [1, None, 3]}, {"_id": 2, "a": [1, 2, 3]}], + expected=[{"_id": 2, "a": [1, 2, 3]}], + msg="$nin [null] excludes doc when array contains null", + ), + QueryTestCase( + id="array_of_objects_element_match", + filter={"a": {"$nin": [{"x": 1}]}}, + doc=[{"_id": 1, "a": [{"x": 1}, {"x": 2}]}, {"_id": 2, "a": [{"x": 3}]}], + expected=[{"_id": 2, "a": [{"x": 3}]}], + msg="$nin excludes doc when array contains matching object element", + ), + QueryTestCase( + id="duplicate_values_with_array_field", + filter={"a": {"$nin": [1, 1, 1]}}, + doc=[{"_id": 1, "a": [1, 2, 3]}, {"_id": 2, "a": [4, 5]}], + expected=[{"_id": 2, "a": [4, 5]}], + msg="$nin with duplicate values against array field behaves same as single", + ), + QueryTestCase( + id="mixed_scalar_and_array", + filter={"a": {"$nin": [1]}}, + doc=[ + {"_id": 1, "a": 1}, + {"_id": 2, "a": [1, 2]}, + {"_id": 3, "a": [3, 4]}, + {"_id": 4, "a": 5}, + ], + expected=[{"_id": 3, "a": [3, 4]}, {"_id": 4, "a": 5}], + msg="$nin with mixed scalar and array fields", + ), + QueryTestCase( + id="array_of_objects_no_match_includes", + filter={"a": {"$nin": [{"x": 3}]}}, + doc=[{"_id": 1, "a": [{"x": 1}, {"x": 2}]}], + expected=[{"_id": 1, "a": [{"x": 1}, {"x": 2}]}], + msg="$nin includes doc when array has no matching object element", + ), + QueryTestCase( + id="object_key_order_no_exclude", + filter={"a": {"$nin": [{"b": 2, "a": 1}]}}, + doc=[{"_id": 1, "a": [{"a": 1, "b": 2}]}], + expected=[{"_id": 1, "a": [{"a": 1, "b": 2}]}], + msg="$nin with object different key order does NOT exclude (BSON key order matters)", + ), + QueryTestCase( + id="object_extra_keys_no_exclude", + filter={"a": {"$nin": [{"a": 1}]}}, + doc=[{"_id": 1, "a": [{"a": 1, "b": 2}]}], + expected=[{"_id": 1, "a": [{"a": 1, "b": 2}]}], + msg="$nin with object does NOT exclude object with extra keys", + ), + QueryTestCase( + id="empty_object_excludes", + filter={"a": {"$nin": [{}]}}, + doc=[{"_id": 1, "a": {}}, {"_id": 2, "a": {"a": 1}}], + expected=[{"_id": 2, "a": {"a": 1}}], + msg="$nin with empty object excludes empty object", + ), + QueryTestCase( + id="object_key_order_scalar_no_exclude", + filter={"a": {"$nin": [{"b": 2, "a": 1}]}}, + doc=[{"_id": 1, "a": {"a": 1, "b": 2}}], + expected=[{"_id": 1, "a": {"a": 1, "b": 2}}], + msg="$nin with object different key order on scalar field does NOT exclude", + ), + QueryTestCase( + id="array_partial_value_no_exclude", + filter={"a": {"$nin": [[1]]}}, + doc=[{"_id": 1, "a": [1, 2]}], + expected=[{"_id": 1, "a": [1, 2]}], + msg="$nin with partial array value does NOT exclude", + ), + QueryTestCase( + id="array_exact_value_excludes", + filter={"a": {"$nin": [[1, 2]]}}, + doc=[{"_id": 1, "a": [1, 2]}, {"_id": 2, "a": [3, 4]}], + expected=[{"_id": 2, "a": [3, 4]}], + msg="$nin with array value excludes exact array field", + ), + QueryTestCase( + id="nin_array_order_independence", + filter={"a": {"$nin": [3, 1]}}, + doc=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}, {"_id": 3, "a": 3}], + expected=[{"_id": 2, "a": 2}], + msg="$nin excludes regardless of value order in the array", + ), + QueryTestCase( + id="dotted_path_array_of_objects", + filter={"a.b": {"$nin": [1]}}, + doc=[{"_id": 1, "a": [{"b": 1}, {"b": 2}]}, {"_id": 2, "a": [{"b": 3}]}], + expected=[{"_id": 2, "a": [{"b": 3}]}], + msg="$nin on dotted path into array of objects excludes matching", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(TESTS)) +def test_nin_array_matching(collection, test_case): + """Parametrized test for $nin array matching behavior.""" + collection.insert_many(test_case.doc) + result = execute_command(collection, {"find": collection.name, "filter": test_case.filter}) + assertSuccess(result, test_case.expected, ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/comparison/nin/test_nin_bson_wiring.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/nin/test_nin_bson_wiring.py new file mode 100644 index 00000000..48ae5995 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/nin/test_nin_bson_wiring.py @@ -0,0 +1,277 @@ +""" +Tests for $nin BSON type wiring, numeric cross-type equivalence, and type distinction. + +Covers all major BSON types, verifies that numerically equivalent values across +int/long/double/Decimal128 are excluded by $nin, and confirms that distinct BSON types +(bool vs int, null vs empty string) do NOT incorrectly exclude. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, 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 + +BSON_TYPE_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="string", + filter={"a": {"$nin": ["hello"]}}, + doc=[{"_id": 1, "a": "hello"}, {"_id": 2, "a": "world"}], + expected=[{"_id": 2, "a": "world"}], + msg="$nin with string excludes matching document", + ), + QueryTestCase( + id="double", + filter={"a": {"$nin": [3.14]}}, + doc=[{"_id": 1, "a": 3.14}, {"_id": 2, "a": 2.71}], + expected=[{"_id": 2, "a": 2.71}], + msg="$nin with double excludes matching document", + ), + QueryTestCase( + id="int32", + filter={"a": {"$nin": [42]}}, + doc=[{"_id": 1, "a": 42}, {"_id": 2, "a": 99}], + expected=[{"_id": 2, "a": 99}], + msg="$nin with int32 excludes matching document", + ), + QueryTestCase( + id="int64", + filter={"a": {"$nin": [Int64(9999999999)]}}, + doc=[{"_id": 1, "a": Int64(9999999999)}, {"_id": 2, "a": Int64(1)}], + expected=[{"_id": 2, "a": Int64(1)}], + msg="$nin with Int64 excludes matching document", + ), + QueryTestCase( + id="decimal128", + filter={"a": {"$nin": [Decimal128("1.5")]}}, + doc=[{"_id": 1, "a": Decimal128("1.5")}, {"_id": 2, "a": Decimal128("2.5")}], + expected=[{"_id": 2, "a": Decimal128("2.5")}], + msg="$nin with Decimal128 excludes matching document", + ), + QueryTestCase( + id="bool_true", + filter={"a": {"$nin": [True]}}, + doc=[{"_id": 1, "a": True}, {"_id": 2, "a": False}], + expected=[{"_id": 2, "a": False}], + msg="$nin with bool true excludes matching document", + ), + QueryTestCase( + id="bool_false", + filter={"a": {"$nin": [False]}}, + doc=[{"_id": 1, "a": False}, {"_id": 2, "a": True}], + expected=[{"_id": 2, "a": True}], + msg="$nin with bool false excludes matching document", + ), + QueryTestCase( + id="datetime", + filter={"a": {"$nin": [datetime(2024, 1, 1, tzinfo=timezone.utc)]}}, + doc=[ + {"_id": 1, "a": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + {"_id": 2, "a": datetime(2025, 1, 1, tzinfo=timezone.utc)}, + ], + expected=[{"_id": 2, "a": datetime(2025, 1, 1)}], + msg="$nin with datetime excludes matching document", + ), + QueryTestCase( + id="objectid", + filter={"a": {"$nin": [ObjectId("507f1f77bcf86cd799439011")]}}, + doc=[ + {"_id": 1, "a": ObjectId("507f1f77bcf86cd799439011")}, + {"_id": 2, "a": ObjectId("507f1f77bcf86cd799439012")}, + ], + expected=[{"_id": 2, "a": ObjectId("507f1f77bcf86cd799439012")}], + msg="$nin with ObjectId excludes matching document", + ), + QueryTestCase( + id="embedded_document", + filter={"a": {"$nin": [{"x": 1, "y": 2}]}}, + doc=[{"_id": 1, "a": {"x": 1, "y": 2}}, {"_id": 2, "a": {"x": 3}}], + expected=[{"_id": 2, "a": {"x": 3}}], + msg="$nin with embedded document excludes matching document", + ), + QueryTestCase( + id="array", + filter={"a": {"$nin": [[1, 2, 3]]}}, + doc=[{"_id": 1, "a": [1, 2, 3]}, {"_id": 2, "a": [4, 5]}], + expected=[{"_id": 2, "a": [4, 5]}], + msg="$nin with array excludes matching document", + ), + QueryTestCase( + id="null", + filter={"a": {"$nin": [None]}}, + doc=[{"_id": 1, "a": None}, {"_id": 2, "a": 1}], + expected=[{"_id": 2, "a": 1}], + msg="$nin with null excludes matching document", + ), + QueryTestCase( + id="regex", + filter={"a": {"$nin": [Regex("^hello")]}}, + doc=[{"_id": 1, "a": "hello world"}, {"_id": 2, "a": "goodbye"}], + expected=[{"_id": 2, "a": "goodbye"}], + msg="$nin with regex excludes matching document", + ), + QueryTestCase( + id="binary", + filter={"a": {"$nin": [Binary(b"\x01\x02\x03")]}}, + doc=[{"_id": 1, "a": Binary(b"\x01\x02\x03")}, {"_id": 2, "a": Binary(b"\x04\x05")}], + expected=[{"_id": 2, "a": b"\x04\x05"}], + msg="$nin with Binary excludes matching document", + ), + QueryTestCase( + id="timestamp", + filter={"a": {"$nin": [Timestamp(1234567890, 1)]}}, + doc=[{"_id": 1, "a": Timestamp(1234567890, 1)}, {"_id": 2, "a": Timestamp(1, 1)}], + expected=[{"_id": 2, "a": Timestamp(1, 1)}], + msg="$nin with Timestamp excludes matching document", + ), + QueryTestCase( + id="minkey", + filter={"a": {"$nin": [MinKey()]}}, + doc=[{"_id": 1, "a": MinKey()}, {"_id": 2, "a": 1}], + expected=[{"_id": 2, "a": 1}], + msg="$nin with MinKey excludes matching document", + ), + QueryTestCase( + id="maxkey", + filter={"a": {"$nin": [MaxKey()]}}, + doc=[{"_id": 1, "a": MaxKey()}, {"_id": 2, "a": 1}], + expected=[{"_id": 2, "a": 1}], + msg="$nin with MaxKey excludes matching document", + ), +] + +NUMERIC_CROSS_TYPE_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="int_excludes_long", + filter={"a": {"$nin": [1]}}, + doc=[{"_id": 1, "a": Int64(1)}, {"_id": 2, "a": Int64(2)}], + expected=[{"_id": 2, "a": Int64(2)}], + msg="$nin int(1) excludes long(1)", + ), + QueryTestCase( + id="int_excludes_double", + filter={"a": {"$nin": [1]}}, + doc=[{"_id": 1, "a": 1.0}, {"_id": 2, "a": 2.0}], + expected=[{"_id": 2, "a": 2.0}], + msg="$nin int(1) excludes double(1.0)", + ), + QueryTestCase( + id="int_excludes_decimal128", + filter={"a": {"$nin": [1]}}, + doc=[{"_id": 1, "a": Decimal128("1")}, {"_id": 2, "a": Decimal128("2")}], + expected=[{"_id": 2, "a": Decimal128("2")}], + msg="$nin int(1) excludes Decimal128('1')", + ), + QueryTestCase( + id="long_excludes_double", + filter={"a": {"$nin": [Int64(1)]}}, + doc=[{"_id": 1, "a": 1.0}, {"_id": 2, "a": 2.0}], + expected=[{"_id": 2, "a": 2.0}], + msg="$nin long(1) excludes double(1.0)", + ), + QueryTestCase( + id="double_excludes_decimal128", + filter={"a": {"$nin": [1.0]}}, + doc=[{"_id": 1, "a": Decimal128("1")}, {"_id": 2, "a": Decimal128("2")}], + expected=[{"_id": 2, "a": Decimal128("2")}], + msg="$nin double(1.0) excludes Decimal128('1')", + ), + QueryTestCase( + id="all_numeric_types_excluded", + filter={"a": {"$nin": [1]}}, + doc=[ + {"_id": 1, "a": 1}, + {"_id": 2, "a": Int64(1)}, + {"_id": 3, "a": 1.0}, + {"_id": 4, "a": Decimal128("1")}, + {"_id": 5, "a": 2}, + ], + expected=[{"_id": 5, "a": 2}], + msg="$nin int(1) excludes all equivalent numeric types", + ), +] + +TYPE_DISTINCTION_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="false_does_not_exclude_zero", + filter={"a": {"$nin": [False]}}, + doc=[{"_id": 1, "a": 0}, {"_id": 2, "a": False}], + expected=[{"_id": 1, "a": 0}], + msg="$nin false does NOT exclude int 0", + ), + QueryTestCase( + id="zero_does_not_exclude_false", + filter={"a": {"$nin": [0]}}, + doc=[{"_id": 1, "a": False}, {"_id": 2, "a": 0}], + expected=[{"_id": 1, "a": False}], + msg="$nin int 0 does NOT exclude bool false", + ), + QueryTestCase( + id="true_does_not_exclude_one", + filter={"a": {"$nin": [True]}}, + doc=[{"_id": 1, "a": 1}, {"_id": 2, "a": True}], + expected=[{"_id": 1, "a": 1}], + msg="$nin true does NOT exclude int 1", + ), + QueryTestCase( + id="one_does_not_exclude_true", + filter={"a": {"$nin": [1]}}, + doc=[{"_id": 1, "a": True}, {"_id": 2, "a": 1}], + expected=[{"_id": 1, "a": True}], + msg="$nin int 1 does NOT exclude bool true", + ), + QueryTestCase( + id="type_null_does_not_exclude_empty_string", + filter={"a": {"$nin": [None]}}, + doc=[{"_id": 1, "a": ""}, {"_id": 2, "a": None}], + expected=[{"_id": 1, "a": ""}], + msg="$nin null does NOT exclude empty string", + ), + QueryTestCase( + id="empty_string_does_not_exclude_null", + filter={"a": {"$nin": [""]}}, + doc=[{"_id": 1, "a": None}, {"_id": 2, "a": ""}], + expected=[{"_id": 1, "a": None}], + msg="$nin empty string does NOT exclude null", + ), + QueryTestCase( + id="null_does_not_exclude_zero", + filter={"a": {"$nin": [None]}}, + doc=[{"_id": 1, "a": 0}, {"_id": 2, "a": None}], + expected=[{"_id": 1, "a": 0}], + msg="$nin null does NOT exclude int 0", + ), + QueryTestCase( + id="null_does_not_exclude_false", + filter={"a": {"$nin": [None]}}, + doc=[{"_id": 1, "a": False}, {"_id": 2, "a": None}], + expected=[{"_id": 1, "a": False}], + msg="$nin null does NOT exclude bool false", + ), + QueryTestCase( + id="datetime_does_not_exclude_timestamp", + filter={"a": {"$nin": [datetime(2024, 1, 1, tzinfo=timezone.utc)]}}, + doc=[ + {"_id": 1, "a": Timestamp(1704067200, 0)}, + {"_id": 2, "a": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + ], + expected=[{"_id": 1, "a": Timestamp(1704067200, 0)}], + msg="$nin datetime does NOT exclude Timestamp with same epoch seconds", + ), +] + +TESTS = BSON_TYPE_TESTS + NUMERIC_CROSS_TYPE_TESTS + TYPE_DISTINCTION_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(TESTS)) +def test_nin_bson_wiring(collection, test_case): + """Parametrized test for $nin BSON type wiring.""" + collection.insert_many(test_case.doc) + result = execute_command(collection, {"find": collection.name, "filter": test_case.filter}) + assertSuccess(result, test_case.expected, msg=test_case.msg, ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/comparison/nin/test_nin_core_semantics.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/nin/test_nin_core_semantics.py new file mode 100644 index 00000000..1b9fb93d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/nin/test_nin_core_semantics.py @@ -0,0 +1,136 @@ +""" +Tests for $nin core semantics. + +Covers basic exclusion, equivalence with $not+$in, complement of $in, +single-element equivalence with $ne, absent field matching, and _id field targeting. +""" + +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 + +_oid1, _oid2, _oid3 = ObjectId(), ObjectId(), ObjectId() + +TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="basic_exclusion", + filter={"a": {"$nin": [2, 4]}}, + doc=[ + {"_id": 1, "a": 1}, + {"_id": 2, "a": 2}, + {"_id": 3, "a": 3}, + {"_id": 4, "a": 4}, + ], + expected=[{"_id": 1, "a": 1}, {"_id": 3, "a": 3}], + msg="$nin excludes matching values and returns non-matching", + ), + QueryTestCase( + id="no_matches_returns_all", + filter={"a": {"$nin": [10, 20]}}, + doc=[ + {"_id": 1, "a": 1}, + {"_id": 2, "a": 2}, + {"_id": 3, "a": 3}, + ], + expected=[ + {"_id": 1, "a": 1}, + {"_id": 2, "a": 2}, + {"_id": 3, "a": 3}, + ], + msg="$nin returns all docs when no values match", + ), + QueryTestCase( + id="all_excluded_returns_none", + filter={"a": {"$nin": [1, 2]}}, + doc=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + expected=[], + msg="$nin returns no docs when all values are excluded", + ), + QueryTestCase( + id="nin_equivalent_to_not_in", + filter={"a": {"$nin": [1, 2]}}, + doc=[ + {"_id": 1, "a": 1}, + {"_id": 2, "a": 2}, + {"_id": 3, "a": 3}, + {"_id": 4, "a": None}, + {"_id": 5, "b": 1}, + ], + expected=[ + {"_id": 3, "a": 3}, + {"_id": 4, "a": None}, + {"_id": 5, "b": 1}, + ], + msg="$nin produces same results as $not+$in", + ), + QueryTestCase( + id="not_nin_equivalent_to_in", + filter={"a": {"$not": {"$nin": [1, 2]}}}, + doc=[ + {"_id": 1, "a": 1}, + {"_id": 2, "a": 2}, + {"_id": 3, "a": 3}, + ], + expected=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + msg="$not+$nin is equivalent to $in", + ), + QueryTestCase( + id="complement_of_in", + filter={"a": {"$nin": [1, 3]}}, + doc=[ + {"_id": 1, "a": 1}, + {"_id": 2, "a": 2}, + {"_id": 3, "a": 3}, + {"_id": 4, "a": 4}, + {"_id": 5, "a": 5}, + ], + expected=[ + {"_id": 2, "a": 2}, + {"_id": 4, "a": 4}, + {"_id": 5, "a": 5}, + ], + msg="$nin returns complement of $in", + ), + QueryTestCase( + id="single_element_equivalent_to_ne", + filter={"a": {"$nin": [1]}}, + doc=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + expected=[{"_id": 2, "a": 2}], + msg="$nin with single element is equivalent to $ne", + ), + QueryTestCase( + id="matches_absent_field", + filter={"a": {"$nin": [1]}}, + doc=[{"_id": 1, "a": 1}, {"_id": 2, "b": 1}], + expected=[{"_id": 2, "b": 1}], + msg="$nin matches documents where field is absent", + ), + QueryTestCase( + id="on_id_with_integers", + filter={"_id": {"$nin": [1, 3]}}, + doc=[{"_id": 1}, {"_id": 2}, {"_id": 3}], + expected=[{"_id": 2}], + msg="$nin on _id field with integer values", + ), + QueryTestCase( + id="on_id_with_objectid", + filter={"_id": {"$nin": [_oid1, _oid3]}}, + doc=[{"_id": _oid1}, {"_id": _oid2}, {"_id": _oid3}], + expected=[{"_id": _oid2}], + msg="$nin on _id field with ObjectId values", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(TESTS)) +def test_nin_core_semantics(collection, test_case): + """Parametrized test for $nin core semantics.""" + collection.insert_many(test_case.doc) + result = execute_command(collection, {"find": collection.name, "filter": test_case.filter}) + assertSuccess(result, test_case.expected, ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/comparison/nin/test_nin_nan_infinity.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/nin/test_nin_nan_infinity.py new file mode 100644 index 00000000..eadfe2fd --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/nin/test_nin_nan_infinity.py @@ -0,0 +1,144 @@ +""" +Tests for $nin query operator NaN and Infinity matching. + +Covers NaN self-matching, cross-type NaN, Infinity matching, +negative zero vs positive zero, and NaN non-matching with regular numbers. +""" + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.query.utils.query_test_case import ( + QueryTestCase, +) +from documentdb_tests.framework.assertions import assertSuccess, assertSuccessNaN +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, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +NAN_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="double_nan_excludes_double_nan", + filter={"a": {"$nin": [FLOAT_NAN]}}, + doc=[{"_id": 1, "a": FLOAT_NAN}, {"_id": 2, "a": 1}], + expected=[{"_id": 2, "a": 1}], + msg="$nin with double NaN excludes double NaN field", + ), + QueryTestCase( + id="decimal128_nan_excludes_decimal128_nan", + filter={"a": {"$nin": [DECIMAL128_NAN]}}, + doc=[ + {"_id": 1, "a": DECIMAL128_NAN}, + {"_id": 2, "a": Decimal128("1")}, + ], + expected=[{"_id": 2, "a": Decimal128("1")}], + msg="$nin with Decimal128 NaN excludes Decimal128 NaN field", + ), + QueryTestCase( + id="double_nan_excludes_decimal128_nan", + filter={"a": {"$nin": [FLOAT_NAN]}}, + doc=[{"_id": 1, "a": DECIMAL128_NAN}, {"_id": 2, "a": 1}], + expected=[{"_id": 2, "a": 1}], + msg="$nin with double NaN excludes Decimal128 NaN (cross-type NaN matching)", + ), + QueryTestCase( + id="nan_does_not_exclude_regular_number", + filter={"a": {"$nin": [FLOAT_NAN]}}, + doc=[{"_id": 1, "a": 0}, {"_id": 2, "a": 1}, {"_id": 3, "a": -1}], + expected=[{"_id": 1, "a": 0}, {"_id": 2, "a": 1}, {"_id": 3, "a": -1}], + msg="$nin with NaN does NOT exclude regular numbers", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(NAN_TESTS)) +def test_nin_nan(collection, test_case): + """Parametrized test for $nin NaN matching.""" + collection.insert_many(test_case.doc) + result = execute_command( + collection, + {"find": collection.name, "filter": test_case.filter}, + ) + assertSuccessNaN(result, test_case.expected, msg=test_case.msg, ignore_doc_order=True) + + +INFINITY_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="double_infinity_excluded", + filter={"a": {"$nin": [FLOAT_INFINITY]}}, + doc=[{"_id": 1, "a": FLOAT_INFINITY}, {"_id": 2, "a": 1}], + expected=[{"_id": 2, "a": 1}], + msg="$nin with double Infinity excludes double Infinity field", + ), + QueryTestCase( + id="double_negative_infinity_excluded", + filter={"a": {"$nin": [FLOAT_NEGATIVE_INFINITY]}}, + doc=[{"_id": 1, "a": FLOAT_NEGATIVE_INFINITY}, {"_id": 2, "a": 1}], + expected=[{"_id": 2, "a": 1}], + msg="$nin with double -Infinity excludes double -Infinity field", + ), + QueryTestCase( + id="decimal128_infinity_excluded", + filter={"a": {"$nin": [DECIMAL128_INFINITY]}}, + doc=[{"_id": 1, "a": DECIMAL128_INFINITY}, {"_id": 2, "a": Decimal128("1")}], + expected=[{"_id": 2, "a": Decimal128("1")}], + msg="$nin with Decimal128 Infinity excludes Decimal128 Infinity field", + ), + QueryTestCase( + id="decimal128_negative_infinity_excluded", + filter={"a": {"$nin": [DECIMAL128_NEGATIVE_INFINITY]}}, + doc=[{"_id": 1, "a": DECIMAL128_NEGATIVE_INFINITY}, {"_id": 2, "a": Decimal128("1")}], + expected=[{"_id": 2, "a": Decimal128("1")}], + msg="$nin with Decimal128 -Infinity excludes Decimal128 -Infinity field", + ), + QueryTestCase( + id="infinity_does_not_exclude_negative_infinity", + filter={"a": {"$nin": [FLOAT_INFINITY]}}, + doc=[{"_id": 1, "a": FLOAT_NEGATIVE_INFINITY}], + expected=[{"_id": 1, "a": FLOAT_NEGATIVE_INFINITY}], + msg="$nin with Infinity does NOT exclude -Infinity", + ), +] + +NEGATIVE_ZERO_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="double_negative_zero_excludes_positive_zero", + filter={"a": {"$nin": [DOUBLE_NEGATIVE_ZERO]}}, + doc=[{"_id": 1, "a": DOUBLE_ZERO}, {"_id": 2, "a": 1}], + expected=[{"_id": 2, "a": 1}], + msg="$nin with double -0.0 excludes double 0.0", + ), + QueryTestCase( + id="double_positive_zero_excludes_negative_zero", + filter={"a": {"$nin": [DOUBLE_ZERO]}}, + doc=[{"_id": 1, "a": DOUBLE_NEGATIVE_ZERO}, {"_id": 2, "a": 1}], + expected=[{"_id": 2, "a": 1}], + msg="$nin with double 0.0 excludes double -0.0", + ), + QueryTestCase( + id="decimal128_negative_zero_excludes_positive_zero", + filter={"a": {"$nin": [DECIMAL128_NEGATIVE_ZERO]}}, + doc=[{"_id": 1, "a": DECIMAL128_ZERO}, {"_id": 2, "a": Decimal128("1")}], + expected=[{"_id": 2, "a": Decimal128("1")}], + msg="$nin with Decimal128 -0 excludes Decimal128 0", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(INFINITY_TESTS + NEGATIVE_ZERO_TESTS)) +def test_nin_infinity_and_zero(collection, test_case): + """Parametrized test for $nin Infinity and negative zero matching.""" + collection.insert_many(test_case.doc) + result = execute_command(collection, {"find": collection.name, "filter": test_case.filter}) + assertSuccess(result, test_case.expected, msg=test_case.msg, ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/comparison/nin/test_nin_null_missing.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/nin/test_nin_null_missing.py new file mode 100644 index 00000000..95599cf4 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/nin/test_nin_null_missing.py @@ -0,0 +1,59 @@ +""" +Tests for $nin query operator null and missing field handling. + +Covers missing field exclusion, combined null with other values, +and null excluding both null and missing together. +""" + +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 + +TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="null_excludes_missing_field", + filter={"x": {"$nin": [None]}}, + doc=[{"_id": 1, "y": 1}, {"_id": 2, "x": 1}], + expected=[{"_id": 2, "x": 1}], + msg="$nin with [null] excludes document where field is missing", + ), + QueryTestCase( + id="null_and_value_excludes_null_missing_and_value", + filter={"x": {"$nin": [None, 1]}}, + doc=[ + {"_id": 1, "x": None}, + {"_id": 2, "y": 1}, + {"_id": 3, "x": 1}, + {"_id": 4, "x": 2}, + ], + expected=[{"_id": 4, "x": 2}], + msg="$nin with [null, 1] excludes null, missing, and 1", + ), + QueryTestCase( + id="missing_field_without_null_in_array_included", + filter={"x": {"$nin": [1, 2]}}, + doc=[{"_id": 1, "y": 1}, {"_id": 2, "x": 1}], + expected=[{"_id": 1, "y": 1}], + msg="$nin without null in array does NOT exclude missing field", + ), + QueryTestCase( + id="null_excludes_both_null_and_missing", + filter={"x": {"$nin": [None]}}, + doc=[{"_id": 1, "x": None}, {"_id": 2, "y": 1}, {"_id": 3, "x": 1}], + expected=[{"_id": 3, "x": 1}], + msg="$nin with [null] excludes both null and missing in same query", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(TESTS)) +def test_nin_null_missing(collection, test_case): + """Parametrized test for $nin null and missing field handling.""" + collection.insert_many(test_case.doc) + result = execute_command(collection, {"find": collection.name, "filter": test_case.filter}) + assertSuccess(result, test_case.expected, msg=test_case.msg, ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/comparison/nin/test_nin_numeric_edge_cases.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/nin/test_nin_numeric_edge_cases.py new file mode 100644 index 00000000..b668620c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/nin/test_nin_numeric_edge_cases.py @@ -0,0 +1,116 @@ +""" +Tests for $nin query operator numeric boundary and edge cases. + +Covers INT32/INT64 boundaries, Decimal128 precision boundaries, +trailing zeros equivalence, and scientific notation equivalence. +""" + +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_LARGE_EXPONENT, + DECIMAL128_MAX, + DECIMAL128_MIN, + DECIMAL128_MIN_POSITIVE, + DECIMAL128_SMALL_EXPONENT, + DECIMAL128_TRAILING_ZERO, + INT32_MAX, + INT32_MIN, + INT64_MAX, + INT64_MIN, +) + +TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="int32_max", + filter={"a": {"$nin": [INT32_MAX]}}, + doc=[{"_id": 1, "a": INT32_MAX}, {"_id": 2, "a": 0}], + expected=[{"_id": 2, "a": 0}], + msg="$nin excludes INT32_MAX", + ), + QueryTestCase( + id="int32_min", + filter={"a": {"$nin": [INT32_MIN]}}, + doc=[{"_id": 1, "a": INT32_MIN}, {"_id": 2, "a": 0}], + expected=[{"_id": 2, "a": 0}], + msg="$nin excludes INT32_MIN", + ), + QueryTestCase( + id="int64_max", + filter={"a": {"$nin": [INT64_MAX]}}, + doc=[{"_id": 1, "a": INT64_MAX}, {"_id": 2, "a": Int64(0)}], + expected=[{"_id": 2, "a": Int64(0)}], + msg="$nin excludes INT64_MAX", + ), + QueryTestCase( + id="int64_min", + filter={"a": {"$nin": [INT64_MIN]}}, + doc=[{"_id": 1, "a": INT64_MIN}, {"_id": 2, "a": Int64(0)}], + expected=[{"_id": 2, "a": Int64(0)}], + msg="$nin excludes INT64_MIN", + ), + QueryTestCase( + id="decimal128_max", + filter={"a": {"$nin": [DECIMAL128_MAX]}}, + doc=[{"_id": 1, "a": DECIMAL128_MAX}, {"_id": 2, "a": Decimal128("0")}], + expected=[{"_id": 2, "a": Decimal128("0")}], + msg="$nin excludes DECIMAL128_MAX", + ), + QueryTestCase( + id="decimal128_min", + filter={"a": {"$nin": [DECIMAL128_MIN]}}, + doc=[{"_id": 1, "a": DECIMAL128_MIN}, {"_id": 2, "a": Decimal128("0")}], + expected=[{"_id": 2, "a": Decimal128("0")}], + msg="$nin excludes DECIMAL128_MIN", + ), + QueryTestCase( + id="decimal128_min_positive", + filter={"a": {"$nin": [DECIMAL128_MIN_POSITIVE]}}, + doc=[{"_id": 1, "a": DECIMAL128_MIN_POSITIVE}, {"_id": 2, "a": Decimal128("0")}], + expected=[{"_id": 2, "a": Decimal128("0")}], + msg="$nin excludes DECIMAL128_MIN_POSITIVE", + ), + QueryTestCase( + id="decimal128_small_exponent", + filter={"a": {"$nin": [DECIMAL128_SMALL_EXPONENT]}}, + doc=[{"_id": 1, "a": DECIMAL128_SMALL_EXPONENT}, {"_id": 2, "a": Decimal128("0")}], + expected=[{"_id": 2, "a": Decimal128("0")}], + msg="$nin excludes DECIMAL128_SMALL_EXPONENT", + ), + QueryTestCase( + id="decimal128_large_exponent", + filter={"a": {"$nin": [DECIMAL128_LARGE_EXPONENT]}}, + doc=[{"_id": 1, "a": DECIMAL128_LARGE_EXPONENT}, {"_id": 2, "a": Decimal128("0")}], + expected=[{"_id": 2, "a": Decimal128("0")}], + msg="$nin excludes DECIMAL128_LARGE_EXPONENT", + ), + QueryTestCase( + id="decimal128_trailing_zeros_equivalence", + filter={"a": {"$nin": [DECIMAL128_TRAILING_ZERO]}}, + doc=[{"_id": 1, "a": Decimal128("1")}, {"_id": 2, "a": Decimal128("2")}], + expected=[{"_id": 2, "a": Decimal128("2")}], + msg="$nin with Decimal128('1.0') excludes Decimal128('1') (numeric equivalence)", + ), + QueryTestCase( + id="decimal128_scientific_notation_equivalence", + filter={"a": {"$nin": [Decimal128("1.0E+2")]}}, + doc=[{"_id": 1, "a": 100}, {"_id": 2, "a": 200}], + expected=[{"_id": 2, "a": 200}], + msg="$nin with Decimal128('1.0E+2') excludes int 100 (numeric equivalence)", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(TESTS)) +def test_nin_numeric_edge_cases(collection, test_case): + """Parametrized test for $nin numeric boundary and edge cases.""" + collection.insert_many(test_case.doc) + result = execute_command(collection, {"find": collection.name, "filter": test_case.filter}) + assertSuccess(result, test_case.expected, msg=test_case.msg, ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/comparison/nin/test_nin_regex.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/nin/test_nin_regex.py new file mode 100644 index 00000000..c0b6e9f9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/nin/test_nin_regex.py @@ -0,0 +1,108 @@ +""" +Tests for $nin with regex values. + +Covers regex exclusion on scalar strings, case-insensitive flag, multiline flag, +no-flag default behavior, multiple regexes, mixed regex/literal values, +regex on array fields, and $regex expression rejection. +""" + +import pytest +from bson import Regex + +from documentdb_tests.compatibility.tests.core.operator.query.utils.query_test_case import ( + QueryTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccess +from documentdb_tests.framework.error_codes import BAD_VALUE_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +SUCCESS_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="regex_excludes_matching_string", + filter={"a": {"$nin": [Regex("^a")]}}, + doc=[{"_id": 1, "a": "abc"}, {"_id": 2, "a": "xyz"}], + expected=[{"_id": 2, "a": "xyz"}], + msg="$nin with regex excludes doc where string matches pattern", + ), + QueryTestCase( + id="regex_includes_non_matching_string", + filter={"a": {"$nin": [Regex("^a")]}}, + doc=[{"_id": 1, "a": "xyz"}], + expected=[{"_id": 1, "a": "xyz"}], + msg="$nin with regex includes doc where string does not match pattern", + ), + QueryTestCase( + id="regex_case_insensitive_excludes", + filter={"a": {"$nin": [Regex("abc", "i")]}}, + doc=[{"_id": 1, "a": "ABC"}, {"_id": 2, "a": "xyz"}], + expected=[{"_id": 2, "a": "xyz"}], + msg="$nin with case-insensitive regex excludes matching doc", + ), + QueryTestCase( + id="regex_no_flag_is_case_sensitive", + filter={"a": {"$nin": [Regex("^abc")]}}, + doc=[{"_id": 1, "a": "abcdef"}, {"_id": 2, "a": "ABCdef"}], + expected=[{"_id": 2, "a": "ABCdef"}], + msg="$nin with regex without flags is case-sensitive by default", + ), + QueryTestCase( + id="regex_multiline_flag", + filter={"a": {"$nin": [Regex("^abc", "m")]}}, + doc=[{"_id": 1, "a": "hello\nabcdef"}, {"_id": 2, "a": "hello\nxyz"}], + expected=[{"_id": 2, "a": "hello\nxyz"}], + msg="$nin with regex using multiline flag excludes match after newline", + ), + QueryTestCase( + id="regex_multiple_patterns", + filter={"a": {"$nin": [Regex("^a"), Regex("^b")]}}, + doc=[ + {"_id": 1, "a": "apple"}, + {"_id": 2, "a": "banana"}, + {"_id": 3, "a": "cherry"}, + ], + expected=[{"_id": 3, "a": "cherry"}], + msg="$nin with multiple regexes excludes all matching", + ), + QueryTestCase( + id="regex_and_literal_mixed", + filter={"a": {"$nin": [Regex("^a"), "xyz"]}}, + doc=[{"_id": 1, "a": "abc"}, {"_id": 2, "a": "xyz"}, {"_id": 3, "a": "def"}], + expected=[{"_id": 3, "a": "def"}], + msg="$nin with regex and literal excludes both regex and literal matches", + ), + QueryTestCase( + id="regex_array_element_matches_excludes", + filter={"a": {"$nin": [Regex("^a")]}}, + doc=[{"_id": 1, "a": ["abc", "def"]}, {"_id": 2, "a": ["xyz", "def"]}], + expected=[{"_id": 2, "a": ["xyz", "def"]}], + msg="$nin with regex excludes doc when array element matches", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(SUCCESS_TESTS)) +def test_nin_regex(collection, test_case): + """Parametrized test for $nin with regex values.""" + collection.insert_many(test_case.doc) + result = execute_command(collection, {"find": collection.name, "filter": test_case.filter}) + assertSuccess(result, test_case.expected, msg=test_case.msg, ignore_doc_order=True) + + +ERROR_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="dollar_regex_expression_rejected", + filter={"a": {"$nin": [{"$regex": "^abc"}]}}, + doc=[{"_id": 1, "a": "abcdef"}], + error_code=BAD_VALUE_ERROR, + msg="$nin with $regex expression inside array returns error", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(ERROR_TESTS)) +def test_nin_regex_errors(collection, test_case): + """Parametrized test for $nin regex error cases.""" + collection.insert_many(test_case.doc) + result = execute_command(collection, {"find": collection.name, "filter": test_case.filter}) + assertFailureCode(result, test_case.error_code, test_case.msg) diff --git a/documentdb_tests/framework/assertions.py b/documentdb_tests/framework/assertions.py index f92abcd9..771e7fb3 100644 --- a/documentdb_tests/framework/assertions.py +++ b/documentdb_tests/framework/assertions.py @@ -247,3 +247,32 @@ def assertResult( ignore_order_in=ignore_order_in, ignore_doc_order=ignore_doc_order, ) + + +def _replace_nan(val: Any) -> Any: + """Recursively replace NaN (float or Decimal128) with __NAN__ so that == works.""" + if isinstance(val, float) and math.isnan(val): + return "__NaN__" + if isinstance(val, Decimal128) and val.to_decimal().is_nan(): + return "__NaN__" + if isinstance(val, dict): + return {k: _replace_nan(v) for k, v in val.items()} + if isinstance(val, list): + return [_replace_nan(v) for v in val] + return val + + +def assertSuccessNaN( + result: Union[Any, Exception], + expected: Any, + msg: Optional[str] = None, + ignore_doc_order: bool = False, +): + """Assert command succeeded, treating NaN == NaN as True.""" + assertSuccess( + result, + _replace_nan(expected), + msg=msg, + ignore_doc_order=ignore_doc_order, + transform=_replace_nan, + ) diff --git a/documentdb_tests/framework/test_constants.py b/documentdb_tests/framework/test_constants.py index ba16ad6f..505ad497 100644 --- a/documentdb_tests/framework/test_constants.py +++ b/documentdb_tests/framework/test_constants.py @@ -60,6 +60,7 @@ DECIMAL128_MAX = Decimal128("9.999999999999999999999999999999999E+6144") DECIMAL128_LARGE_EXPONENT = Decimal128("1E+6144") DECIMAL128_SMALL_EXPONENT = Decimal128("1E-6143") +DECIMAL128_MIN_POSITIVE = Decimal128("1E-6176") DECIMAL128_TRAILING_ZERO = Decimal128("1.0") DECIMAL128_MANY_TRAILING_ZEROS = Decimal128("1.00000000000000000000000000000000") DECIMAL128_MAX_COEFFICIENT = Decimal128("9999999999999999999999999999999999")