diff --git a/documentdb_tests/compatibility/tests/core/operator/query/comparison/eq/__init__.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/eq/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/query/comparison/eq/test_eq_bson_wiring.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/eq/test_eq_bson_wiring.py new file mode 100644 index 000000000..487d652a6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/eq/test_eq_bson_wiring.py @@ -0,0 +1,337 @@ +""" +Tests for $eq BSON type wiring, numeric cross-type equivalence, type +distinction, and date boundaries. + +Covers matching within each BSON type (including int32, javascript, and date), +cross-type numeric equivalence, distinct types that must NOT match (bool vs int, +string vs number, null vs empty string / zero / bool, ObjectId vs its string +form, BinData subtype), and date boundary/precision cases plus +Date-vs-Timestamp-vs-ObjectId distinctness. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, 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 +from documentdb_tests.framework.test_constants import ( + DATE_BEFORE_EPOCH, + DATE_EPOCH, + DATE_Y2K, + DATE_YEAR_1, + DATE_YEAR_9999, +) + +BSON_TYPE_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="double", + filter={"a": {"$eq": 3.14}}, + doc=[{"_id": 1, "a": 3.14}, {"_id": 2, "a": 2.71}], + expected=[{"_id": 1, "a": 3.14}], + msg="$eq with double", + ), + QueryTestCase( + id="int64", + filter={"a": {"$eq": Int64(9999999999)}}, + doc=[{"_id": 1, "a": Int64(9999999999)}, {"_id": 2, "a": Int64(1)}], + expected=[{"_id": 1, "a": Int64(9999999999)}], + msg="$eq with Int64 (long)", + ), + QueryTestCase( + id="decimal128", + filter={"a": {"$eq": Decimal128("1.5")}}, + doc=[{"_id": 1, "a": Decimal128("1.5")}, {"_id": 2, "a": Decimal128("2.5")}], + expected=[{"_id": 1, "a": Decimal128("1.5")}], + msg="$eq with Decimal128", + ), + QueryTestCase( + id="bool_true", + filter={"a": {"$eq": True}}, + doc=[{"_id": 1, "a": True}, {"_id": 2, "a": False}], + expected=[{"_id": 1, "a": True}], + msg="$eq with bool true", + ), + QueryTestCase( + id="bool_false", + filter={"a": {"$eq": False}}, + doc=[{"_id": 1, "a": False}, {"_id": 2, "a": True}], + expected=[{"_id": 1, "a": False}], + msg="$eq with bool false", + ), + QueryTestCase( + id="datetime", + filter={"a": {"$eq": 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, tzinfo=timezone.utc)}], + msg="$eq with datetime (date)", + ), + QueryTestCase( + id="binary", + filter={"a": {"$eq": 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="$eq with Binary (binData)", + ), + QueryTestCase( + id="timestamp", + filter={"a": {"$eq": Timestamp(1234567890, 1)}}, + doc=[{"_id": 1, "a": Timestamp(1234567890, 1)}, {"_id": 2, "a": Timestamp(1, 1)}], + expected=[{"_id": 1, "a": Timestamp(1234567890, 1)}], + msg="$eq with Timestamp", + ), + QueryTestCase( + id="int32", + filter={"a": {"$eq": 42}}, + doc=[{"_id": 1, "a": 42}, {"_id": 2, "a": 43}], + expected=[{"_id": 1, "a": 42}], + msg="$eq with int32 matches same-type int32", + ), + QueryTestCase( + id="javascript", + filter={"a": {"$eq": Code("function() { return 1; }")}}, + doc=[ + {"_id": 1, "a": Code("function() { return 1; }")}, + {"_id": 2, "a": Code("function() { return 2; }")}, + ], + expected=[{"_id": 1, "a": Code("function() { return 1; }")}], + msg="$eq with JavaScript (Code) matches same-type javascript value", + ), + QueryTestCase( + id="minkey", + filter={"a": {"$eq": MinKey()}}, + doc=[{"_id": 1, "a": MinKey()}, {"_id": 2, "a": 1}], + expected=[{"_id": 1, "a": MinKey()}], + msg="$eq with MinKey", + ), + QueryTestCase( + id="maxkey", + filter={"a": {"$eq": MaxKey()}}, + doc=[{"_id": 1, "a": MaxKey()}, {"_id": 2, "a": 1}], + expected=[{"_id": 1, "a": MaxKey()}], + msg="$eq with MaxKey", + ), +] + +NUMERIC_CROSS_TYPE_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="int_matches_long", + filter={"a": {"$eq": 1}}, + doc=[{"_id": 1, "a": Int64(1)}, {"_id": 2, "a": Int64(2)}], + expected=[{"_id": 1, "a": Int64(1)}], + msg="$eq int(1) matches long(1)", + ), + QueryTestCase( + id="int_matches_double", + filter={"a": {"$eq": 1}}, + doc=[{"_id": 1, "a": 1.0}, {"_id": 2, "a": 2.0}], + expected=[{"_id": 1, "a": 1.0}], + msg="$eq int(1) matches double(1.0)", + ), + QueryTestCase( + id="int_matches_decimal128", + filter={"a": {"$eq": 1}}, + doc=[{"_id": 1, "a": Decimal128("1")}, {"_id": 2, "a": Decimal128("2")}], + expected=[{"_id": 1, "a": Decimal128("1")}], + msg="$eq int(1) matches Decimal128('1')", + ), + QueryTestCase( + id="long_matches_double", + filter={"a": {"$eq": Int64(1)}}, + doc=[{"_id": 1, "a": 1.0}, {"_id": 2, "a": 2.0}], + expected=[{"_id": 1, "a": 1.0}], + msg="$eq long(1) matches double(1.0)", + ), + QueryTestCase( + id="double_matches_decimal128", + filter={"a": {"$eq": 1.0}}, + doc=[{"_id": 1, "a": Decimal128("1")}, {"_id": 2, "a": Decimal128("2")}], + expected=[{"_id": 1, "a": Decimal128("1")}], + msg="$eq double(1.0) matches Decimal128('1')", + ), + QueryTestCase( + id="all_numeric_types_match", + filter={"a": {"$eq": 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="$eq int(1) matches all equivalent numeric types", + ), +] + +TYPE_DISTINCTION_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="false_does_not_match_zero", + filter={"a": {"$eq": False}}, + doc=[{"_id": 1, "a": 0}, {"_id": 2, "a": False}], + expected=[{"_id": 2, "a": False}], + msg="$eq false does NOT match int 0", + ), + QueryTestCase( + id="zero_does_not_match_false", + filter={"a": {"$eq": 0}}, + doc=[{"_id": 1, "a": False}, {"_id": 2, "a": 0}], + expected=[{"_id": 2, "a": 0}], + msg="$eq int 0 does NOT match bool false", + ), + QueryTestCase( + id="true_does_not_match_one", + filter={"a": {"$eq": True}}, + doc=[{"_id": 1, "a": 1}, {"_id": 2, "a": True}], + expected=[{"_id": 2, "a": True}], + msg="$eq true does NOT match int 1", + ), + QueryTestCase( + id="one_does_not_match_true", + filter={"a": {"$eq": 1}}, + doc=[{"_id": 1, "a": True}, {"_id": 2, "a": 1}], + expected=[{"_id": 2, "a": 1}], + msg="$eq int 1 does NOT match bool true", + ), + QueryTestCase( + id="empty_string_does_not_match_null", + filter={"a": {"$eq": ""}}, + doc=[{"_id": 1, "a": None}, {"_id": 2, "a": ""}], + expected=[{"_id": 2, "a": ""}], + msg="$eq empty string does NOT match null", + ), + QueryTestCase( + id="null_does_not_match_zero", + filter={"a": {"$eq": None}}, + doc=[{"_id": 1, "a": 0}, {"_id": 2, "a": None}], + expected=[{"_id": 2, "a": None}], + msg="$eq null does NOT match int 0", + ), + QueryTestCase( + id="null_does_not_match_false", + filter={"a": {"$eq": None}}, + doc=[{"_id": 1, "a": False}, {"_id": 2, "a": None}], + expected=[{"_id": 2, "a": None}], + msg="$eq null does NOT match bool false", + ), + QueryTestCase( + id="string_does_not_match_objectid", + filter={"a": {"$eq": "507f1f77bcf86cd799439011"}}, + doc=[ + {"_id": 1, "a": ObjectId("507f1f77bcf86cd799439011")}, + {"_id": 2, "a": "507f1f77bcf86cd799439011"}, + ], + expected=[{"_id": 2, "a": "507f1f77bcf86cd799439011"}], + msg="$eq string does NOT match an ObjectId with the same hex value", + ), + QueryTestCase( + id="number_does_not_match_numeric_string", + filter={"a": {"$eq": 1}}, + doc=[{"_id": 1, "a": "1"}, {"_id": 2, "a": 1}], + expected=[{"_id": 2, "a": 1}], + msg="$eq int 1 does NOT match the string '1'", + ), + QueryTestCase( + id="numeric_string_does_not_match_number", + filter={"a": {"$eq": "1"}}, + doc=[{"_id": 1, "a": 1}, {"_id": 2, "a": "1"}], + expected=[{"_id": 2, "a": "1"}], + msg="$eq string '1' does NOT match the int 1", + ), + QueryTestCase( + id="bindata_subtype_distinct", + filter={"a": {"$eq": Binary(b"\x01\x02\x03", 0)}}, + doc=[ + {"_id": 1, "a": Binary(b"\x01\x02\x03", 0)}, + {"_id": 2, "a": Binary(b"\x01\x02\x03", 128)}, + ], + expected=[{"_id": 1, "a": b"\x01\x02\x03"}], + msg="$eq BinData matches only same subtype, not identical bytes with a different subtype", + ), +] + + +DATE_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="date_epoch", + filter={"a": {"$eq": DATE_EPOCH}}, + doc=[{"_id": 1, "a": DATE_EPOCH}, {"_id": 2, "a": DATE_Y2K}], + expected=[{"_id": 1, "a": DATE_EPOCH}], + msg="$eq with the Unix epoch (DATE_EPOCH) matches the same instant", + ), + QueryTestCase( + id="date_before_epoch", + filter={"a": {"$eq": DATE_BEFORE_EPOCH}}, + doc=[{"_id": 1, "a": DATE_BEFORE_EPOCH}, {"_id": 2, "a": DATE_EPOCH}], + expected=[{"_id": 1, "a": DATE_BEFORE_EPOCH}], + msg="$eq with a pre-epoch date (negative ms since epoch) matches the same instant", + ), + QueryTestCase( + id="date_year_1_boundary", + filter={"a": {"$eq": DATE_YEAR_1}}, + doc=[{"_id": 1, "a": DATE_YEAR_1}, {"_id": 2, "a": DATE_YEAR_9999}], + expected=[{"_id": 1, "a": DATE_YEAR_1}], + msg="$eq matches the minimum-year (year 1) date boundary", + ), + QueryTestCase( + id="date_year_9999_boundary", + filter={"a": {"$eq": DATE_YEAR_9999}}, + doc=[{"_id": 1, "a": DATE_YEAR_9999}, {"_id": 2, "a": DATE_YEAR_1}], + expected=[{"_id": 1, "a": DATE_YEAR_9999}], + msg="$eq matches the maximum-year (year 9999) date boundary", + ), + QueryTestCase( + id="date_millisecond_precision", + filter={"a": {"$eq": datetime(2024, 1, 1, 0, 0, 0, 1000, tzinfo=timezone.utc)}}, + doc=[ + {"_id": 1, "a": datetime(2024, 1, 1, 0, 0, 0, 1000, tzinfo=timezone.utc)}, + {"_id": 2, "a": datetime(2024, 1, 1, 0, 0, 0, 2000, tzinfo=timezone.utc)}, + ], + expected=[{"_id": 1, "a": datetime(2024, 1, 1, 0, 0, 0, 1000, tzinfo=timezone.utc)}], + msg="$eq on date is millisecond-precise: matches 1ms but not a 2ms-apart instant", + ), + QueryTestCase( + id="date_does_not_match_timestamp", + filter={"a": {"$eq": DATE_Y2K}}, + doc=[ + {"_id": 1, "a": Timestamp(int(DATE_Y2K.timestamp()), 1)}, + {"_id": 2, "a": DATE_Y2K}, + ], + expected=[{"_id": 2, "a": DATE_Y2K}], + msg="$eq date does NOT match a Timestamp at the same instant (distinct BSON types)", + ), + QueryTestCase( + id="date_does_not_match_objectid", + filter={"a": {"$eq": DATE_Y2K}}, + doc=[ + {"_id": 1, "a": ObjectId.from_datetime(DATE_Y2K)}, + {"_id": 2, "a": DATE_Y2K}, + ], + expected=[{"_id": 2, "a": DATE_Y2K}], + msg="$eq date does NOT match an ObjectId whose embedded timestamp is the same instant", + ), +] + + +ALL_TESTS = BSON_TYPE_TESTS + NUMERIC_CROSS_TYPE_TESTS + TYPE_DISTINCTION_TESTS + DATE_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_eq_bson_wiring(collection, test): + """Parametrized test for $eq BSON type wiring, numeric cross-type, and type distinction.""" + 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/eq/test_eq_edge_cases.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/eq/test_eq_edge_cases.py new file mode 100644 index 000000000..40a355f32 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/eq/test_eq_edge_cases.py @@ -0,0 +1,75 @@ +""" +Edge case tests for $eq operator. + +Covers ObjectId on _id, deeply nested documents, large arrays, and NaN on a +subdocument path. +""" + +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, assertSuccessNaN +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import FLOAT_NAN + +_OID = ObjectId("507f1f77bcf86cd799439011") + +_DEEPLY_NESTED_DOC: dict = {"level": 1} +for _ in range(2, 11): + _DEEPLY_NESTED_DOC = {"level": _DEEPLY_NESTED_DOC} + +_LARGE_ARRAY = list(range(1000)) + +TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="id_objectid", + filter={"_id": {"$eq": _OID}}, + doc=[{"_id": _OID, "a": 1}, {"_id": ObjectId(), "a": 2}], + expected=[{"_id": _OID, "a": 1}], + msg="$eq on _id with ObjectId", + ), + QueryTestCase( + id="deeply_nested_document", + filter={"a": {"$eq": _DEEPLY_NESTED_DOC}}, + doc=[{"_id": 1, "a": _DEEPLY_NESTED_DOC}, {"_id": 2, "a": {"x": 1}}], + expected=[{"_id": 1, "a": _DEEPLY_NESTED_DOC}], + msg="$eq with deeply nested document matches", + ), + QueryTestCase( + id="large_array", + filter={"a": {"$eq": _LARGE_ARRAY}}, + doc=[{"_id": 1, "a": _LARGE_ARRAY}, {"_id": 2, "a": [1]}], + expected=[{"_id": 1, "a": _LARGE_ARRAY}], + msg="$eq with large array matches same array", + ), +] + +NAN_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="subdocument_nan", + filter={"a.b": {"$eq": FLOAT_NAN}}, + doc=[{"_id": 1, "a": {"b": FLOAT_NAN}}, {"_id": 2, "a": {"b": 1}}], + expected=[{"_id": 1, "a": {"b": FLOAT_NAN}}], + msg="$eq on subdocument with NaN matches only NaN document", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(TESTS)) +def test_eq_edge_cases(collection, test): + """Parametrized test for $eq 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) + + +@pytest.mark.parametrize("test", pytest_params(NAN_TESTS)) +def test_eq_edge_cases_nan(collection, test): + """Parametrized test for $eq edge cases involving NaN.""" + collection.insert_many(test.doc) + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertSuccessNaN(result, test.expected, ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/comparison/eq/test_eq_field_lookup.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/eq/test_eq_field_lookup.py new file mode 100644 index 000000000..30774943a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/eq/test_eq_field_lookup.py @@ -0,0 +1,208 @@ +""" +Tests for $eq field lookup patterns, null/missing handling, and boundary values. + +Covers dot notation, array/object indexing, null vs missing semantics, +and _id and string boundary 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 + +FIELD_LOOKUP_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="nested_field", + filter={"a.b": {"$eq": 1}}, + doc=[{"_id": 1, "a": {"b": 1}}, {"_id": 2, "a": {"b": 2}}], + expected=[{"_id": 1, "a": {"b": 1}}], + msg="$eq on nested field using dot notation", + ), + QueryTestCase( + id="deep_nested_field", + filter={"a.b.c": {"$eq": 1}}, + doc=[{"_id": 1, "a": {"b": {"c": 1}}}, {"_id": 2, "a": {"b": {"c": 2}}}], + expected=[{"_id": 1, "a": {"b": {"c": 1}}}], + msg="$eq on deep nested field", + ), + QueryTestCase( + id="array_element_match", + filter={"tags": {"$eq": "B"}}, + doc=[{"_id": 1, "tags": ["A", "B", "C"]}, {"_id": 2, "tags": ["D", "E"]}], + expected=[{"_id": 1, "tags": ["A", "B", "C"]}], + msg="$eq on array field matches if any element equals value", + ), + QueryTestCase( + id="array_index_zero", + filter={"arr.0": {"$eq": 10}}, + doc=[{"_id": 1, "arr": [10, 20]}, {"_id": 2, "arr": [30, 40]}], + expected=[{"_id": 1, "arr": [10, 20]}], + msg="$eq on array index 0", + ), + QueryTestCase( + id="array_index_one", + filter={"arr.1": {"$eq": 20}}, + doc=[{"_id": 1, "arr": [10, 20]}, {"_id": 2, "arr": [30, 40]}], + expected=[{"_id": 1, "arr": [10, 20]}], + msg="$eq on array index 1", + ), + QueryTestCase( + id="array_of_objects", + filter={"a.b": {"$eq": 1}}, + doc=[{"_id": 1, "a": [{"b": 1}, {"b": 2}]}, {"_id": 2, "a": [{"b": 3}]}], + expected=[{"_id": 1, "a": [{"b": 1}, {"b": 2}]}], + msg="$eq on array of objects matches if any element's field equals value", + ), + QueryTestCase( + id="numeric_index_on_array", + filter={"a.0.b": {"$eq": 1}}, + doc=[{"_id": 1, "a": [{"b": 1}, {"b": 2}]}, {"_id": 2, "a": [{"b": 3}]}], + expected=[{"_id": 1, "a": [{"b": 1}, {"b": 2}]}], + msg="$eq with numeric index path on array", + ), + QueryTestCase( + id="numeric_key_on_object", + filter={"a.0.b": {"$eq": 1}}, + doc=[{"_id": 1, "a": {"0": {"b": 1}}}, {"_id": 2, "a": {"0": {"b": 2}}}], + expected=[{"_id": 1, "a": {"0": {"b": 1}}}], + msg="$eq with numeric key on object (not array)", + ), + QueryTestCase( + id="subdocument_different_field", + filter={"a.x": {"$eq": 1}}, + doc=[{"_id": 1, "a": {"x": 1}}, {"_id": 2, "a": {"y": 1}}], + expected=[{"_id": 1, "a": {"x": 1}}], + msg="$eq on subdocument with different field name", + ), + QueryTestCase( + id="dollar_prefixed_field_name", + filter={"a": {"$eq": {"$type": "special"}}}, + doc=[{"_id": 1, "a": {"$type": "special"}}, {"_id": 2, "a": {"$type": "other"}}], + expected=[{"_id": 1, "a": {"$type": "special"}}], + msg="$eq with dollar-prefixed keys in query value is allowed", + ), + QueryTestCase( + id="array_element_no_match", + filter={"tags": {"$eq": "D"}}, + doc=[{"_id": 1, "tags": ["A", "B", "C"]}], + expected=[], + msg="$eq on array field no match when element not present", + ), + QueryTestCase( + id="array_of_objects_no_match", + filter={"a.b": {"$eq": 3}}, + doc=[{"_id": 1, "a": [{"b": 1}, {"b": 2}]}], + expected=[], + msg="$eq on array of objects no match", + ), + QueryTestCase( + id="nonexistent_field_with_null", + filter={"missing": {"$eq": None}}, + doc=[{"_id": 1, "a": 1}], + expected=[{"_id": 1, "a": 1}], + msg="$eq on non-existent field with null matches", + ), + QueryTestCase( + id="nonexistent_field_with_value", + filter={"missing": {"$eq": 1}}, + doc=[{"_id": 1, "a": 1}], + expected=[], + msg="$eq on non-existent field with non-null does not match", + ), +] + +NULL_MISSING_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="null_matches_both_null_and_missing", + filter={"a": {"$eq": None}}, + doc=[{"_id": 1, "a": None}, {"_id": 2}, {"_id": 3, "a": 1}], + expected=[{"_id": 1, "a": None}, {"_id": 2}], + msg="$eq null matches both null and missing fields", + ), + QueryTestCase( + id="null_does_not_match_non_null", + filter={"a": {"$eq": None}}, + doc=[{"_id": 1, "a": 0}, {"_id": 2, "a": ""}, {"_id": 3, "a": False}], + expected=[], + msg="$eq null does NOT match existing non-null field", + ), + QueryTestCase( + id="nested_null_matches_missing_leaf_under_present_parent", + filter={"a.b": {"$eq": None}}, + doc=[ + {"_id": 1, "a": {}}, + {"_id": 2, "a": {"x": 1}}, + {"_id": 3, "a": {"b": None}}, + {"_id": 4, "a": {"b": 1}}, + ], + expected=[ + {"_id": 1, "a": {}}, + {"_id": 2, "a": {"x": 1}}, + {"_id": 3, "a": {"b": None}}, + ], + msg="$eq null on dot path matches a missing leaf or explicit null, not a non-null value", + ), + QueryTestCase( + id="nested_null_matches_array_element_missing_key", + filter={"a.b": {"$eq": None}}, + doc=[ + {"_id": 1, "a": [{"b": 1}, {"c": 2}]}, + {"_id": 2, "a": [{"b": 1}, {"b": 2}]}, + ], + expected=[{"_id": 1, "a": [{"b": 1}, {"c": 2}]}], + msg="$eq null on dot path into array-of-objects matches when any element lacks the key", + ), +] + +EDGE_CASE_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="id_compound_document", + filter={"_id": {"$eq": {"a": 1, "b": 2}}}, + doc=[{"_id": {"a": 1, "b": 2}, "x": 1}, {"_id": {"a": 3, "b": 4}, "x": 2}], + expected=[{"_id": {"a": 1, "b": 2}, "x": 1}], + msg="$eq on _id with compound document", + ), + QueryTestCase( + id="id_with_null", + filter={"_id": {"$eq": None}}, + doc=[{"_id": None, "a": 1}, {"_id": 1, "a": 2}], + expected=[{"_id": None, "a": 1}], + msg="$eq on _id with null — matches documents with _id: null only", + ), + QueryTestCase( + id="long_string", + filter={"a": {"$eq": "x" * 10000}}, + doc=[{"_id": 1, "a": "x" * 10000}], + expected=[{"_id": 1, "a": "x" * 10000}], + msg="$eq with very long string matches same string", + ), + QueryTestCase( + id="eq_with_empty_string", + filter={"a": {"$eq": ""}}, + doc=[{"_id": 1, "a": ""}, {"_id": 2, "a": "x"}], + expected=[{"_id": 1, "a": ""}], + msg="$eq with empty string is valid", + ), + QueryTestCase( + id="eq_with_nested_empty_object", + filter={"a": {"$eq": {"b": {}}}}, + doc=[{"_id": 1, "a": {"b": {}}}, {"_id": 2, "a": {"b": 1}}], + expected=[{"_id": 1, "a": {"b": {}}}], + msg="$eq with nested empty object is valid", + ), +] + + +ALL_TESTS = FIELD_LOOKUP_TESTS + NULL_MISSING_TESTS + EDGE_CASE_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_eq_field_lookup(collection, test): + """Parametrized test for $eq field lookup, null/missing handling, and boundary values.""" + 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/eq/test_eq_numeric_edge_cases.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/eq/test_eq_numeric_edge_cases.py new file mode 100644 index 000000000..8b985c6c4 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/eq/test_eq_numeric_edge_cases.py @@ -0,0 +1,138 @@ +""" +Tests for $eq numeric edge cases. + +Covers precision-loss boundaries near 2^53, infinity matching (including +cross-type double vs Decimal128), double and Decimal128 negative-zero +equivalence, and Decimal128 precision (MAX/MIN and decimal-vs-double 0.1). +""" + +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_MAX, + DECIMAL128_MIN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DOUBLE_MAX_SAFE_INTEGER, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_PRECISION_LOSS, + FLOAT_INFINITY, +) + +PRECISION_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="double_safe_integer_matches_only_equal_long", + filter={"a": {"$eq": float(DOUBLE_MAX_SAFE_INTEGER)}}, + doc=[ + {"_id": 1, "a": Int64(DOUBLE_MAX_SAFE_INTEGER)}, + {"_id": 2, "a": Int64(DOUBLE_PRECISION_LOSS)}, + ], + expected=[{"_id": 1, "a": Int64(DOUBLE_MAX_SAFE_INTEGER)}], + msg="$eq double(2^53) matches long(2^53) but NOT long(2^53+1) — precision-aware equality", + ), + QueryTestCase( + id="long_precision_loss_matched_only_by_exact_long", + filter={"a": {"$eq": Int64(DOUBLE_PRECISION_LOSS)}}, + doc=[ + {"_id": 1, "a": float(DOUBLE_MAX_SAFE_INTEGER)}, + {"_id": 2, "a": Int64(DOUBLE_PRECISION_LOSS)}, + ], + expected=[{"_id": 2, "a": Int64(DOUBLE_PRECISION_LOSS)}], + msg="$eq long(2^53+1) matches the exact long, not double(2^53) which cannot represent it", + ), +] + +INFINITY_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="infinity_matches_double_infinity", + filter={"a": {"$eq": FLOAT_INFINITY}}, + doc=[ + {"_id": 1, "a": FLOAT_INFINITY}, + {"_id": 2, "a": -FLOAT_INFINITY}, + {"_id": 3, "a": 1.0}, + ], + expected=[{"_id": 1, "a": FLOAT_INFINITY}], + msg="$eq Infinity matches double Infinity only (not -Infinity or finite values)", + ), + QueryTestCase( + id="infinity_matches_decimal_infinity_cross_type", + filter={"a": {"$eq": FLOAT_INFINITY}}, + doc=[ + {"_id": 1, "a": DECIMAL128_INFINITY}, + {"_id": 2, "a": DECIMAL128_NEGATIVE_INFINITY}, + ], + expected=[{"_id": 1, "a": DECIMAL128_INFINITY}], + msg="$eq double Infinity matches Decimal128 Infinity (cross-type numeric equivalence)", + ), + QueryTestCase( + id="negative_infinity_distinct_from_positive_infinity", + filter={"a": {"$eq": -FLOAT_INFINITY}}, + doc=[ + {"_id": 1, "a": FLOAT_INFINITY}, + {"_id": 2, "a": -FLOAT_INFINITY}, + ], + expected=[{"_id": 2, "a": -FLOAT_INFINITY}], + msg="$eq -Infinity does NOT match +Infinity", + ), +] + +NEGATIVE_ZERO_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="positive_zero_matches_negative_zero", + filter={"a": {"$eq": 0.0}}, + doc=[{"_id": 1, "a": DOUBLE_NEGATIVE_ZERO}, {"_id": 2, "a": 1.0}], + expected=[{"_id": 1, "a": DOUBLE_NEGATIVE_ZERO}], + msg="$eq 0.0 matches stored -0.0 (negative zero equals positive zero)", + ), + QueryTestCase( + id="positive_zero_matches_decimal128_negative_zero", + filter={"a": {"$eq": 0.0}}, + doc=[{"_id": 1, "a": DECIMAL128_NEGATIVE_ZERO}, {"_id": 2, "a": Int64(1)}], + expected=[{"_id": 1, "a": DECIMAL128_NEGATIVE_ZERO}], + msg="$eq 0.0 matches stored Decimal128 -0 (negative zero equals positive zero, cross-type)", + ), +] + + +DECIMAL128_PRECISION_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="decimal128_max_exact_match", + filter={"a": {"$eq": DECIMAL128_MAX}}, + doc=[{"_id": 1, "a": DECIMAL128_MAX}, {"_id": 2, "a": Decimal128("1")}], + expected=[{"_id": 1, "a": DECIMAL128_MAX}], + msg="$eq matches the maximum representable Decimal128 value exactly", + ), + QueryTestCase( + id="decimal128_min_exact_match", + filter={"a": {"$eq": DECIMAL128_MIN}}, + doc=[{"_id": 1, "a": DECIMAL128_MIN}, {"_id": 2, "a": Decimal128("-1")}], + expected=[{"_id": 1, "a": DECIMAL128_MIN}], + msg="$eq matches the minimum (most negative) representable Decimal128 value exactly", + ), + QueryTestCase( + id="decimal128_point_one_distinct_from_double_point_one", + filter={"a": {"$eq": Decimal128("0.1")}}, + doc=[{"_id": 1, "a": 0.1}, {"_id": 2, "a": Decimal128("0.1")}], + expected=[{"_id": 2, "a": Decimal128("0.1")}], + msg="$eq Decimal128('0.1') does NOT match double 0.1 (distinct exact values)", + ), +] + + +ALL_TESTS = PRECISION_TESTS + INFINITY_TESTS + NEGATIVE_ZERO_TESTS + DECIMAL128_PRECISION_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_eq_numeric_edge_cases(collection, test): + """Parametrized test for $eq 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/eq/test_eq_operand_handling.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/eq/test_eq_operand_handling.py new file mode 100644 index 000000000..b7465bc6c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/eq/test_eq_operand_handling.py @@ -0,0 +1,96 @@ +""" +Tests for $eq operand handling. + +Covers $eq treating its operand as a literal BSON value (never an operator +expression or field reference), $eq rejected as a top-level operator, and +equivalence of the explicit {$eq: v} and implicit {a: v} query forms. +""" + +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 + +LITERAL_OPERAND_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="dollar_prefixed_string_is_literal", + filter={"a": {"$eq": "$other"}}, + doc=[{"_id": 1, "a": "$other"}, {"_id": 2, "a": "literal"}], + expected=[{"_id": 1, "a": "$other"}], + msg="$eq with a $-prefixed string matches the literal string, not a field reference", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(LITERAL_OPERAND_TESTS)) +def test_eq_literal_operand(collection, test): + """Parametrized test for $eq literal-operand safety.""" + collection.insert_many(test.doc) + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertSuccess(result, test.expected, ignore_doc_order=True) + + +def test_eq_at_query_root_errors(collection): + """Test $eq as a top-level operator (no field) fails with BAD_VALUE.""" + collection.insert_many([{"_id": 1, "a": 1}]) + result = execute_command(collection, {"find": collection.name, "filter": {"$eq": 5}}) + assertFailureCode( + result, + BAD_VALUE_ERROR, + msg="$eq at query root is an unknown top-level operator", + ) + + +# (id, docs, value, expected) — the explicit {a: {$eq: value}} and implicit +# {a: value} forms must both return `expected` for any non-regex value. Each form +# is asserted independently (one assertion per test) against the same expected set, +# which establishes that the two forms are equivalent. +IMPLICIT_EQUIVALENCE_CASES = [ + ("scalar_int", [{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], 1, [{"_id": 1, "a": 1}]), + ("string", [{"_id": 1, "a": "x"}, {"_id": 2, "a": "y"}], "x", [{"_id": 1, "a": "x"}]), + ("bool", [{"_id": 1, "a": True}, {"_id": 2, "a": False}], True, [{"_id": 1, "a": True}]), + ( + "null_and_missing", + [{"_id": 1, "a": None}, {"_id": 2}, {"_id": 3, "a": 1}], + None, + [{"_id": 1, "a": None}, {"_id": 2}], + ), + ( + "array_exact", + [{"_id": 1, "a": [1, 2]}, {"_id": 2, "a": [1, 2, 3]}], + [1, 2], + [{"_id": 1, "a": [1, 2]}], + ), + ( + "embedded_document", + [{"_id": 1, "a": {"b": 1}}, {"_id": 2, "a": {"b": 2}}], + {"b": 1}, + [{"_id": 1, "a": {"b": 1}}], + ), +] + + +def _equivalence_params(): + params = [] + for case_id, docs, value, expected in IMPLICIT_EQUIVALENCE_CASES: + params.append(pytest.param({"a": {"$eq": value}}, docs, expected, id=f"{case_id}_explicit")) + params.append(pytest.param({"a": value}, docs, expected, id=f"{case_id}_implicit")) + return params + + +@pytest.mark.parametrize("filter, docs, expected", _equivalence_params()) +def test_eq_implicit_form_equivalence(collection, filter, docs, expected): + """Explicit {a: {$eq: v}} and implicit {a: v} forms return the same result set.""" + collection.insert_many(docs) + result = execute_command(collection, {"find": collection.name, "filter": filter}) + assertSuccess( + result, + expected, + ignore_doc_order=True, + msg="explicit $eq and implicit-equality forms must return identical results", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/comparison/eq/test_eq_regex.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/eq/test_eq_regex.py new file mode 100644 index 000000000..79214a70a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/eq/test_eq_regex.py @@ -0,0 +1,97 @@ +""" +Tests for $eq regular expression behavior. + +Covers the key difference: explicit $eq matches only stored regex objects, +while implicit regex performs a pattern match against strings. +""" + +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 assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="regex_matches_stored_regex", + filter={"a": {"$eq": Regex("abc")}}, + doc=[{"_id": 1, "a": Regex("abc")}, {"_id": 2, "a": "abc"}], + expected=[{"_id": 1, "a": Regex("abc")}], + msg="$eq with regex matches stored regex object with same pattern and flags", + ), + QueryTestCase( + id="case_insensitive_matches_stored_with_same_flags", + filter={"a": {"$eq": Regex("abc", "i")}}, + doc=[{"_id": 1, "a": Regex("abc", "i")}, {"_id": 2, "a": Regex("abc")}], + expected=[{"_id": 1, "a": Regex("abc", "i")}], + msg="$eq with case-insensitive regex matches stored regex with same flags", + ), + QueryTestCase( + id="implicit_regex_matches_string_substring", + filter={"a": Regex("abc")}, + doc=[{"_id": 1, "a": "abcdef"}, {"_id": 2, "a": "xyz"}], + expected=[{"_id": 1, "a": "abcdef"}], + msg="Implicit regex matches string containing pattern", + ), + QueryTestCase( + id="implicit_regex_anchored", + filter={"a": Regex("^abc$")}, + doc=[{"_id": 1, "a": "abc"}, {"_id": 2, "a": "abcdef"}], + expected=[{"_id": 1, "a": "abc"}], + msg="Implicit regex with anchors matches exact string", + ), + QueryTestCase( + id="regex_does_not_match_string", + filter={"a": {"$eq": Regex("abc")}}, + doc=[{"_id": 1, "a": "abc"}], + expected=[], + msg="$eq with regex does NOT match string value", + ), + QueryTestCase( + id="case_insensitive_does_not_match_string", + filter={"a": {"$eq": Regex("abc", "i")}}, + doc=[{"_id": 1, "a": "ABC"}], + expected=[], + msg="$eq with case-insensitive regex does NOT match string", + ), + QueryTestCase( + id="flag_mismatch_no_match", + filter={"a": {"$eq": Regex("abc")}}, + doc=[{"_id": 1, "a": Regex("abc", "i")}], + expected=[], + msg="$eq with regex does NOT match stored regex with different flags", + ), + QueryTestCase( + id="different_pattern_no_match", + filter={"a": {"$eq": Regex("abc")}}, + doc=[{"_id": 1, "a": Regex("abcd")}], + expected=[], + msg="$eq with regex does NOT match stored regex with different pattern", + ), + QueryTestCase( + id="implicit_regex_matches_string_and_equal_stored_regex", + filter={"a": Regex("abc")}, + doc=[{"_id": 1, "a": "abc"}, {"_id": 2, "a": Regex("abc")}], + expected=[{"_id": 1, "a": "abc"}, {"_id": 2, "a": Regex("abc")}], + msg="Implicit regex matches a pattern-matching string and an equal stored regex", + ), + QueryTestCase( + id="implicit_regex_does_not_match_number", + filter={"a": Regex("123")}, + doc=[{"_id": 1, "a": 123}], + expected=[], + msg="Implicit regex does NOT match non-string types", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(TESTS)) +def test_eq_regex(collection, test): + """Parametrized test for $eq regex behavior.""" + 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/eq/test_eq_string.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/eq/test_eq_string.py new file mode 100644 index 000000000..9f2a3e246 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/eq/test_eq_string.py @@ -0,0 +1,76 @@ +""" +Tests for $eq string-equality subtleties. + +Covers case sensitivity, multibyte UTF-8, absence of Unicode normalization, +embedded null bytes, and whitespace significance — all under plain (non-regex) +byte-wise string equality. +""" + +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 + +STRING_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="case_sensitive", + filter={"a": {"$eq": "abc"}}, + doc=[{"_id": 1, "a": "abc"}, {"_id": 2, "a": "ABC"}], + expected=[{"_id": 1, "a": "abc"}], + msg="$eq string equality is case-sensitive: 'abc' does NOT match 'ABC'", + ), + QueryTestCase( + id="multibyte_utf8", + filter={"a": {"$eq": "caf\u00e9\u2603\U0001d7d9"}}, + doc=[ + {"_id": 1, "a": "caf\u00e9\u2603\U0001d7d9"}, + {"_id": 2, "a": "cafe\u2603\U0001d7d9"}, + ], + expected=[{"_id": 1, "a": "caf\u00e9\u2603\U0001d7d9"}], + msg="$eq matches a string with 2/3/4-byte UTF-8 characters exactly", + ), + QueryTestCase( + id="no_unicode_normalization", + filter={"a": {"$eq": "\u00e9"}}, + doc=[{"_id": 1, "a": "\u00e9"}, {"_id": 2, "a": "e\u0301"}], + expected=[{"_id": 1, "a": "\u00e9"}], + msg="$eq does NOT normalize Unicode: precomposed U+00E9 != decomposed 'e'+U+0301", + ), + QueryTestCase( + id="embedded_null_byte", + filter={"a": {"$eq": "a\x00b"}}, + doc=[ + {"_id": 1, "a": "a\x00b"}, + {"_id": 2, "a": "ab"}, + {"_id": 3, "a": "a"}, + ], + expected=[{"_id": 1, "a": "a\x00b"}], + msg="$eq matches only the exact embedded-null-byte string, not the truncated forms", + ), + QueryTestCase( + id="whitespace_significant", + filter={"a": {"$eq": "a b"}}, + doc=[ + {"_id": 1, "a": "a b"}, + {"_id": 2, "a": "a b"}, + {"_id": 3, "a": "ab"}, + ], + expected=[{"_id": 1, "a": "a b"}], + msg="$eq treats whitespace as significant: 'a b' does not match 'a b' or 'ab'", + ), +] + + +ALL_TESTS = STRING_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_eq_string(collection, test): + """Parametrized test for $eq string-equality subtleties.""" + 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/eq/test_eq_value_matching.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/eq/test_eq_value_matching.py new file mode 100644 index 000000000..c39d7e577 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/eq/test_eq_value_matching.py @@ -0,0 +1,252 @@ +""" +Tests for $eq value matching — arrays and objects. + +Covers array exact/element matching, order and length sensitivity, dot-path +empty arrays, typed single-element arrays, and object field-order and shape +sensitivity. +""" + +from datetime import datetime, timezone + +import pytest +from bson import SON, Binary, Int64, 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 + +ARRAY_MATCHING_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="array_order_matters", + filter={"a": {"$eq": ["B", "A"]}}, + doc=[{"_id": 1, "a": ["A", "B"]}], + expected=[], + msg="$eq with array value — order matters", + ), + QueryTestCase( + id="array_no_partial_match", + filter={"a": {"$eq": ["A", "B"]}}, + doc=[{"_id": 1, "a": ["A", "B", "C"]}], + expected=[], + msg="$eq with array value does not partial match", + ), + QueryTestCase( + id="array_matches_nested_array_element", + filter={"a": {"$eq": ["A", "B"]}}, + doc=[{"_id": 1, "a": [["A", "B"], "C"]}], + expected=[{"_id": 1, "a": [["A", "B"], "C"]}], + msg="$eq with array value matches element that is an array", + ), + QueryTestCase( + id="array_query_on_scalar_no_match", + filter={"a": {"$eq": ["A", "B"]}}, + doc=[{"_id": 1, "a": "B"}, {"_id": 2, "a": "A"}], + expected=[], + msg="$eq with array value does NOT match scalar even if scalar is in array", + ), + QueryTestCase( + id="empty_array_matches_nested_empty_element", + filter={"a": {"$eq": []}}, + doc=[{"_id": 1, "a": [[]]}], + expected=[{"_id": 1, "a": [[]]}], + msg="$eq [] matches [[]] because [[]] contains element [] which equals []", + ), + QueryTestCase( + id="scalar_no_match_in_array", + filter={"a": {"$eq": 4}}, + doc=[{"_id": 1, "a": [1, 2, 3]}], + expected=[], + msg="$eq scalar no match when not in array", + ), + QueryTestCase( + id="scalar_on_nested_array_no_match", + filter={"a": {"$eq": 1}}, + doc=[{"_id": 1, "a": [[1, 2], [3, 4]]}], + expected=[], + msg="$eq scalar on array of arrays does NOT match (only one level traversal)", + ), + QueryTestCase( + id="null_on_empty_array_no_match", + filter={"a": {"$eq": None}}, + doc=[{"_id": 1, "a": []}], + expected=[], + msg="$eq null on empty array does NOT match", + ), + QueryTestCase( + id="array_different_length_longer", + filter={"a": {"$eq": [1, 2, 3]}}, + doc=[{"_id": 1, "a": [1, 2]}], + expected=[], + msg="$eq array [1,2,3] does not match [1,2]", + ), + QueryTestCase( + id="array_exact_match", + filter={"a": {"$eq": ["A", "B"]}}, + doc=[{"_id": 1, "a": ["A", "B"]}, {"_id": 2, "a": ["C", "D"]}], + expected=[{"_id": 1, "a": ["A", "B"]}], + msg="$eq with array value matches exact array", + ), + QueryTestCase( + id="empty_array", + filter={"a": {"$eq": []}}, + doc=[{"_id": 1, "a": []}, {"_id": 2, "a": [1]}], + expected=[{"_id": 1, "a": []}], + msg="$eq with empty array matches empty array", + ), + QueryTestCase( + id="scalar_matches_array_containing_scalar", + filter={"a": {"$eq": 1}}, + doc=[{"_id": 1, "a": [1, 2, 3]}, {"_id": 2, "a": [4, 5]}], + expected=[{"_id": 1, "a": [1, 2, 3]}], + msg="$eq scalar matches array containing that scalar", + ), + QueryTestCase( + id="array_on_array_of_arrays_matches", + filter={"a": {"$eq": [1]}}, + doc=[{"_id": 1, "a": [[1], [2]]}], + expected=[{"_id": 1, "a": [[1], [2]]}], + msg="$eq array on array of arrays matches top-level element", + ), + QueryTestCase( + id="null_on_array_containing_null", + filter={"a": {"$eq": None}}, + doc=[{"_id": 1, "a": [1, None, 3]}], + expected=[{"_id": 1, "a": [1, None, 3]}], + msg="$eq null on array containing null matches", + ), + QueryTestCase( + id="subdocument_with_array_cross_type", + filter={"a.b": {"$eq": 1}}, + doc=[{"_id": 1, "a": {"b": [1, 2, 3]}}, {"_id": 2, "a": {"b": [4, 5]}}], + expected=[{"_id": 1, "a": {"b": [1, 2, 3]}}], + msg="$eq on subdocument with array matches element", + ), + QueryTestCase( + id="array_of_subdocuments", + filter={"a": {"$eq": [{"x": 1}, {"x": 2}]}}, + doc=[{"_id": 1, "a": [{"x": 1}, {"x": 2}]}, {"_id": 2, "a": [{"x": 3}]}], + expected=[{"_id": 1, "a": [{"x": 1}, {"x": 2}]}], + msg="$eq with array of subdocuments matches exact array", + ), + QueryTestCase( + id="dot_path_empty_array", + filter={"a.b": {"$eq": []}}, + doc=[{"_id": 1, "a": {"b": []}}, {"_id": 2, "a": {"b": [1]}}], + expected=[{"_id": 1, "a": {"b": []}}], + msg="$eq [] on a dot path matches a stored empty array at that nested path", + ), + QueryTestCase( + id="single_element_int64_array", + filter={"a": {"$eq": [Int64(5)]}}, + doc=[{"_id": 1, "a": [Int64(5)]}, {"_id": 2, "a": [Int64(6)]}], + expected=[{"_id": 1, "a": [Int64(5)]}], + msg="$eq matches a single-element array with a long (Int64) element", + ), + QueryTestCase( + id="single_element_binary_array", + filter={"a": {"$eq": [Binary(b"\x01", 0)]}}, + doc=[{"_id": 1, "a": [Binary(b"\x01", 0)]}, {"_id": 2, "a": [Binary(b"\x02", 0)]}], + expected=[{"_id": 1, "a": [b"\x01"]}], + msg="$eq matches a single-element array with a BinData element", + ), + QueryTestCase( + id="single_element_date_array", + filter={"a": {"$eq": [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, tzinfo=timezone.utc)]}], + msg="$eq matches a single-element array with a date element", + ), + QueryTestCase( + id="single_element_objectid_array", + filter={"a": {"$eq": [ObjectId("507f1f77bcf86cd799439011")]}}, + doc=[ + {"_id": 1, "a": [ObjectId("507f1f77bcf86cd799439011")]}, + {"_id": 2, "a": [ObjectId("507f1f77bcf86cd799439012")]}, + ], + expected=[{"_id": 1, "a": [ObjectId("507f1f77bcf86cd799439011")]}], + msg="$eq matches a single-element array with an ObjectId element", + ), +] + +OBJECT_MATCHING_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="field_order_no_match", + filter={"a": {"$eq": SON([("y", 2), ("x", 1)])}}, + doc=[{"_id": 1, "a": SON([("x", 1), ("y", 2)])}], + expected=[], + msg="$eq document does NOT match with different field order", + ), + QueryTestCase( + id="extra_field_no_match", + filter={"a": {"$eq": {"x": 1, "y": 2}}}, + doc=[{"_id": 1, "a": {"x": 1, "y": 2, "z": 3}}], + expected=[], + msg="$eq document with extra field does NOT match", + ), + QueryTestCase( + id="missing_field_no_match", + filter={"a": {"$eq": {"x": 1, "y": 2, "z": 3}}}, + doc=[{"_id": 1, "a": {"x": 1, "y": 2}}], + expected=[], + msg="$eq document with missing field does NOT match", + ), + QueryTestCase( + id="nested_field_order_matters", + filter={"a": {"$eq": {"b": SON([("y", 2), ("x", 1)])}}}, + doc=[{"_id": 1, "a": {"b": SON([("x", 1), ("y", 2)])}}], + expected=[], + msg="$eq nested document field order matters", + ), + QueryTestCase( + id="nested_document_value_differs", + filter={"a": {"$eq": {"b": {"c": 2}}}}, + doc=[{"_id": 1, "a": {"b": {"c": 1}}}], + expected=[], + msg="$eq nested document does NOT match when nested value differs", + ), + QueryTestCase( + id="field_order_match", + filter={"a": {"$eq": SON([("x", 1), ("y", 2)])}}, + doc=[{"_id": 1, "a": SON([("x", 1), ("y", 2)])}], + expected=[{"_id": 1, "a": {"x": 1, "y": 2}}], + msg="$eq document matches with same field order", + ), + QueryTestCase( + id="nested_document", + filter={"a": {"$eq": {"b": {"c": 1}}}}, + doc=[{"_id": 1, "a": {"b": {"c": 1}}}, {"_id": 2, "a": {"b": {"c": 2}}}], + expected=[{"_id": 1, "a": {"b": {"c": 1}}}], + msg="$eq with nested document matches exact structure", + ), + QueryTestCase( + id="empty_document", + filter={"a": {"$eq": {}}}, + doc=[{"_id": 1, "a": {}}, {"_id": 2, "a": {"x": 1}}], + expected=[{"_id": 1, "a": {}}], + msg="$eq with empty document matches empty document", + ), + QueryTestCase( + id="dollar_prefixed_value", + filter={"a": {"$eq": {"$x": 1, "$y": 2}}}, + doc=[{"_id": 1, "a": {"$x": 1, "$y": 2}}], + expected=[{"_id": 1, "a": {"$x": 1, "$y": 2}}], + msg="$eq with dollar-prefixed keys in value matches stored document", + ), +] + + +ALL_TESTS = ARRAY_MATCHING_TESTS + OBJECT_MATCHING_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_eq_value_matching(collection, test): + """Parametrized test for $eq array and object 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 84cb4178c..1ce9300fe 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 @@ -4,6 +4,10 @@ Covers range combinations ($gt/$lt/$gte/$lte), range with exclusions ($ne/$nin), $in/$nin with comparisons, $not with comparisons, $or disjoint ranges, multi-field combinations, and array field interactions. + +Also covers $eq compound interactions: $eq with $and/$or/$nor/$elemMatch, +$not inversion of $eq, $eq alongside $exists/$type/$gt on the same field, +and $eq treating a nested operator document as a literal value. """ import pytest @@ -334,6 +338,166 @@ ] +EQ_LOGICAL_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="eq_with_and", + filter={"$and": [{"a": {"$eq": 1}}, {"b": {"$eq": 2}}]}, + doc=[{"_id": 1, "a": 1, "b": 2}, {"_id": 2, "a": 1, "b": 3}, {"_id": 3, "a": 2, "b": 2}], + expected=[{"_id": 1, "a": 1, "b": 2}], + msg="$eq combined with $and", + ), + QueryTestCase( + id="eq_with_or", + filter={"$or": [{"a": {"$eq": 1}}, {"a": {"$eq": 2}}]}, + doc=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}, {"_id": 3, "a": 3}], + expected=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + msg="$eq combined with $or", + ), + QueryTestCase( + id="eq_with_nor", + filter={"$nor": [{"a": {"$eq": 1}}]}, + doc=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}, {"_id": 3, "a": 3}], + expected=[{"_id": 2, "a": 2}, {"_id": 3, "a": 3}], + msg="$eq combined with $nor", + ), +] + +EQ_ELEMMATCH_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="eq_inside_elemmatch_scalar", + filter={"arr": {"$elemMatch": {"$eq": 1}}}, + doc=[{"_id": 1, "arr": [1, 2, 3]}, {"_id": 2, "arr": [4, 5]}], + expected=[{"_id": 1, "arr": [1, 2, 3]}], + msg="$eq inside $elemMatch on scalar array", + ), + QueryTestCase( + id="eq_inside_elemmatch_nested", + filter={"arr": {"$elemMatch": {"x": {"$eq": 1}}}}, + doc=[{"_id": 1, "arr": [{"x": 1}, {"x": 2}]}, {"_id": 2, "arr": [{"x": 3}]}], + expected=[{"_id": 1, "arr": [{"x": 1}, {"x": 2}]}], + msg="$eq inside $elemMatch on array of objects", + ), +] + +EQ_NOT_INVERSION_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="not_eq_matches_not_equal", + filter={"a": {"$not": {"$eq": 1}}}, + doc=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}, {"_id": 3, "a": 3}], + expected=[{"_id": 2, "a": 2}, {"_id": 3, "a": 3}], + msg="{$not: {$eq: 1}} matches documents where field != 1", + ), + QueryTestCase( + id="not_eq_matches_null_field", + filter={"a": {"$not": {"$eq": 1}}}, + doc=[{"_id": 1, "a": 1}, {"_id": 2, "a": None}], + expected=[{"_id": 2, "a": None}], + msg="{$not: {$eq: 1}} matches documents where field is null", + ), + QueryTestCase( + id="not_eq_matches_missing_field", + filter={"a": {"$not": {"$eq": 1}}}, + doc=[{"_id": 1, "a": 1}, {"_id": 2}], + expected=[{"_id": 2}], + msg="{$not: {$eq: 1}} matches documents where field is missing", + ), + QueryTestCase( + id="not_eq_null_matches_non_null", + filter={"a": {"$not": {"$eq": None}}}, + doc=[{"_id": 1, "a": 1}, {"_id": 2, "a": None}, {"_id": 3}], + expected=[{"_id": 1, "a": 1}], + msg="{$not: {$eq: null}} matches documents where field exists and is not null", + ), + QueryTestCase( + id="not_eq_null_does_not_match_null", + filter={"a": {"$not": {"$eq": None}}}, + doc=[{"_id": 1, "a": None}], + expected=[], + msg="{$not: {$eq: null}} does NOT match null field", + ), + QueryTestCase( + id="not_eq_null_does_not_match_missing", + filter={"a": {"$not": {"$eq": None}}}, + doc=[{"_id": 1}], + expected=[], + msg="{$not: {$eq: null}} does NOT match missing field", + ), +] + +EQ_COMBINATION_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="eq_with_exists_same_field", + filter={"a": {"$eq": 1, "$exists": True}}, + doc=[{"_id": 1, "a": 1}, {"_id": 2, "a": None}, {"_id": 3}], + expected=[{"_id": 1, "a": 1}], + msg="$eq combined with $exists on same field", + ), + QueryTestCase( + id="eq_nested_gt_operator_treated_as_literal", + filter={"a": {"$eq": {"$gt": 5}}}, + doc=[{"_id": 1, "a": {"$gt": 5}}, {"_id": 2, "a": 10}], + expected=[{"_id": 1, "a": {"$gt": 5}}], + msg="$eq nested $gt is a literal document match: matches stored {$gt:5}, not values > 5", + ), + QueryTestCase( + id="eq_nested_gt_operator_as_literal", + filter={"a": {"$eq": {"$gt": 1}}}, + doc=[{"_id": 1, "a": 1}, {"_id": 2, "a": {"$gt": 1}}], + expected=[{"_id": 2, "a": {"$gt": 1}}], + msg="$eq with nested $gt treats it as literal document match", + ), + QueryTestCase( + id="eq_nested_in_operator_as_literal", + filter={"a": {"$eq": {"$in": [1, 2]}}}, + doc=[{"_id": 1, "a": 1}], + expected=[], + msg="$eq with nested $in treats it as literal document match", + ), + QueryTestCase( + id="eq_nested_exists_operator_as_literal", + filter={"a": {"$eq": {"$exists": True}}}, + doc=[{"_id": 1, "a": 1}], + expected=[], + msg="$eq with $exists as value treats it as literal document match", + ), + QueryTestCase( + id="eq_on_two_different_fields", + filter={"a": {"$eq": 1}, "b": {"$eq": 2}}, + doc=[ + {"_id": 1, "a": 1, "b": 2}, + {"_id": 2, "a": 1, "b": 3}, + {"_id": 3, "a": 2, "b": 2}, + ], + expected=[{"_id": 1, "a": 1, "b": 2}], + msg="$eq on two different fields — implicit AND", + ), + QueryTestCase( + id="eq_on_three_fields", + filter={"a": {"$eq": 1}, "b": {"$eq": 2}, "c": {"$eq": 3}}, + doc=[ + {"_id": 1, "a": 1, "b": 2, "c": 3}, + {"_id": 2, "a": 1, "b": 2, "c": 4}, + ], + expected=[{"_id": 1, "a": 1, "b": 2, "c": 3}], + msg="$eq on three fields — all must match", + ), + QueryTestCase( + id="eq_with_gt_on_same_field", + filter={"a": {"$eq": 5, "$gt": 3}}, + doc=[{"_id": 1, "a": 5}, {"_id": 2, "a": 3}], + expected=[{"_id": 1, "a": 5}], + msg="$eq combined with $gt on same field", + ), + QueryTestCase( + id="eq_with_type_on_same_field", + filter={"a": {"$eq": 1, "$type": "int"}}, + doc=[{"_id": 1, "a": 1}, {"_id": 2, "a": "1"}], + expected=[{"_id": 1, "a": 1}], + msg="$eq combined with $type on same field", + ), +] + + ALL_TESTS = ( RANGE_TESTS + RANGE_EXCLUSION_TESTS @@ -343,6 +507,10 @@ + OR_DISJOINT_RANGE_TESTS + ARRAY_RANGE_TESTS + MISSING_PAIR_TESTS + + EQ_LOGICAL_TESTS + + EQ_ELEMMATCH_TESTS + + EQ_NOT_INVERSION_TESTS + + EQ_COMBINATION_TESTS ) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/comparison/test_comparison_nan_equality.py b/documentdb_tests/compatibility/tests/core/operator/query/comparison/test_comparison_nan_equality.py index 1c192673c..22536e2b6 100644 --- a/documentdb_tests/compatibility/tests/core/operator/query/comparison/test_comparison_nan_equality.py +++ b/documentdb_tests/compatibility/tests/core/operator/query/comparison/test_comparison_nan_equality.py @@ -32,6 +32,20 @@ expected=[{"_id": 1, "a": DECIMAL128_NAN}], msg="$eq matches Decimal128 NaN to Decimal128 NaN", ), + QueryTestCase( + id="eq_float_nan_matches_decimal128_nan", + filter={"a": {"$eq": FLOAT_NAN}}, + doc=[{"_id": 1, "a": DECIMAL128_NAN}, {"_id": 2, "a": Decimal128("1")}], + expected=[{"_id": 1, "a": DECIMAL128_NAN}], + msg="$eq matches float NaN query to stored Decimal128 NaN (cross-type NaN equality)", + ), + QueryTestCase( + id="eq_decimal128_nan_matches_float_nan", + filter={"a": {"$eq": DECIMAL128_NAN}}, + doc=[{"_id": 1, "a": FLOAT_NAN}, {"_id": 2, "a": 1}], + expected=[{"_id": 1, "a": FLOAT_NAN}], + msg="$eq matches Decimal128 NaN query to stored float NaN (cross-type NaN equality)", + ), QueryTestCase( id="gte_float_nan", filter={"a": {"$gte": FLOAT_NAN}},