diff --git a/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_collation.py b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_collation.py new file mode 100644 index 00000000..94aa8e5e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_collation.py @@ -0,0 +1,191 @@ +"""Tests for count command collation behavior.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.target_collection import CustomCollection + +# Property [Collation Behavior]: the collation option controls string comparison +# rules used during query evaluation. +COUNT_COLLATION_BEHAVIOR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "collation_explicit_equality", + docs=[{"_id": 1, "s": "abc"}, {"_id": 2, "s": "ABC"}, {"_id": 3, "s": "def"}], + command=lambda ctx: { + "count": ctx.collection, + "query": {"s": "abc"}, + "collation": {"locale": "en", "strength": 2}, + }, + expected={"n": 2, "ok": 1.0}, + msg="count with case-insensitive collation should match both cases in equality", + ), + CommandTestCase( + "collation_explicit_ne", + docs=[{"_id": 1, "s": "abc"}, {"_id": 2, "s": "ABC"}, {"_id": 3, "s": "def"}], + command=lambda ctx: { + "count": ctx.collection, + "query": {"s": {"$ne": "abc"}}, + "collation": {"locale": "en", "strength": 2}, + }, + expected={"n": 1, "ok": 1.0}, + msg="count collation should affect $ne operator", + ), + CommandTestCase( + "collation_explicit_in", + docs=[{"_id": 1, "s": "abc"}, {"_id": 2, "s": "ABC"}, {"_id": 3, "s": "def"}], + command=lambda ctx: { + "count": ctx.collection, + "query": {"s": {"$in": ["abc"]}}, + "collation": {"locale": "en", "strength": 2}, + }, + expected={"n": 2, "ok": 1.0}, + msg="count collation should affect $in operator", + ), + CommandTestCase( + "collation_explicit_nin", + docs=[{"_id": 1, "s": "abc"}, {"_id": 2, "s": "ABC"}, {"_id": 3, "s": "def"}], + command=lambda ctx: { + "count": ctx.collection, + "query": {"s": {"$nin": ["abc"]}}, + "collation": {"locale": "en", "strength": 2}, + }, + expected={"n": 1, "ok": 1.0}, + msg="count collation should affect $nin operator", + ), + CommandTestCase( + "collation_explicit_gt", + docs=[{"_id": 1, "s": "abc"}, {"_id": 2, "s": "ABC"}, {"_id": 3, "s": "def"}], + command=lambda ctx: { + "count": ctx.collection, + "query": {"s": {"$gt": "ABC"}}, + "collation": {"locale": "en", "strength": 2}, + }, + expected={"n": 1, "ok": 1.0}, + msg="count collation should affect $gt operator", + ), + CommandTestCase( + "collation_explicit_gte", + docs=[{"_id": 1, "s": "abc"}, {"_id": 2, "s": "ABC"}, {"_id": 3, "s": "def"}], + command=lambda ctx: { + "count": ctx.collection, + "query": {"s": {"$gte": "abc"}}, + "collation": {"locale": "en", "strength": 2}, + }, + expected={"n": 3, "ok": 1.0}, + msg="count collation should affect $gte operator", + ), + CommandTestCase( + "collation_explicit_lt", + docs=[{"_id": 1, "s": "abc"}, {"_id": 2, "s": "ABC"}, {"_id": 3, "s": "def"}], + command=lambda ctx: { + "count": ctx.collection, + "query": {"s": {"$lt": "abc"}}, + "collation": {"locale": "en", "strength": 2}, + }, + expected={"n": 0, "ok": 1.0}, + msg="count collation should affect $lt operator", + ), + CommandTestCase( + "collation_explicit_lte", + docs=[{"_id": 1, "s": "abc"}, {"_id": 2, "s": "ABC"}, {"_id": 3, "s": "def"}], + command=lambda ctx: { + "count": ctx.collection, + "query": {"s": {"$lte": "ABC"}}, + "collation": {"locale": "en", "strength": 2}, + }, + expected={"n": 2, "ok": 1.0}, + msg="count collation should affect $lte operator", + ), + CommandTestCase( + "collation_explicit_expr", + docs=[{"_id": 1, "s": "abc"}, {"_id": 2, "s": "ABC"}, {"_id": 3, "s": "def"}], + command=lambda ctx: { + "count": ctx.collection, + "query": {"$expr": {"$eq": ["$s", "abc"]}}, + "collation": {"locale": "en", "strength": 2}, + }, + expected={"n": 2, "ok": 1.0}, + msg="count collation should affect $expr in query", + ), + CommandTestCase( + "collation_regex_unaffected", + docs=[{"_id": 1, "s": "abc"}, {"_id": 2, "s": "ABC"}, {"_id": 3, "s": "def"}], + command=lambda ctx: { + "count": ctx.collection, + "query": {"s": {"$regex": "^abc$"}}, + "collation": {"locale": "en", "strength": 2}, + }, + expected={"n": 1, "ok": 1.0}, + msg="count collation should NOT affect $regex matching", + ), + CommandTestCase( + "collation_collection_default", + target_collection=CustomCollection(options={"collation": {"locale": "en", "strength": 2}}), + docs=[{"_id": 1, "s": "abc"}, {"_id": 2, "s": "ABC"}, {"_id": 3, "s": "def"}], + command=lambda ctx: { + "count": ctx.collection, + "query": {"s": "abc"}, + }, + expected={"n": 2, "ok": 1.0}, + msg="count should use collection default collation when collation is omitted", + ), + CommandTestCase( + "collation_null_uses_default", + target_collection=CustomCollection(options={"collation": {"locale": "en", "strength": 2}}), + docs=[{"_id": 1, "s": "abc"}, {"_id": 2, "s": "ABC"}, {"_id": 3, "s": "def"}], + command=lambda ctx: { + "count": ctx.collection, + "query": {"s": "abc"}, + "collation": None, + }, + expected={"n": 2, "ok": 1.0}, + msg="count with collation=null should use collection default collation", + ), + CommandTestCase( + "collation_empty_uses_default", + target_collection=CustomCollection(options={"collation": {"locale": "en", "strength": 2}}), + docs=[{"_id": 1, "s": "abc"}, {"_id": 2, "s": "ABC"}, {"_id": 3, "s": "def"}], + command=lambda ctx: { + "count": ctx.collection, + "query": {"s": "abc"}, + "collation": {}, + }, + expected={"n": 2, "ok": 1.0}, + msg="count with collation={} should use collection default collation", + ), + CommandTestCase( + "collation_simple_overrides_default", + target_collection=CustomCollection(options={"collation": {"locale": "en", "strength": 2}}), + docs=[{"_id": 1, "s": "abc"}, {"_id": 2, "s": "ABC"}, {"_id": 3, "s": "def"}], + command=lambda ctx: { + "count": ctx.collection, + "query": {"s": "abc"}, + "collation": {"locale": "simple"}, + }, + expected={"n": 1, "ok": 1.0}, + msg='count with collation locale "simple" should override collection default', + ), +] + + +@pytest.mark.parametrize("test", pytest_params(COUNT_COLLATION_BEHAVIOR_TESTS)) +def test_count_collation(database_client, collection, test): + """Test count command collation behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_collation_subfields.py b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_collation_subfields.py new file mode 100644 index 00000000..8bb0fdb3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_collation_subfields.py @@ -0,0 +1,695 @@ +"""Tests for count command collation sub-field validation.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +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_NAN, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + FLOAT_NEGATIVE_NAN, +) + +# Property [Type Strictness: collation (strength)]: the strength sub-field +# validates type and range. +COUNT_TYPE_STRICTNESS_COLLATION_STRENGTH_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "type_collation_strength_int32_valid", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "strength": 3}, + }, + expected={"n": 1, "ok": 1.0}, + msg="count should accept int32 strength value 3", + ), + CommandTestCase( + "type_collation_strength_int64_valid", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "strength": Int64(3)}, + }, + expected={"n": 1, "ok": 1.0}, + msg="count should accept Int64 strength value 3", + ), + CommandTestCase( + "type_collation_strength_double_valid", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "strength": 3.0}, + }, + expected={"n": 1, "ok": 1.0}, + msg="count should accept whole-number double strength value", + ), + CommandTestCase( + "type_collation_strength_decimal128_valid", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "strength": Decimal128("3")}, + }, + expected={"n": 1, "ok": 1.0}, + msg="count should accept Decimal128 strength value 3", + ), + CommandTestCase( + "type_collation_strength_null", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "strength": None}, + }, + expected={"n": 1, "ok": 1.0}, + msg="count should accept null for strength (treated as default)", + ), + CommandTestCase( + "type_collation_strength_fractional_double", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "strength": 2.5}, + }, + expected={"n": 1, "ok": 1.0}, + msg="count should accept fractional double 2.5 for strength (floor to 2)", + ), + CommandTestCase( + "type_collation_strength_one_min_valid", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "strength": 1}, + }, + expected={"n": 1, "ok": 1.0}, + msg="count should accept strength 1 (minimum valid value)", + ), + CommandTestCase( + "type_collation_strength_five_max_valid", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "strength": 5}, + }, + expected={"n": 1, "ok": 1.0}, + msg="count should accept strength 5 (maximum valid value)", + ), + CommandTestCase( + "type_collation_strength_zero", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "strength": 0}, + }, + error_code=BAD_VALUE_ERROR, + msg="count should reject strength 0", + ), + CommandTestCase( + "type_collation_strength_six", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "strength": 6}, + }, + error_code=BAD_VALUE_ERROR, + msg="count should reject strength 6 (above max)", + ), + CommandTestCase( + "type_collation_strength_negative", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "strength": -1}, + }, + error_code=BAD_VALUE_ERROR, + msg="count should reject negative strength value", + ), + CommandTestCase( + "type_collation_strength_fractional_floor_to_zero", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "strength": 0.9}, + }, + error_code=BAD_VALUE_ERROR, + msg="count should reject strength 0.9 (floor to 0, then invalid)", + ), + CommandTestCase( + "type_collation_strength_nan", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "strength": FLOAT_NAN}, + }, + error_code=BAD_VALUE_ERROR, + msg="count should reject NaN for strength (coerces to 0, then invalid)", + ), + CommandTestCase( + "type_collation_strength_neg_nan", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "strength": FLOAT_NEGATIVE_NAN}, + }, + error_code=BAD_VALUE_ERROR, + msg="count should reject -NaN double for strength", + ), + CommandTestCase( + "type_collation_strength_decimal128_nan", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "strength": DECIMAL128_NAN}, + }, + error_code=BAD_VALUE_ERROR, + msg="count should reject NaN Decimal128 for strength", + ), + CommandTestCase( + "type_collation_strength_decimal128_neg_nan", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "strength": DECIMAL128_NEGATIVE_NAN}, + }, + error_code=BAD_VALUE_ERROR, + msg="count should reject -NaN Decimal128 for strength", + ), + CommandTestCase( + "type_collation_strength_infinity", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "strength": FLOAT_INFINITY}, + }, + error_code=BAD_VALUE_ERROR, + msg="count should reject Infinity for strength (coerces to int32 max, then invalid)", + ), + CommandTestCase( + "type_collation_strength_neg_infinity", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "strength": FLOAT_NEGATIVE_INFINITY}, + }, + error_code=BAD_VALUE_ERROR, + msg="count should reject -Infinity double for strength", + ), + CommandTestCase( + "type_collation_strength_decimal128_infinity", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "strength": DECIMAL128_INFINITY}, + }, + error_code=BAD_VALUE_ERROR, + msg="count should reject Infinity Decimal128 for strength", + ), + CommandTestCase( + "type_collation_strength_decimal128_neg_infinity", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "strength": DECIMAL128_NEGATIVE_INFINITY}, + }, + error_code=BAD_VALUE_ERROR, + msg="count should reject -Infinity Decimal128 for strength", + ), + *[ + CommandTestCase( + f"type_collation_strength_{tid}", + docs=[{"_id": 1}], + command=lambda ctx, v=val: { + "count": ctx.collection, + "collation": {"locale": "en", "strength": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"count should reject {tid} for collation strength", + ) + for tid, val in [ + ("string", "3"), + ("bool", True), + ("array", [3]), + ("object", {"v": 3}), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02")), + ("regex", Regex("^abc")), + ("code", Code("function(){}")), + ("code_with_scope", Code("function(){}", {"x": 1})), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], +] + +# Property [Type Strictness: collation (boolean sub-fields)]: the boolean +# sub-fields validate type strictly and have field-specific null handling. +COUNT_TYPE_STRICTNESS_COLLATION_BOOL_FIELDS_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"type_collation_{field}_{tid}", + docs=[{"_id": 1}], + command=lambda ctx, f=field, v=val: { + "count": ctx.collection, + "collation": {"locale": "en", f: v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"count should reject {tid} for collation {field}", + ) + for field in ["caseLevel", "numericOrdering", "backwards", "normalization"] + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", Decimal128("1")), + ("string", "true"), + ("array", [True]), + ("object", {"a": True}), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02")), + ("regex", Regex("^abc")), + ("code", Code("function(){}")), + ("code_with_scope", Code("function(){}", {"x": 1})), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + # Null handling: caseLevel, numericOrdering, normalization accept null; + # backwards rejects null. + *[ + CommandTestCase( + f"type_collation_{field}_null_accepted", + docs=[{"_id": 1}], + command=lambda ctx, f=field: { + "count": ctx.collection, + "collation": {"locale": "en", f: None}, + }, + expected={"n": 1, "ok": 1.0}, + msg=f"count should accept null for collation {field} (treated as omitted)", + ) + for field in ["caseLevel", "numericOrdering", "normalization"] + ], + CommandTestCase( + "type_collation_backwards_null_rejected", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "backwards": None}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject null for collation backwards", + ), +] + +# Property [Type Strictness: collation (enum sub-fields)]: the string enum +# sub-fields validate type, value, and field-specific constraints. +COUNT_TYPE_STRICTNESS_COLLATION_ENUM_FIELDS_TESTS: list[CommandTestCase] = [ + # caseFirst valid values and constraints + CommandTestCase( + "type_collation_casefirst_lower_accepted", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "caseFirst": "lower", "strength": 3}, + }, + expected={"n": 1, "ok": 1.0}, + msg='count should accept caseFirst "lower" with strength > 2', + ), + CommandTestCase( + "type_collation_casefirst_with_strength_3", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "caseFirst": "upper", "strength": 3}, + }, + expected={"n": 1, "ok": 1.0}, + msg="count should accept caseFirst with strength > 2", + ), + CommandTestCase( + "type_collation_casefirst_with_caselevel", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": { + "locale": "en", + "caseFirst": "upper", + "caseLevel": True, + }, + }, + expected={"n": 1, "ok": 1.0}, + msg="count should accept caseFirst with caseLevel=true", + ), + CommandTestCase( + "type_collation_casefirst_off_always_valid", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "caseFirst": "off", "strength": 1}, + }, + expected={"n": 1, "ok": 1.0}, + msg='count should accept caseFirst "off" regardless of strength or caseLevel', + ), + CommandTestCase( + "type_collation_casefirst_requires_caselevel_or_strength", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "caseFirst": "upper", "strength": 1}, + }, + error_code=BAD_VALUE_ERROR, + msg="count should reject caseFirst without caseLevel=true or strength > 2", + ), + # alternate valid values + CommandTestCase( + "type_collation_alternate_non_ignorable_accepted", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "alternate": "non-ignorable"}, + }, + expected={"n": 1, "ok": 1.0}, + msg='count should accept alternate "non-ignorable"', + ), + CommandTestCase( + "type_collation_alternate_shifted_accepted", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "alternate": "shifted"}, + }, + expected={"n": 1, "ok": 1.0}, + msg='count should accept alternate "shifted"', + ), + # maxVariable valid values + CommandTestCase( + "type_collation_maxvariable_punct_accepted", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "maxVariable": "punct"}, + }, + expected={"n": 1, "ok": 1.0}, + msg='count should accept maxVariable "punct"', + ), + CommandTestCase( + "type_collation_maxvariable_space_accepted", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "maxVariable": "space"}, + }, + expected={"n": 1, "ok": 1.0}, + msg='count should accept maxVariable "space"', + ), + # Null acceptance for all enum sub-fields + *[ + CommandTestCase( + f"type_collation_{field}_null_accepted", + docs=[{"_id": 1}], + command=lambda ctx, f=field: { + "count": ctx.collection, + "collation": {"locale": "en", f: None}, + }, + expected={"n": 1, "ok": 1.0}, + msg=f"count should accept null for collation {field} (treated as omitted)", + ) + for field in ["caseFirst", "alternate", "maxVariable"] + ], + # Invalid string values (BadValue) + *[ + CommandTestCase( + f"type_collation_{field}_{tid}", + docs=[{"_id": 1}], + command=lambda ctx, f=field, v=val: { + "count": ctx.collection, + "collation": {"locale": "en", f: v}, + }, + error_code=BAD_VALUE_ERROR, + msg=f"count should reject {tid} for collation {field}", + ) + for field, tid, val in [ + ("caseFirst", "invalid", "invalid"), + ("caseFirst", "empty", ""), + ("caseFirst", "wrong_case", "Upper"), + ("alternate", "invalid", "invalid"), + ("alternate", "empty", ""), + ("alternate", "wrong_case", "Shifted"), + ("maxVariable", "invalid", "invalid"), + ("maxVariable", "empty", ""), + ("maxVariable", "wrong_case", "Punct"), + ] + ], + # Non-string type rejection (TypeMismatch) for all enum sub-fields + *[ + CommandTestCase( + f"type_collation_{field}_{tid}", + docs=[{"_id": 1}], + command=lambda ctx, f=field, v=val: { + "count": ctx.collection, + "collation": {"locale": "en", f: v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"count should reject {tid} for collation {field}", + ) + for field in ["caseFirst", "alternate", "maxVariable"] + for tid, val in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", Decimal128("1")), + ("bool", True), + ("array", [1, 2]), + ("object", {"a": 1}), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02")), + ("regex", Regex("^abc")), + ("code", Code("function(){}")), + ("code_with_scope", Code("function(){}", {"x": 1})), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], +] + +# Property [Type Strictness: collation (unknown fields)]: unknown fields in the +# collation document produce an UnrecognizedCommandField error. +COUNT_TYPE_STRICTNESS_COLLATION_UNKNOWN_FIELDS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "type_collation_unknown_field", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "unknownField": 1}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="count should reject unknown fields in collation document", + ), +] + +# Property [Collation Behavior: numericOrdering]: numericOrdering=true causes +# numeric strings to be compared by their numeric value rather than +# lexicographically. +COUNT_COLLATION_NUMERIC_ORDERING_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "collation_numericordering_true", + docs=[{"_id": 1, "s": "2"}, {"_id": 2, "s": "10"}, {"_id": 3, "s": "1"}], + command=lambda ctx: { + "count": ctx.collection, + "query": {"s": {"$gt": "2"}}, + "collation": {"locale": "en", "numericOrdering": True}, + }, + expected={"n": 1, "ok": 1.0}, + msg="count with numericOrdering=true should compare '10' > '2' numerically", + ), + CommandTestCase( + "collation_numericordering_false", + docs=[{"_id": 1, "s": "2"}, {"_id": 2, "s": "10"}, {"_id": 3, "s": "1"}], + command=lambda ctx: { + "count": ctx.collection, + "query": {"s": {"$gt": "2"}}, + "collation": {"locale": "en", "numericOrdering": False}, + }, + expected={"n": 0, "ok": 1.0}, + msg="count with numericOrdering=false should compare '10' < '2' lexicographically", + ), +] + +# Property [Collation Behavior: alternate]: alternate="shifted" causes +# punctuation and whitespace to be ignored at primary/secondary strength levels. +COUNT_COLLATION_ALTERNATE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "collation_alternate_shifted_ignores_punctuation", + docs=[{"_id": 1, "s": "abc"}, {"_id": 2, "s": "a-b-c"}, {"_id": 3, "s": "def"}], + command=lambda ctx: { + "count": ctx.collection, + "query": {"s": "abc"}, + "collation": {"locale": "en", "alternate": "shifted", "strength": 1}, + }, + expected={"n": 2, "ok": 1.0}, + msg="count with alternate=shifted should ignore punctuation at strength 1", + ), + CommandTestCase( + "collation_alternate_non_ignorable_respects_punctuation", + docs=[{"_id": 1, "s": "abc"}, {"_id": 2, "s": "a-b-c"}, {"_id": 3, "s": "def"}], + command=lambda ctx: { + "count": ctx.collection, + "query": {"s": "abc"}, + "collation": {"locale": "en", "alternate": "non-ignorable", "strength": 1}, + }, + expected={"n": 1, "ok": 1.0}, + msg="count with alternate=non-ignorable should respect punctuation", + ), +] + +# Property [Collation Behavior: maxVariable]: maxVariable controls which +# characters are ignored when alternate="shifted"; "space" ignores only +# whitespace, "punct" ignores both whitespace and punctuation. +COUNT_COLLATION_MAX_VARIABLE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "collation_maxvariable_space", + docs=[{"_id": 1, "s": "abc"}, {"_id": 2, "s": "a bc"}, {"_id": 3, "s": "a.bc"}], + command=lambda ctx: { + "count": ctx.collection, + "query": {"s": "abc"}, + "collation": { + "locale": "en", + "alternate": "shifted", + "maxVariable": "space", + "strength": 1, + }, + }, + expected={"n": 2, "ok": 1.0}, + msg="count with maxVariable=space should ignore only whitespace", + ), + CommandTestCase( + "collation_maxvariable_punct", + docs=[{"_id": 1, "s": "abc"}, {"_id": 2, "s": "a bc"}, {"_id": 3, "s": "a.bc"}], + command=lambda ctx: { + "count": ctx.collection, + "query": {"s": "abc"}, + "collation": { + "locale": "en", + "alternate": "shifted", + "maxVariable": "punct", + "strength": 1, + }, + }, + expected={"n": 3, "ok": 1.0}, + msg="count with maxVariable=punct should ignore whitespace and punctuation", + ), +] + +# Property [Collation Behavior: backwards]: backwards=true reverses the +# secondary (accent) comparison direction. +COUNT_COLLATION_BACKWARDS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "collation_backwards_true", + docs=[ + {"_id": 1, "s": "cote"}, + {"_id": 2, "s": "cot\u00e9"}, + {"_id": 3, "s": "c\u00f4te"}, + {"_id": 4, "s": "c\u00f4t\u00e9"}, + ], + command=lambda ctx: { + "count": ctx.collection, + "query": {"s": {"$gt": "cot\u00e9"}}, + "collation": {"locale": "en", "strength": 2, "backwards": True}, + }, + expected={"n": 1, "ok": 1.0}, + msg="count with backwards=true should reverse accent comparison direction", + ), + CommandTestCase( + "collation_backwards_false", + docs=[ + {"_id": 1, "s": "cote"}, + {"_id": 2, "s": "cot\u00e9"}, + {"_id": 3, "s": "c\u00f4te"}, + {"_id": 4, "s": "c\u00f4t\u00e9"}, + ], + command=lambda ctx: { + "count": ctx.collection, + "query": {"s": {"$gt": "cot\u00e9"}}, + "collation": {"locale": "en", "strength": 2, "backwards": False}, + }, + expected={"n": 2, "ok": 1.0}, + msg="count with backwards=false should use normal accent comparison direction", + ), +] + +# Property [Collation Behavior: caseFirst]: caseFirst controls whether +# uppercase or lowercase sorts first at the tertiary level. +COUNT_COLLATION_CASEFIRST_BEHAVIOR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "collation_casefirst_upper", + docs=[{"_id": 1, "s": "a"}, {"_id": 2, "s": "A"}, {"_id": 3, "s": "b"}], + command=lambda ctx: { + "count": ctx.collection, + "query": {"s": {"$gt": "A"}}, + "collation": {"locale": "en", "caseFirst": "upper", "strength": 3}, + }, + expected={"n": 2, "ok": 1.0}, + msg="count with caseFirst=upper should sort uppercase before lowercase", + ), + CommandTestCase( + "collation_casefirst_lower", + docs=[{"_id": 1, "s": "a"}, {"_id": 2, "s": "A"}, {"_id": 3, "s": "b"}], + command=lambda ctx: { + "count": ctx.collection, + "query": {"s": {"$gt": "A"}}, + "collation": {"locale": "en", "caseFirst": "lower", "strength": 3}, + }, + expected={"n": 1, "ok": 1.0}, + msg="count with caseFirst=lower should sort lowercase before uppercase", + ), +] + +COUNT_COLLATION_SUBFIELD_TESTS: list[CommandTestCase] = ( + COUNT_TYPE_STRICTNESS_COLLATION_STRENGTH_TESTS + + COUNT_TYPE_STRICTNESS_COLLATION_BOOL_FIELDS_TESTS + + COUNT_TYPE_STRICTNESS_COLLATION_ENUM_FIELDS_TESTS + + COUNT_TYPE_STRICTNESS_COLLATION_UNKNOWN_FIELDS_TESTS + + COUNT_COLLATION_NUMERIC_ORDERING_TESTS + + COUNT_COLLATION_ALTERNATE_TESTS + + COUNT_COLLATION_MAX_VARIABLE_TESTS + + COUNT_COLLATION_BACKWARDS_TESTS + + COUNT_COLLATION_CASEFIRST_BEHAVIOR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(COUNT_COLLATION_SUBFIELD_TESTS)) +def test_count_collation_subfields(database_client, collection, test): + """Test count command collation sub-field validation.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_comment.py b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_comment.py new file mode 100644 index 00000000..4fbf5a7e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_comment.py @@ -0,0 +1,63 @@ +"""Tests for count command comment acceptance.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Comment Acceptance]: all pymongo-representable BSON types are +# accepted for the comment field without error. +COUNT_COMMENT_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"comment_{tid}", + docs=[{"_id": 1}], + command=lambda ctx, v=val: {"count": ctx.collection, "comment": v}, + expected={"n": 1, "ok": 1.0}, + msg=f"count should accept {tid} comment", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", Decimal128("1")), + ("string", "hello"), + ("bool", True), + ("null", None), + ("array", [1, 2, 3]), + ("object", {"key": "value"}), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02")), + ("regex", Regex("^abc")), + ("code", Code("function(){}")), + ("code_with_scope", Code("function(){}", {"x": 1})), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + + +@pytest.mark.parametrize("test", pytest_params(COUNT_COMMENT_ACCEPTANCE_TESTS)) +def test_count_comment(database_client, collection, test): + """Test count command comment acceptance.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_core.py b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_core.py new file mode 100644 index 00000000..71a4ea0f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_core.py @@ -0,0 +1,146 @@ +"""Tests for count command core behavior and null handling.""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +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 + +# Property [Success Response Structure]: a successful count returns exactly +# two fields: n (int32) and ok (double 1.0). +COUNT_SUCCESS_RESPONSE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "success_basic", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection}, + expected={"n": 1, "ok": 1.0}, + msg="count should return exactly n and ok fields", + ), +] + +# Property [Core Behavior]: count returns the number of documents matching +# the query, or all documents when no query is specified. +COUNT_CORE_BEHAVIOR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "core_nonexistent_collection", + docs=None, + command=lambda ctx: {"count": ctx.collection}, + expected={"n": 0, "ok": 1.0}, + msg="count on non-existent collection should return n=0", + ), + CommandTestCase( + "core_empty_collection", + docs=[], + command=lambda ctx: {"count": ctx.collection}, + expected={"n": 0, "ok": 1.0}, + msg="count on empty collection should return n=0", + ), + CommandTestCase( + "core_no_query", + docs=[{"_id": 1}, {"_id": 2}, {"_id": 3}, {"_id": 4}, {"_id": 5}], + command=lambda ctx: {"count": ctx.collection}, + expected={"n": 5, "ok": 1.0}, + msg="count without query should return total document count", + ), + CommandTestCase( + "core_with_query", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}, {"_id": 3, "x": 1}], + command=lambda ctx: {"count": ctx.collection, "query": {"x": 1}}, + expected={"n": 2, "ok": 1.0}, + msg="count with query should return only matching document count", + ), + CommandTestCase( + "core_query_no_matches", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}], + command=lambda ctx: {"count": ctx.collection, "query": {"x": 99}}, + expected={"n": 0, "ok": 1.0}, + msg="count with non-matching query should return n=0", + ), + CommandTestCase( + "core_case_sensitive_name", + docs=[{"_id": 1}, {"_id": 2}, {"_id": 3}], + command=lambda ctx: {"count": ctx.collection.upper()}, + expected={"n": 0, "ok": 1.0}, + msg="count is case-sensitive for collection names", + ), +] + +# Property [Null Success]: null-valued optional fields are treated as omitted. +COUNT_NULL_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "null_query", + docs=[{"_id": 1}, {"_id": 2}, {"_id": 3}], + command=lambda ctx: {"count": ctx.collection, "query": None}, + expected={"n": 3, "ok": 1.0}, + msg="count with query=null should return full document count", + ), + CommandTestCase( + "null_skip", + docs=[{"_id": 1}, {"_id": 2}, {"_id": 3}], + command=lambda ctx: {"count": ctx.collection, "skip": None}, + expected={"n": 3, "ok": 1.0}, + msg="count with skip=null should treat it as skip=0", + ), + CommandTestCase( + "null_read_concern", + docs=[{"_id": 1}, {"_id": 2}, {"_id": 3}], + command=lambda ctx: {"count": ctx.collection, "readConcern": None}, + expected={"n": 3, "ok": 1.0}, + msg="count with readConcern=null should use default read concern", + ), + CommandTestCase( + "null_collation", + docs=[{"_id": 1}, {"_id": 2}, {"_id": 3}], + command=lambda ctx: {"count": ctx.collection, "collation": None}, + expected={"n": 3, "ok": 1.0}, + msg="count with collation=null should use default comparison", + ), + CommandTestCase( + "null_max_time_ms", + docs=[{"_id": 1}, {"_id": 2}, {"_id": 3}], + command=lambda ctx: {"count": ctx.collection, "maxTimeMS": None}, + expected={"n": 3, "ok": 1.0}, + msg="count with maxTimeMS=null should be unbounded", + ), +] + +# Property [Null Error]: some fields reject null rather than treating it as +# omitted. +COUNT_NULL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "null_limit", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "limit": None}, + error_code=BAD_VALUE_ERROR, + msg="count with limit=null should produce an error", + ), +] + +COUNT_NULL_TESTS = COUNT_NULL_SUCCESS_TESTS + COUNT_NULL_ERROR_TESTS + + +COUNT_CORE_TESTS: list[CommandTestCase] = ( + COUNT_SUCCESS_RESPONSE_TESTS + COUNT_CORE_BEHAVIOR_TESTS + COUNT_NULL_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(COUNT_CORE_TESTS)) +def test_count_core(database_client, collection, test): + """Test count command core behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_hint.py b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_hint.py new file mode 100644 index 00000000..96d59371 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_hint.py @@ -0,0 +1,490 @@ +"""Tests for count command hint behavior and type strictness.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + FAILED_TO_PARSE_ERROR, + NO_QUERY_EXECUTION_PLANS_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.target_collection import ViewCollection + +# Property [Hint Behavior]: the count command uses the specified hint to +# select which index to use for query execution. +COUNT_HINT_BEHAVIOR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "hint_default_id_by_name", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "hint": "_id_"}, + expected={"n": 5, "ok": 1.0}, + msg="count should accept the default _id_ index by name", + ), + CommandTestCase( + "hint_default_id_by_key_pattern", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "hint": {"_id": 1}}, + expected={"n": 5, "ok": 1.0}, + msg="count should accept the default _id index by key pattern", + ), + CommandTestCase( + "hint_string_by_name", + docs=[{"_id": i, "x": i} for i in range(5)], + indexes=[IndexModel([("x", 1)], name="x_1")], + command=lambda ctx: {"count": ctx.collection, "hint": "x_1"}, + expected={"n": 5, "ok": 1.0}, + msg="count should accept a string hint specifying an index by name", + ), + CommandTestCase( + "hint_string_case_sensitive", + docs=[{"_id": i, "x": i} for i in range(5)], + indexes=[IndexModel([("x", 1)], name="x_1")], + command=lambda ctx: {"count": ctx.collection, "hint": "X_1"}, + error_code=BAD_VALUE_ERROR, + msg="count string hint should be case-sensitive", + ), + CommandTestCase( + "hint_string_no_trimming", + docs=[{"_id": i, "x": i} for i in range(5)], + indexes=[IndexModel([("x", 1)], name="x_1")], + command=lambda ctx: {"count": ctx.collection, "hint": " x_1 "}, + error_code=BAD_VALUE_ERROR, + msg="count string hint should not trim whitespace", + ), + CommandTestCase( + "hint_doc_key_pattern", + docs=[{"_id": i, "x": i} for i in range(5)], + indexes=[IndexModel([("x", 1)])], + command=lambda ctx: {"count": ctx.collection, "hint": {"x": 1}}, + expected={"n": 5, "ok": 1.0}, + msg="count should accept a document hint specifying an index by key pattern", + ), + CommandTestCase( + "hint_doc_compound_order_matters", + docs=[{"_id": i, "a": i, "b": i} for i in range(5)], + indexes=[IndexModel([("a", 1), ("b", 1)])], + command=lambda ctx: {"count": ctx.collection, "hint": {"b": 1, "a": 1}}, + error_code=BAD_VALUE_ERROR, + msg="count document hint should require correct field order for compound indexes", + ), + CommandTestCase( + "hint_empty_doc", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "hint": {}}, + expected={"n": 5, "ok": 1.0}, + msg="count with empty document hint should be treated as no hint", + ), + CommandTestCase( + "hint_natural_forward", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "hint": {"$natural": 1}}, + expected={"n": 5, "ok": 1.0}, + msg='count should accept {"$natural": 1} as a document hint', + ), + CommandTestCase( + "hint_natural_reverse", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "hint": {"$natural": -1}}, + expected={"n": 5, "ok": 1.0}, + msg='count should accept {"$natural": -1} as a document hint', + ), + CommandTestCase( + "hint_direction_int64", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "hint": {"$natural": Int64(1)}}, + expected={"n": 5, "ok": 1.0}, + msg="count document hint should accept Int64 direction value", + ), + CommandTestCase( + "hint_direction_double", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "hint": {"$natural": 1.0}}, + expected={"n": 5, "ok": 1.0}, + msg="count document hint should accept double direction value", + ), + CommandTestCase( + "hint_direction_decimal128", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: { + "count": ctx.collection, + "hint": {"$natural": Decimal128("1")}, + }, + expected={"n": 5, "ok": 1.0}, + msg="count document hint should accept Decimal128 direction value", + ), + CommandTestCase( + "hint_direction_decimal128_neg", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: { + "count": ctx.collection, + "hint": {"$natural": Decimal128("-1")}, + }, + expected={"n": 5, "ok": 1.0}, + msg="count document hint should accept Decimal128(-1) direction value", + ), + CommandTestCase( + "hint_nonexistent_collection_string", + docs=None, + command=lambda ctx: {"count": ctx.collection, "hint": "any_index"}, + expected={"n": 0, "ok": 1.0}, + msg="count on non-existent collection should accept any string hint", + ), + CommandTestCase( + "hint_nonexistent_collection_doc", + docs=None, + command=lambda ctx: {"count": ctx.collection, "hint": {"a": 1}}, + expected={"n": 0, "ok": 1.0}, + msg="count on non-existent collection should accept any document hint", + ), + CommandTestCase( + "hint_sparse_index", + docs=[ + {"_id": 1, "x": 1}, + {"_id": 2, "x": 2}, + {"_id": 3}, + {"_id": 4, "x": None}, + {"_id": 5}, + ], + indexes=[IndexModel([("x", 1)], name="x_sparse", sparse=True)], + command=lambda ctx: {"count": ctx.collection, "hint": "x_sparse"}, + expected={"n": 3, "ok": 1.0}, + msg="count with sparse index hint should only count documents with the indexed field", + ), + CommandTestCase( + "hint_partial_filter", + docs=[ + {"_id": 1, "x": 1, "status": "active"}, + {"_id": 2, "x": 2, "status": "active"}, + {"_id": 3, "x": 3, "status": "inactive"}, + {"_id": 4, "x": 4, "status": "active"}, + {"_id": 5, "x": 5, "status": "inactive"}, + ], + indexes=[ + IndexModel( + [("x", 1)], + name="x_partial", + partialFilterExpression={"status": "active"}, + ) + ], + command=lambda ctx: {"count": ctx.collection, "hint": "x_partial"}, + expected={"n": 3, "ok": 1.0}, + msg="count with partial filter index hint should only count documents matching the filter", + ), +] + +# Property [Hint Validation Errors]: invalid hint values produce an error. +COUNT_HINT_VALIDATION_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "hint_err_natural_string", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "hint": "$natural"}, + error_code=BAD_VALUE_ERROR, + msg="count should reject $natural as a string hint", + ), + CommandTestCase( + "hint_err_natural_combined_with_other_field", + docs=[{"_id": 1, "x": 1}], + command=lambda ctx: { + "count": ctx.collection, + "hint": {"$natural": 1, "x": 1}, + }, + error_code=BAD_VALUE_ERROR, + msg="count should reject $natural combined with other fields in a document hint", + ), + CommandTestCase( + "hint_err_direction_zero", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "hint": {"$natural": 0}}, + error_code=BAD_VALUE_ERROR, + msg="count should reject zero as a direction value in document hint", + ), + CommandTestCase( + "hint_err_direction_fractional", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "hint": {"$natural": 0.5}}, + error_code=BAD_VALUE_ERROR, + msg="count should reject non-integer direction value in document hint", + ), + CommandTestCase( + "hint_err_direction_out_of_range", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "hint": {"$natural": 2}}, + error_code=BAD_VALUE_ERROR, + msg="count should reject out-of-range direction value in document hint", + ), + CommandTestCase( + "hint_err_direction_boolean", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "hint": {"$natural": True}}, + error_code=BAD_VALUE_ERROR, + msg="count should reject boolean direction value in document hint", + ), + CommandTestCase( + "hint_err_direction_null", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "hint": {"$natural": None}}, + error_code=BAD_VALUE_ERROR, + msg="count should reject null direction value in document hint", + ), + CommandTestCase( + "hint_err_direction_string", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "hint": {"$natural": "forward"}, + }, + error_code=BAD_VALUE_ERROR, + msg="count should reject string direction value in document hint", + ), + CommandTestCase( + "hint_err_nonexistent_index_name", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "hint": "nonexistent_idx"}, + error_code=BAD_VALUE_ERROR, + msg="count should reject a non-existent index name on an existing collection", + ), + CommandTestCase( + "hint_err_nonexistent_index_spec", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "hint": {"z": 1}}, + error_code=BAD_VALUE_ERROR, + msg="count should reject a non-existent index spec on an existing collection", + ), + CommandTestCase( + "hint_err_nonexistent_index_empty_collection", + docs=[], + command=lambda ctx: {"count": ctx.collection, "hint": "nonexistent_idx"}, + error_code=BAD_VALUE_ERROR, + msg="count should reject a non-existent index name on an empty collection", + ), +] + +COUNT_HINT_TESTS = COUNT_HINT_BEHAVIOR_TESTS + COUNT_HINT_VALIDATION_ERROR_TESTS + +# Property [Type Strictness: hint]: only string and document types are accepted +# for the hint field; all other BSON types produce a FailedToParse error. +COUNT_TYPE_STRICTNESS_HINT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "type_hint_int32", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "hint": 42}, + error_code=FAILED_TO_PARSE_ERROR, + msg="count should reject int32 for hint", + ), + CommandTestCase( + "type_hint_int64", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "hint": Int64(1)}, + error_code=FAILED_TO_PARSE_ERROR, + msg="count should reject Int64 for hint", + ), + CommandTestCase( + "type_hint_double", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "hint": 3.14}, + error_code=FAILED_TO_PARSE_ERROR, + msg="count should reject double for hint", + ), + CommandTestCase( + "type_hint_decimal128", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "hint": Decimal128("1")}, + error_code=FAILED_TO_PARSE_ERROR, + msg="count should reject Decimal128 for hint", + ), + CommandTestCase( + "type_hint_bool", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "hint": True}, + error_code=FAILED_TO_PARSE_ERROR, + msg="count should reject bool for hint", + ), + CommandTestCase( + "type_hint_null", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "hint": None}, + error_code=FAILED_TO_PARSE_ERROR, + msg="count should reject null for hint", + ), + CommandTestCase( + "type_hint_array", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "hint": [1, 2]}, + error_code=FAILED_TO_PARSE_ERROR, + msg="count should reject array for hint", + ), + CommandTestCase( + "type_hint_objectid", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "hint": ObjectId()}, + error_code=FAILED_TO_PARSE_ERROR, + msg="count should reject ObjectId for hint", + ), + CommandTestCase( + "type_hint_datetime", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "hint": datetime(2024, 1, 1, tzinfo=timezone.utc), + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="count should reject datetime for hint", + ), + CommandTestCase( + "type_hint_timestamp", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "hint": Timestamp(1, 1)}, + error_code=FAILED_TO_PARSE_ERROR, + msg="count should reject Timestamp for hint", + ), + CommandTestCase( + "type_hint_binary", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "hint": Binary(b"\x01\x02"), + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="count should reject Binary for hint", + ), + CommandTestCase( + "type_hint_regex", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "hint": Regex("^abc")}, + error_code=FAILED_TO_PARSE_ERROR, + msg="count should reject Regex for hint", + ), + CommandTestCase( + "type_hint_code", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "hint": Code("function(){}"), + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="count should reject Code for hint", + ), + CommandTestCase( + "type_hint_code_with_scope", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "hint": Code("function(){}", {"x": 1}), + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="count should reject Code with scope for hint", + ), + CommandTestCase( + "type_hint_minkey", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "hint": MinKey()}, + error_code=FAILED_TO_PARSE_ERROR, + msg="count should reject MinKey for hint", + ), + CommandTestCase( + "type_hint_maxkey", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "hint": MaxKey()}, + error_code=FAILED_TO_PARSE_ERROR, + msg="count should reject MaxKey for hint", + ), +] + +# Property [Text Search and Hint Interaction]: a $text query combined with a +# hint produces an error, but an empty document hint {} is exempt from this +# restriction. +COUNT_TEXT_SEARCH_HINT_INTERACTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "text_hint_error", + docs=[{"_id": 1, "s": "hello world"}, {"_id": 2, "s": "foo bar"}], + indexes=[IndexModel([("s", "text")])], + command=lambda ctx: { + "count": ctx.collection, + "query": {"$text": {"$search": "hello"}}, + "hint": "s_text", + }, + error_code=BAD_VALUE_ERROR, + msg="count should reject $text query combined with a hint", + ), + CommandTestCase( + "text_hint_empty_doc_exempt", + docs=[{"_id": 1, "s": "hello world"}, {"_id": 2, "s": "foo bar"}], + indexes=[IndexModel([("s", "text")])], + command=lambda ctx: { + "count": ctx.collection, + "query": {"$text": {"$search": "hello"}}, + "hint": {}, + }, + expected={"n": 1, "ok": 1.0}, + msg="count should allow $text query with empty document hint", + ), +] + +# Property [Hint on View Errors]: a hint referencing a non-existent index on a +# view produces an error. +COUNT_HINT_ON_VIEW_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "hint_view_nonexistent_index_string", + target_collection=ViewCollection(), + docs=[{"_id": i} for i in range(3)], + command=lambda ctx: {"count": ctx.collection, "hint": "nonexistent_idx"}, + error_code=BAD_VALUE_ERROR, + msg="count should reject a non-existent index name hint on a view", + ), + CommandTestCase( + "hint_view_nonexistent_index_doc", + target_collection=ViewCollection(), + docs=[{"_id": i} for i in range(3)], + command=lambda ctx: {"count": ctx.collection, "hint": {"z": 1}}, + error_code=BAD_VALUE_ERROR, + msg="count should reject a non-existent index spec hint on a view", + ), +] + +# Property [Wildcard Index Hint]: a wildcard index hint without a query produces +# an error because the planner cannot generate a plan from a wildcard index alone. +COUNT_WILDCARD_INDEX_HINT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "wildcard_hint_no_query", + docs=[{"_id": i, "x": i} for i in range(5)], + indexes=[IndexModel([("$**", 1)], name="wildcard_all")], + command=lambda ctx: {"count": ctx.collection, "hint": "wildcard_all"}, + error_code=NO_QUERY_EXECUTION_PLANS_ERROR, + msg="count with wildcard index hint without a query should produce an error", + ), +] + +COUNT_HINT_ALL_TESTS: list[CommandTestCase] = ( + COUNT_HINT_TESTS + + COUNT_TYPE_STRICTNESS_HINT_TESTS + + COUNT_TEXT_SEARCH_HINT_INTERACTION_TESTS + + COUNT_HINT_ON_VIEW_ERROR_TESTS + + COUNT_WILDCARD_INDEX_HINT_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(COUNT_HINT_ALL_TESTS)) +def test_count_hint(database_client, collection, test): + """Test count command hint behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_max_time_ms.py b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_max_time_ms.py new file mode 100644 index 00000000..a39b031c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_max_time_ms.py @@ -0,0 +1,401 @@ +"""Tests for count command maxTimeMS behavior and type strictness.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + FAILED_TO_PARSE_ERROR, + TYPE_MISMATCH_ERROR, +) +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_NAN, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + FLOAT_NEGATIVE_NAN, + INT32_MAX, + INT32_OVERFLOW, +) + +# Property [MaxTimeMS Behavior]: maxTimeMS controls the execution time limit +# for the count command. +COUNT_MAX_TIME_MS_BEHAVIOR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "maxtimems_zero_int", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "maxTimeMS": 0}, + expected={"n": 5, "ok": 1.0}, + msg="count with maxTimeMS=0 should mean unbounded (no timeout)", + ), + CommandTestCase( + "maxtimems_neg_zero_double", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: { + "count": ctx.collection, + "maxTimeMS": DOUBLE_NEGATIVE_ZERO, + }, + expected={"n": 5, "ok": 1.0}, + msg="count with maxTimeMS=-0.0 should be accepted as 0 (unbounded)", + ), + CommandTestCase( + "maxtimems_neg_zero_decimal", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: { + "count": ctx.collection, + "maxTimeMS": DECIMAL128_NEGATIVE_ZERO, + }, + expected={"n": 5, "ok": 1.0}, + msg='count with maxTimeMS=Decimal128("-0") should be accepted as 0 (unbounded)', + ), + CommandTestCase( + "maxtimems_neg_zero_decimal_exponent", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: { + "count": ctx.collection, + "maxTimeMS": Decimal128("-0E+10"), + }, + expected={"n": 5, "ok": 1.0}, + msg='count with maxTimeMS=Decimal128("-0E+10") should be accepted as 0 (unbounded)', + ), +] + +# Property [Type Strictness: maxTimeMS (accepted)]: int32, int64, whole-number +# double, and integer-valued Decimal128 are accepted for maxTimeMS. +COUNT_TYPE_STRICTNESS_MAXTIMEMS_ACCEPTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "type_maxtimems_int32", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "maxTimeMS": 1000}, + expected={"n": 1, "ok": 1.0}, + msg="count should accept int32 for maxTimeMS", + ), + CommandTestCase( + "type_maxtimems_int64", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "maxTimeMS": Int64(1000)}, + expected={"n": 1, "ok": 1.0}, + msg="count should accept Int64 for maxTimeMS", + ), + CommandTestCase( + "type_maxtimems_whole_double", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "maxTimeMS": 1000.0}, + expected={"n": 1, "ok": 1.0}, + msg="count should accept whole-number double for maxTimeMS", + ), + CommandTestCase( + "type_maxtimems_whole_decimal128", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "maxTimeMS": Decimal128("1000"), + }, + expected={"n": 1, "ok": 1.0}, + msg="count should accept integer-valued Decimal128 for maxTimeMS", + ), + CommandTestCase( + "type_maxtimems_int32_max", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "maxTimeMS": INT32_MAX}, + expected={"n": 1, "ok": 1.0}, + msg="count should accept int32 maximum value for maxTimeMS", + ), +] + +# Property [Type Strictness: maxTimeMS (type rejected)]: all non-numeric BSON +# types produce a TypeMismatch error for maxTimeMS. +COUNT_TYPE_STRICTNESS_MAXTIMEMS_TYPE_REJECTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "type_maxtimems_string", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "maxTimeMS": "hello"}, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject string for maxTimeMS", + ), + CommandTestCase( + "type_maxtimems_bool", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "maxTimeMS": True}, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject bool for maxTimeMS", + ), + CommandTestCase( + "type_maxtimems_array", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "maxTimeMS": [1, 2]}, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject array for maxTimeMS", + ), + CommandTestCase( + "type_maxtimems_object", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "maxTimeMS": {"a": 1}}, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject object for maxTimeMS", + ), + CommandTestCase( + "type_maxtimems_objectid", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "maxTimeMS": ObjectId()}, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject ObjectId for maxTimeMS", + ), + CommandTestCase( + "type_maxtimems_datetime", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "maxTimeMS": datetime(2024, 1, 1, tzinfo=timezone.utc), + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject datetime for maxTimeMS", + ), + CommandTestCase( + "type_maxtimems_timestamp", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "maxTimeMS": Timestamp(1, 1), + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject Timestamp for maxTimeMS", + ), + CommandTestCase( + "type_maxtimems_binary", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "maxTimeMS": Binary(b"\x01\x02"), + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject Binary for maxTimeMS", + ), + CommandTestCase( + "type_maxtimems_regex", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "maxTimeMS": Regex("^abc"), + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject Regex for maxTimeMS", + ), + CommandTestCase( + "type_maxtimems_code", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "maxTimeMS": Code("function(){}"), + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject Code for maxTimeMS", + ), + CommandTestCase( + "type_maxtimems_code_with_scope", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "maxTimeMS": Code("function(){}", {"x": 1}), + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject Code with scope for maxTimeMS", + ), + CommandTestCase( + "type_maxtimems_minkey", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "maxTimeMS": MinKey()}, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject MinKey for maxTimeMS", + ), + CommandTestCase( + "type_maxtimems_maxkey", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "maxTimeMS": MaxKey()}, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject MaxKey for maxTimeMS", + ), +] + +# Property [Type Strictness: maxTimeMS (representability rejected)]: fractional +# values, NaN, Infinity, and values exceeding int64 range produce a +# FailedToParse error for maxTimeMS. +COUNT_TYPE_STRICTNESS_MAXTIMEMS_REPRESENTABILITY_REJECTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "type_maxtimems_fractional_double", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "maxTimeMS": 1.5}, + error_code=FAILED_TO_PARSE_ERROR, + msg="count should reject fractional double for maxTimeMS", + ), + CommandTestCase( + "type_maxtimems_fractional_decimal128", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "maxTimeMS": DECIMAL128_ONE_AND_HALF, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="count should reject fractional Decimal128 for maxTimeMS", + ), + CommandTestCase( + "type_maxtimems_nan_double", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "maxTimeMS": FLOAT_NAN}, + error_code=FAILED_TO_PARSE_ERROR, + msg="count should reject NaN double for maxTimeMS", + ), + CommandTestCase( + "type_maxtimems_neg_nan_double", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "maxTimeMS": FLOAT_NEGATIVE_NAN}, + error_code=FAILED_TO_PARSE_ERROR, + msg="count should reject -NaN double for maxTimeMS", + ), + CommandTestCase( + "type_maxtimems_nan_decimal128", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "maxTimeMS": DECIMAL128_NAN, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="count should reject NaN Decimal128 for maxTimeMS", + ), + CommandTestCase( + "type_maxtimems_neg_nan_decimal128", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "maxTimeMS": DECIMAL128_NEGATIVE_NAN, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="count should reject -NaN Decimal128 for maxTimeMS", + ), + CommandTestCase( + "type_maxtimems_infinity_double", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "maxTimeMS": FLOAT_INFINITY, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="count should reject Infinity double for maxTimeMS", + ), + CommandTestCase( + "type_maxtimems_neg_infinity_double", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "maxTimeMS": FLOAT_NEGATIVE_INFINITY, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="count should reject -Infinity double for maxTimeMS", + ), + CommandTestCase( + "type_maxtimems_infinity_decimal128", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "maxTimeMS": DECIMAL128_INFINITY, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="count should reject Infinity Decimal128 for maxTimeMS", + ), + CommandTestCase( + "type_maxtimems_neg_infinity_decimal128", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "maxTimeMS": DECIMAL128_NEGATIVE_INFINITY, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="count should reject -Infinity Decimal128 for maxTimeMS", + ), + CommandTestCase( + "type_maxtimems_exceeds_int64_decimal128", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "maxTimeMS": Decimal128("9999999999999999999999"), + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="count should reject Decimal128 exceeding int64 range for maxTimeMS", + ), +] + +# Property [Type Strictness: maxTimeMS (range rejected)]: negative values and +# values exceeding the int32 maximum (but within int64) produce a BadValue error for +# maxTimeMS. +COUNT_TYPE_STRICTNESS_MAXTIMEMS_RANGE_REJECTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "type_maxtimems_negative_int32", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "maxTimeMS": -1}, + error_code=BAD_VALUE_ERROR, + msg="count should reject negative int32 for maxTimeMS", + ), + CommandTestCase( + "type_maxtimems_negative_int64", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "maxTimeMS": Int64(-100), + }, + error_code=BAD_VALUE_ERROR, + msg="count should reject negative Int64 for maxTimeMS", + ), + CommandTestCase( + "type_maxtimems_exceeds_int32_max", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "maxTimeMS": Int64(INT32_OVERFLOW), + }, + error_code=BAD_VALUE_ERROR, + msg="count should reject values exceeding the int32 max for maxTimeMS", + ), +] + +COUNT_TYPE_STRICTNESS_MAXTIMEMS_TESTS = ( + COUNT_TYPE_STRICTNESS_MAXTIMEMS_ACCEPTED_TESTS + + COUNT_TYPE_STRICTNESS_MAXTIMEMS_TYPE_REJECTED_TESTS + + COUNT_TYPE_STRICTNESS_MAXTIMEMS_REPRESENTABILITY_REJECTED_TESTS + + COUNT_TYPE_STRICTNESS_MAXTIMEMS_RANGE_REJECTED_TESTS +) + +COUNT_MAX_TIME_MS_ALL_TESTS: list[CommandTestCase] = ( + COUNT_MAX_TIME_MS_BEHAVIOR_TESTS + COUNT_TYPE_STRICTNESS_MAXTIMEMS_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(COUNT_MAX_TIME_MS_ALL_TESTS)) +def test_count_max_time_ms(database_client, collection, test): + """Test count command maxTimeMS behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_namespace.py b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_namespace.py new file mode 100644 index 00000000..e8e2e531 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_namespace.py @@ -0,0 +1,185 @@ +"""Tests for count command namespace validation and count field type strictness.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INVALID_NAMESPACE_ERROR, + INVALID_UUID_ERROR, + NAMESPACE_NOT_FOUND_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Type Strictness: count]: only string type is accepted for the count +# field; all non-string types produce an invalid namespace error, except Binary +# subtype 4 (UUID, 16 bytes) which attempts UUID resolution, and Binary subtype 4 +# with non-16-byte length which produces a malformed UUID error. +COUNT_TYPE_STRICTNESS_COUNT_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"type_count_{tid}", + docs=None, + command=lambda ctx, v=val: {"count": v}, + error_code=INVALID_NAMESPACE_ERROR, + msg=f"count should reject {tid} for collection name", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", Decimal128("1")), + ("bool", True), + ("null", None), + ("array", [1, 2]), + ("object", {"a": 1}), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary_generic", Binary(b"\x01\x02\x03")), + ("regex", Regex("^abc")), + ("code", Code("function(){}")), + ("code_with_scope", Code("function(){}", {"x": 1})), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + CommandTestCase( + "type_count_binary_uuid_16_bytes", + docs=None, + command=lambda ctx: { + "count": Binary( + b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10", + 4, + ) + }, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="count with Binary UUID (16 bytes) should attempt UUID resolution and fail", + ), + CommandTestCase( + "type_count_binary_uuid_short", + docs=None, + command=lambda ctx: {"count": Binary(b"\x01\x02\x03", 4)}, + error_code=INVALID_UUID_ERROR, + msg="count with Binary UUID (non-16-byte) should produce malformed UUID error", + ), +] + +# Property [Namespace Validation Errors]: empty string, dot-prefixed names, and +# names containing null bytes produce an InvalidNamespace error. +COUNT_NAMESPACE_VALIDATION_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "ns_err_empty_string", + docs=None, + command=lambda ctx: {"count": ""}, + error_code=INVALID_NAMESPACE_ERROR, + msg="count should reject empty string for collection name", + ), + CommandTestCase( + "ns_err_dot_prefix", + docs=None, + command=lambda ctx: {"count": ".foo"}, + error_code=INVALID_NAMESPACE_ERROR, + msg="count should reject collection name starting with a dot", + ), + CommandTestCase( + "ns_err_null_byte", + docs=None, + command=lambda ctx: {"count": "foo\x00bar"}, + error_code=INVALID_NAMESPACE_ERROR, + msg="count should reject collection name containing a null byte", + ), +] + +# Property [Namespace Validation Accepted]: all characters other than leading +# dot and embedded null are accepted, with no server-side length limit. +COUNT_NAMESPACE_VALIDATION_ACCEPTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "ns_ok_single_char", + docs=None, + command=lambda ctx: {"count": "a"}, + expected={"n": 0, "ok": 1.0}, + msg="count should accept a single-character collection name", + ), + CommandTestCase( + "ns_ok_dollar_prefix", + docs=None, + command=lambda ctx: {"count": "$special"}, + expected={"n": 0, "ok": 1.0}, + msg="count should accept collection name starting with $", + ), + CommandTestCase( + "ns_ok_mid_dot", + docs=None, + command=lambda ctx: {"count": "mid.dot"}, + expected={"n": 0, "ok": 1.0}, + msg="count should accept collection name with dot in the middle", + ), + CommandTestCase( + "ns_ok_trailing_dot", + docs=None, + command=lambda ctx: {"count": "foo."}, + expected={"n": 0, "ok": 1.0}, + msg="count should accept collection name with trailing dot", + ), + CommandTestCase( + "ns_ok_control_char", + docs=None, + command=lambda ctx: {"count": "foo\x01bar"}, + expected={"n": 0, "ok": 1.0}, + msg="count should accept collection name with control characters", + ), + CommandTestCase( + "ns_ok_whitespace", + docs=None, + command=lambda ctx: {"count": " spaces "}, + expected={"n": 0, "ok": 1.0}, + msg="count should accept collection name with whitespace", + ), + CommandTestCase( + "ns_ok_unicode_emoji", + docs=None, + command=lambda ctx: {"count": "\U0001f389emoji"}, + expected={"n": 0, "ok": 1.0}, + msg="count should accept collection name with unicode and emoji", + ), + CommandTestCase( + "ns_ok_long_name", + docs=None, + command=lambda ctx: {"count": "x" * 10_000}, + expected={"n": 0, "ok": 1.0}, + msg="count should accept very long collection names without a length limit", + ), +] + +COUNT_NAMESPACE_VALIDATION_TESTS = ( + COUNT_NAMESPACE_VALIDATION_ERROR_TESTS + COUNT_NAMESPACE_VALIDATION_ACCEPTED_TESTS +) + +COUNT_NAMESPACE_ALL_TESTS: list[CommandTestCase] = ( + COUNT_TYPE_STRICTNESS_COUNT_TESTS + COUNT_NAMESPACE_VALIDATION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(COUNT_NAMESPACE_ALL_TESTS)) +def test_count_namespace(database_client, collection, test): + """Test count command namespace validation.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_options.py b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_options.py new file mode 100644 index 00000000..a6277b30 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_options.py @@ -0,0 +1,107 @@ +"""Tests for count command legacy options and unknown option validation.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INVALID_OPTIONS_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Legacy Recognized Options]: the fields option is silently accepted +# with no effect, and any BSON type is allowed for its value. +COUNT_LEGACY_RECOGNIZED_OPTIONS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"fields_{tid}", + docs=[{"_id": 1}, {"_id": 2}, {"_id": 3}], + command=lambda ctx, v=val: {"count": ctx.collection, "fields": v}, + expected={"n": 3, "ok": 1.0}, + msg=f"count should silently accept fields={tid} with no effect", + ) + for tid, val in [ + ("null", None), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", Decimal128("1")), + ("string", "hello"), + ("bool", True), + ("array", [1, 2, 3]), + ("object", {"key": "value"}), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02")), + ("regex", Regex("^abc")), + ("code", Code("function(){}")), + ("code_with_scope", Code("function(){}", {"x": 1})), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Unknown Option Validation]: unknown option names in the command +# document produce an error, and option name validation is case-sensitive. +COUNT_UNKNOWN_OPTION_VALIDATION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_opt_case_sensitive_query", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "Query": {"x": 1}}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="count should reject 'Query' as unknown (case-sensitive)", + ), + CommandTestCase( + "unknown_opt_case_sensitive_limit", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "Limit": 5}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="count should reject 'Limit' as unknown (case-sensitive)", + ), +] + +# Property [WriteConcern Rejection]: including writeConcern in the count command +# produces an error because count is a read operation that does not support it. +COUNT_WRITE_CONCERN_REJECTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "writeconcern_rejected", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "writeConcern": {"w": 1}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="count should reject writeConcern as unsupported", + ), +] + +COUNT_OPTIONS_TESTS: list[CommandTestCase] = ( + COUNT_LEGACY_RECOGNIZED_OPTIONS_TESTS + + COUNT_UNKNOWN_OPTION_VALIDATION_TESTS + + COUNT_WRITE_CONCERN_REJECTION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(COUNT_OPTIONS_TESTS)) +def test_count_options(database_client, collection, test): + """Test count command option handling.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_query_operators.py b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_query_operators.py new file mode 100644 index 00000000..ea4d50b6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_query_operators.py @@ -0,0 +1,366 @@ +"""Representative query operator wiring tests for the count command. + +One test per operator category confirms the count command's query parameter +is correctly wired to the query engine. Exhaustive operator behavior is +tested in core/operator/query/. +""" + +from __future__ import annotations + +import pytest +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Query Operator Wiring]: the count command's query parameter supports +# comparison, logical, array, bitwise, miscellaneous, and expression operators. +COUNT_QUERY_OPERATOR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "query_empty_doc", + docs=[{"_id": 1}, {"_id": 2}, {"_id": 3}], + command=lambda ctx: {"count": ctx.collection, "query": {}}, + expected={"n": 3, "ok": 1.0}, + msg="count with empty query should match all documents", + ), + # Comparison operators. + CommandTestCase( + "query_eq", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}, {"_id": 3, "x": 1}], + command=lambda ctx: {"count": ctx.collection, "query": {"x": {"$eq": 1}}}, + expected={"n": 2, "ok": 1.0}, + msg="count should support $eq in query", + ), + CommandTestCase( + "query_ne", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}, {"_id": 3, "x": 3}], + command=lambda ctx: {"count": ctx.collection, "query": {"x": {"$ne": 2}}}, + expected={"n": 2, "ok": 1.0}, + msg="count should support $ne in query", + ), + CommandTestCase( + "query_gt", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 5}, {"_id": 3, "x": 10}], + command=lambda ctx: {"count": ctx.collection, "query": {"x": {"$gt": 4}}}, + expected={"n": 2, "ok": 1.0}, + msg="count should support $gt in query", + ), + CommandTestCase( + "query_gte", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 5}, {"_id": 3, "x": 10}], + command=lambda ctx: {"count": ctx.collection, "query": {"x": {"$gte": 5}}}, + expected={"n": 2, "ok": 1.0}, + msg="count should support $gte in query", + ), + CommandTestCase( + "query_lt", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 5}, {"_id": 3, "x": 10}], + command=lambda ctx: {"count": ctx.collection, "query": {"x": {"$lt": 5}}}, + expected={"n": 1, "ok": 1.0}, + msg="count should support $lt in query", + ), + CommandTestCase( + "query_lte", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 5}, {"_id": 3, "x": 10}], + command=lambda ctx: {"count": ctx.collection, "query": {"x": {"$lte": 5}}}, + expected={"n": 2, "ok": 1.0}, + msg="count should support $lte in query", + ), + CommandTestCase( + "query_in", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 5}, {"_id": 3, "x": 10}], + command=lambda ctx: {"count": ctx.collection, "query": {"x": {"$in": [1, 10]}}}, + expected={"n": 2, "ok": 1.0}, + msg="count should support $in in query", + ), + CommandTestCase( + "query_nin", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 5}, {"_id": 3, "x": 10}], + command=lambda ctx: {"count": ctx.collection, "query": {"x": {"$nin": [1, 10]}}}, + expected={"n": 1, "ok": 1.0}, + msg="count should support $nin in query", + ), + # Logical operators. + CommandTestCase( + "query_and", + docs=[ + {"_id": 1, "x": 1, "y": 10}, + {"_id": 2, "x": 5, "y": 10}, + {"_id": 3, "x": 5, "y": 20}, + ], + command=lambda ctx: { + "count": ctx.collection, + "query": {"$and": [{"x": {"$gt": 2}}, {"y": 10}]}, + }, + expected={"n": 1, "ok": 1.0}, + msg="count should support $and in query", + ), + CommandTestCase( + "query_or", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 5}, {"_id": 3, "x": 10}], + command=lambda ctx: { + "count": ctx.collection, + "query": {"$or": [{"x": 1}, {"x": 10}]}, + }, + expected={"n": 2, "ok": 1.0}, + msg="count should support $or in query", + ), + CommandTestCase( + "query_nor", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 5}, {"_id": 3, "x": 10}], + command=lambda ctx: { + "count": ctx.collection, + "query": {"$nor": [{"x": 1}, {"x": 10}]}, + }, + expected={"n": 1, "ok": 1.0}, + msg="count should support $nor in query", + ), + CommandTestCase( + "query_not", + docs=[{"_id": 1, "x": 3}, {"_id": 2, "x": 7}, {"_id": 3, "x": 10}], + command=lambda ctx: { + "count": ctx.collection, + "query": {"x": {"$not": {"$gt": 5}}}, + }, + expected={"n": 1, "ok": 1.0}, + msg="count should support $not in query", + ), + # Array operators. + CommandTestCase( + "query_all", + docs=[ + {"_id": 1, "arr": [1, 2, 3]}, + {"_id": 2, "arr": [1, 3]}, + {"_id": 3, "arr": [2, 3]}, + ], + command=lambda ctx: {"count": ctx.collection, "query": {"arr": {"$all": [1, 3]}}}, + expected={"n": 2, "ok": 1.0}, + msg="count should support $all in query", + ), + CommandTestCase( + "query_elemMatch", + docs=[ + {"_id": 1, "arr": [{"a": 1, "b": 2}]}, + {"_id": 2, "arr": [{"a": 1, "b": 5}]}, + {"_id": 3, "arr": [{"a": 3, "b": 2}]}, + ], + command=lambda ctx: { + "count": ctx.collection, + "query": {"arr": {"$elemMatch": {"a": 1, "b": {"$gt": 1}}}}, + }, + expected={"n": 2, "ok": 1.0}, + msg="count should support $elemMatch in query", + ), + CommandTestCase( + "query_size", + docs=[ + {"_id": 1, "arr": [1, 2, 3]}, + {"_id": 2, "arr": [1]}, + {"_id": 3, "arr": [1, 2]}, + ], + command=lambda ctx: {"count": ctx.collection, "query": {"arr": {"$size": 2}}}, + expected={"n": 1, "ok": 1.0}, + msg="count should support $size in query", + ), + # Bitwise operators. + CommandTestCase( + "query_bitsAllSet", + docs=[{"_id": 1, "flags": 7}, {"_id": 2, "flags": 3}, {"_id": 3, "flags": 15}], + command=lambda ctx: {"count": ctx.collection, "query": {"flags": {"$bitsAllSet": 5}}}, + expected={"n": 2, "ok": 1.0}, + msg="count should support $bitsAllSet in query", + ), + CommandTestCase( + "query_bitsAllClear", + docs=[{"_id": 1, "flags": 7}, {"_id": 2, "flags": 8}, {"_id": 3, "flags": 15}], + command=lambda ctx: {"count": ctx.collection, "query": {"flags": {"$bitsAllClear": 7}}}, + expected={"n": 1, "ok": 1.0}, + msg="count should support $bitsAllClear in query", + ), + CommandTestCase( + "query_bitsAnySet", + docs=[{"_id": 1, "flags": 4}, {"_id": 2, "flags": 8}, {"_id": 3, "flags": 16}], + command=lambda ctx: {"count": ctx.collection, "query": {"flags": {"$bitsAnySet": 6}}}, + expected={"n": 1, "ok": 1.0}, + msg="count should support $bitsAnySet in query", + ), + CommandTestCase( + "query_bitsAnyClear", + docs=[{"_id": 1, "flags": 7}, {"_id": 2, "flags": 3}, {"_id": 3, "flags": 15}], + command=lambda ctx: {"count": ctx.collection, "query": {"flags": {"$bitsAnyClear": 12}}}, + expected={"n": 2, "ok": 1.0}, + msg="count should support $bitsAnyClear in query", + ), + # Miscellaneous operators. + CommandTestCase( + "query_exists", + docs=[{"_id": 1, "x": 1}, {"_id": 2}, {"_id": 3, "x": 10}], + command=lambda ctx: {"count": ctx.collection, "query": {"x": {"$exists": True}}}, + expected={"n": 2, "ok": 1.0}, + msg="count should support $exists in query", + ), + CommandTestCase( + "query_type", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": "hello"}, {"_id": 3, "x": 3.14}], + command=lambda ctx: {"count": ctx.collection, "query": {"x": {"$type": "string"}}}, + expected={"n": 1, "ok": 1.0}, + msg="count should support $type in query", + ), + CommandTestCase( + "query_regex", + docs=[{"_id": 1, "s": "hello"}, {"_id": 2, "s": "world"}, {"_id": 3, "s": "help"}], + command=lambda ctx: {"count": ctx.collection, "query": {"s": {"$regex": "^hel"}}}, + expected={"n": 2, "ok": 1.0}, + msg="count should support $regex in query", + ), + CommandTestCase( + "query_mod", + docs=[{"_id": 1, "x": 3}, {"_id": 2, "x": 6}, {"_id": 3, "x": 7}], + command=lambda ctx: {"count": ctx.collection, "query": {"x": {"$mod": [3, 0]}}}, + expected={"n": 2, "ok": 1.0}, + msg="count should support $mod in query", + ), + CommandTestCase( + "query_expr", + docs=[{"_id": 1, "a": 5, "b": 3}, {"_id": 2, "a": 1, "b": 10}, {"_id": 3, "a": 4, "b": 4}], + command=lambda ctx: { + "count": ctx.collection, + "query": {"$expr": {"$gt": ["$a", "$b"]}}, + }, + expected={"n": 1, "ok": 1.0}, + msg="count should support $expr in query", + ), + CommandTestCase( + "query_where", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": 2}, {"_id": 3, "x": 3}], + command=lambda ctx: {"count": ctx.collection, "query": {"$where": "this.x > 1"}}, + expected={"n": 2, "ok": 1.0}, + msg="count should support $where in query", + ), + CommandTestCase( + "query_jsonSchema", + docs=[{"_id": 1, "x": 1}, {"_id": 2, "x": "hello"}, {"_id": 3, "x": 3}], + command=lambda ctx: { + "count": ctx.collection, + "query": { + "$jsonSchema": { + "properties": {"x": {"bsonType": "int"}}, + "required": ["x"], + } + }, + }, + expected={"n": 2, "ok": 1.0}, + msg="count should support $jsonSchema in query", + ), + # Geospatial operators. + CommandTestCase( + "query_geoWithin", + docs=[ + {"_id": 1, "loc": [0, 0]}, + {"_id": 2, "loc": [50, 50]}, + {"_id": 3, "loc": [1, 1]}, + ], + command=lambda ctx: { + "count": ctx.collection, + "query": {"loc": {"$geoWithin": {"$center": [[0, 0], 10]}}}, + }, + expected={"n": 2, "ok": 1.0}, + msg="count should support $geoWithin in query", + ), + CommandTestCase( + "query_geoIntersects", + docs=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [50, 50]}}, + ], + command=lambda ctx: { + "count": ctx.collection, + "query": { + "loc": { + "$geoIntersects": { + "$geometry": { + "type": "Polygon", + "coordinates": [[[-1, -1], [1, -1], [1, 1], [-1, 1], [-1, -1]]], + } + } + } + }, + }, + expected={"n": 1, "ok": 1.0}, + msg="count should support $geoIntersects in query", + ), + CommandTestCase( + "query_near", + indexes=[IndexModel([("loc", "2dsphere")])], + docs=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [50, 50]}}, + ], + command=lambda ctx: { + "count": ctx.collection, + "query": { + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + "$maxDistance": 100, + } + } + }, + }, + expected={"n": 1, "ok": 1.0}, + msg="count should support $near in query", + ), + CommandTestCase( + "query_nearSphere", + indexes=[IndexModel([("loc", "2dsphere")])], + docs=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [50, 50]}}, + ], + command=lambda ctx: { + "count": ctx.collection, + "query": { + "loc": { + "$nearSphere": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + "$maxDistance": 100, + } + } + }, + }, + expected={"n": 1, "ok": 1.0}, + msg="count should support $nearSphere in query", + ), + # Text search. + CommandTestCase( + "query_text", + indexes=[IndexModel([("s", "text")])], + docs=[{"_id": 1, "s": "hello world"}, {"_id": 2, "s": "goodbye"}], + command=lambda ctx: { + "count": ctx.collection, + "query": {"$text": {"$search": "hello"}}, + }, + expected={"n": 1, "ok": 1.0}, + msg="count should support $text in query", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(COUNT_QUERY_OPERATOR_TESTS)) +def test_count_query_operators(database_client, collection, test): + """Test count command query operator wiring.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_read_concern.py b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_read_concern.py new file mode 100644 index 00000000..f09ed478 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_read_concern.py @@ -0,0 +1,625 @@ +"""Tests for count command readConcern behavior and type strictness.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + ILLEGAL_OPERATION_ERROR, + INVALID_OPTIONS_ERROR, + NOT_A_REPLICA_SET_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [ReadConcern Behavior]: the count command accepts valid readConcern +# levels. +COUNT_READ_CONCERN_BEHAVIOR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "readconcern_local", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"level": "local"}, + }, + expected={"n": 5, "ok": 1.0}, + msg="count with readConcern level 'local' should succeed", + ), + CommandTestCase( + "readconcern_available", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"level": "available"}, + }, + expected={"n": 5, "ok": 1.0}, + msg="count with readConcern level 'available' should succeed", + ), + CommandTestCase( + "readconcern_majority", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"level": "majority"}, + }, + expected={"n": 5, "ok": 1.0}, + msg="count with readConcern level 'majority' should succeed", + ), + CommandTestCase( + "readconcern_empty_doc", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {}, + }, + expected={"n": 5, "ok": 1.0}, + msg="count with empty readConcern document should succeed with default behavior", + ), + CommandTestCase( + "readconcern_level_null", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"level": None}, + }, + expected={"n": 5, "ok": 1.0}, + msg="count with readConcern level null should succeed with default behavior", + ), +] + +# Property [Type Strictness: readConcern]: the readConcern field validates +# type, sub-field types, and level values. +COUNT_TYPE_STRICTNESS_READ_CONCERN_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "type_readconcern_string", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "readConcern": "local"}, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject string for readConcern", + ), + CommandTestCase( + "type_readconcern_int32", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "readConcern": 42}, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject int32 for readConcern", + ), + CommandTestCase( + "type_readconcern_int64", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "readConcern": Int64(1)}, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject Int64 for readConcern", + ), + CommandTestCase( + "type_readconcern_double", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "readConcern": 3.14}, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject double for readConcern", + ), + CommandTestCase( + "type_readconcern_decimal128", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": Decimal128("1"), + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject Decimal128 for readConcern", + ), + CommandTestCase( + "type_readconcern_bool", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "readConcern": True}, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject bool for readConcern", + ), + CommandTestCase( + "type_readconcern_array", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "readConcern": [1, 2]}, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject array for readConcern", + ), + CommandTestCase( + "type_readconcern_objectid", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "readConcern": ObjectId()}, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject ObjectId for readConcern", + ), + CommandTestCase( + "type_readconcern_datetime", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": datetime(2024, 1, 1, tzinfo=timezone.utc), + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject datetime for readConcern", + ), + CommandTestCase( + "type_readconcern_timestamp", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": Timestamp(1, 1), + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject Timestamp for readConcern", + ), + CommandTestCase( + "type_readconcern_binary", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": Binary(b"\x01\x02"), + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject Binary for readConcern", + ), + CommandTestCase( + "type_readconcern_regex", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": Regex("^abc"), + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject Regex for readConcern", + ), + CommandTestCase( + "type_readconcern_code", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": Code("function(){}"), + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject Code for readConcern", + ), + CommandTestCase( + "type_readconcern_code_with_scope", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": Code("function(){}", {"x": 1}), + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject Code with scope for readConcern", + ), + CommandTestCase( + "type_readconcern_minkey", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "readConcern": MinKey()}, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject MinKey for readConcern", + ), + CommandTestCase( + "type_readconcern_maxkey", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "readConcern": MaxKey()}, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject MaxKey for readConcern", + ), + CommandTestCase( + "type_readconcern_level_int32", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"level": 42}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject int32 for readConcern level sub-field", + ), + CommandTestCase( + "type_readconcern_level_bool", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"level": True}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject bool for readConcern level sub-field", + ), + CommandTestCase( + "type_readconcern_level_array", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"level": ["local"]}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject array for readConcern level sub-field", + ), + CommandTestCase( + "type_readconcern_level_object", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"level": {"a": 1}}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject object for readConcern level sub-field", + ), + CommandTestCase( + "type_readconcern_level_int64", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"level": Int64(1)}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject Int64 for readConcern level sub-field", + ), + CommandTestCase( + "type_readconcern_level_double", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"level": 3.14}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject double for readConcern level sub-field", + ), + CommandTestCase( + "type_readconcern_level_decimal128", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"level": Decimal128("1")}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject Decimal128 for readConcern level sub-field", + ), + CommandTestCase( + "type_readconcern_level_objectid", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"level": ObjectId()}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject ObjectId for readConcern level sub-field", + ), + CommandTestCase( + "type_readconcern_level_datetime", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"level": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject datetime for readConcern level sub-field", + ), + CommandTestCase( + "type_readconcern_level_timestamp", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"level": Timestamp(1, 1)}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject Timestamp for readConcern level sub-field", + ), + CommandTestCase( + "type_readconcern_level_binary", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"level": Binary(b"\x01\x02")}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject Binary for readConcern level sub-field", + ), + CommandTestCase( + "type_readconcern_level_regex", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"level": Regex("^abc")}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject Regex for readConcern level sub-field", + ), + CommandTestCase( + "type_readconcern_level_code", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"level": Code("function(){}")}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject Code for readConcern level sub-field", + ), + CommandTestCase( + "type_readconcern_level_code_with_scope", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"level": Code("function(){}", {"x": 1})}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject Code with scope for readConcern level sub-field", + ), + CommandTestCase( + "type_readconcern_level_minkey", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"level": MinKey()}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject MinKey for readConcern level sub-field", + ), + CommandTestCase( + "type_readconcern_level_maxkey", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"level": MaxKey()}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg="count should reject MaxKey for readConcern level sub-field", + ), + CommandTestCase( + "type_readconcern_level_empty_string", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"level": ""}, + }, + error_code=BAD_VALUE_ERROR, + msg="count should reject empty string for readConcern level", + ), + CommandTestCase( + "type_readconcern_level_unknown", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"level": "unknown"}, + }, + error_code=BAD_VALUE_ERROR, + msg="count should reject unknown readConcern level string", + ), + CommandTestCase( + "type_readconcern_level_wrong_case", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"level": "LOCAL"}, + }, + error_code=BAD_VALUE_ERROR, + msg="count should reject wrong-case readConcern level string", + ), + CommandTestCase( + "type_readconcern_linearizable", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"level": "linearizable"}, + }, + error_code=NOT_A_REPLICA_SET_ERROR, + msg="count with linearizable readConcern should fail on non-replica-set", + ), + CommandTestCase( + "type_readconcern_snapshot", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"level": "snapshot"}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="count should reject snapshot readConcern level", + ), + CommandTestCase( + "type_readconcern_unknown_field", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"level": "local", "unknownField": 1}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="count should reject unknown fields in readConcern document", + ), +] + +# Property [ReadConcern afterClusterTime]: afterClusterTime validates type +# and is rejected on standalone. +COUNT_READ_CONCERN_AFTER_CLUSTER_TIME_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "rc_after_cluster_time_timestamp", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"afterClusterTime": Timestamp(1, 1)}, + }, + error_code=ILLEGAL_OPERATION_ERROR, + msg="count afterClusterTime should be rejected on standalone", + ), + *[ + CommandTestCase( + f"rc_after_cluster_time_{tid}", + docs=[{"_id": 1}], + command=lambda ctx, v=val: { + "count": ctx.collection, + "readConcern": {"afterClusterTime": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"count afterClusterTime as {tid} should produce TypeMismatch", + ) + for tid, val in [ + ("null", None), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", Decimal128("1")), + ("string", "hello"), + ("bool", True), + ("array", [1, 2]), + ("object", {"a": 1}), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("binary", Binary(b"\x01\x02")), + ("regex", Regex("^abc")), + ("code", Code("function(){}")), + ("code_with_scope", Code("function(){}", {"x": 1})), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], +] + +# Property [ReadConcern atClusterTime]: atClusterTime validates type and +# requires snapshot read concern level. +COUNT_READ_CONCERN_AT_CLUSTER_TIME_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "rc_at_cluster_time_timestamp", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"atClusterTime": Timestamp(1, 1)}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="count atClusterTime without snapshot level should be rejected", + ), + *[ + CommandTestCase( + f"rc_at_cluster_time_{tid}", + docs=[{"_id": 1}], + command=lambda ctx, v=val: { + "count": ctx.collection, + "readConcern": {"atClusterTime": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"count atClusterTime as {tid} should produce TypeMismatch", + ) + for tid, val in [ + ("null", None), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", Decimal128("1")), + ("string", "hello"), + ("bool", True), + ("array", [1, 2]), + ("object", {"a": 1}), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("binary", Binary(b"\x01\x02")), + ("regex", Regex("^abc")), + ("code", Code("function(){}")), + ("code_with_scope", Code("function(){}", {"x": 1})), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], +] + +# Property [ReadConcern provenance]: the provenance sub-field validates type +# and enum value. +COUNT_READ_CONCERN_PROVENANCE_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"rc_provenance_{prov}", + docs=[{"_id": 1}], + command=lambda ctx, p=prov: { + "count": ctx.collection, + "readConcern": {"provenance": p}, + }, + expected={"n": 1, "ok": 1.0}, + msg=f"count readConcern provenance '{prov}' should succeed", + ) + for prov in [ + "clientSupplied", + "implicitDefault", + "customDefault", + "getLastErrorDefaults", + ] + ], + CommandTestCase( + "rc_provenance_null", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"provenance": None}, + }, + expected={"n": 1, "ok": 1.0}, + msg="count readConcern provenance null should succeed", + ), + CommandTestCase( + "rc_provenance_invalid", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "readConcern": {"provenance": "invalid"}, + }, + error_code=BAD_VALUE_ERROR, + msg="count readConcern provenance invalid string should be rejected", + ), + *[ + CommandTestCase( + f"rc_provenance_type_{tid}", + docs=[{"_id": 1}], + command=lambda ctx, v=val: { + "count": ctx.collection, + "readConcern": {"provenance": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"count readConcern provenance as {tid} should produce TypeMismatch", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", Decimal128("1")), + ("bool", True), + ("array", [1, 2]), + ("object", {"a": 1}), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02")), + ("regex", Regex("^abc")), + ("code", Code("function(){}")), + ("code_with_scope", Code("function(){}", {"x": 1})), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], +] + +COUNT_READ_CONCERN_ALL_TESTS: list[CommandTestCase] = ( + COUNT_READ_CONCERN_BEHAVIOR_TESTS + + COUNT_TYPE_STRICTNESS_READ_CONCERN_TESTS + + COUNT_READ_CONCERN_AFTER_CLUSTER_TIME_TESTS + + COUNT_READ_CONCERN_AT_CLUSTER_TIME_TESTS + + COUNT_READ_CONCERN_PROVENANCE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(COUNT_READ_CONCERN_ALL_TESTS)) +def test_count_read_concern(database_client, collection, test): + """Test count command readConcern behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_skip_limit.py b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_skip_limit.py new file mode 100644 index 00000000..a815f79c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_skip_limit.py @@ -0,0 +1,364 @@ +"""Tests for count command skip and limit behavior.""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +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_LARGE_EXPONENT, + DECIMAL128_MIN_POSITIVE, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_TWO_AND_HALF, + DOUBLE_MIN_SUBNORMAL, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_NAN, + INT64_MAX, +) + +# Property [Skip Behavior]: skip reduces the counted result by the specified +# number of documents, with no effect when zero or absent. +COUNT_SKIP_BEHAVIOR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "skip_zero", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "skip": 0}, + expected={"n": 5, "ok": 1.0}, + msg="count with skip=0 should return full count", + ), + CommandTestCase( + "skip_partial", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": 3}, + expected={"n": 7, "ok": 1.0}, + msg="count with skip=3 and 10 docs should return 7", + ), + CommandTestCase( + "skip_equals_count", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "skip": 5}, + expected={"n": 0, "ok": 1.0}, + msg="count with skip equal to document count should return 0", + ), + CommandTestCase( + "skip_exceeds_count", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "skip": 10}, + expected={"n": 0, "ok": 1.0}, + msg="count with skip exceeding document count should return 0", + ), + CommandTestCase( + "skip_int64_max", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "skip": INT64_MAX}, + expected={"n": 0, "ok": 1.0}, + msg="count with skip=Int64(max) should skip all documents", + ), +] + +# Property [Skip and Limit Interaction]: skip and limit combine to constrain +# the counted result. +COUNT_SKIP_LIMIT_INTERACTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "skip_limit_basic", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": 3, "limit": 5}, + expected={"n": 5, "ok": 1.0}, + msg="count with skip=3, limit=5, 10 docs should return min(5, max(0, 10-3)) = 5", + ), + CommandTestCase( + "skip_limit_limit_caps", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": 8, "limit": 5}, + expected={"n": 2, "ok": 1.0}, + msg="count with skip=8, limit=5, 10 docs should return min(5, max(0, 10-8)) = 2", + ), + CommandTestCase( + "skip_limit_zero_means_no_limit", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": 3, "limit": 0}, + expected={"n": 7, "ok": 1.0}, + msg="count with limit=0 should mean no limit: max(0, 10-3) = 7", + ), + CommandTestCase( + "skip_limit_skip_exceeds_matching", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": 15, "limit": 5}, + expected={"n": 0, "ok": 1.0}, + msg="count should return 0 when skip exceeds matching document count", + ), + CommandTestCase( + "skip_limit_skip_exceeds_no_limit", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": 15, "limit": 0}, + expected={"n": 0, "ok": 1.0}, + msg="count should return 0 when skip exceeds matching count even with no limit", + ), + CommandTestCase( + "skip_limit_negative_limit", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": 2, "limit": -3}, + expected={"n": 3, "ok": 1.0}, + msg="count with negative limit should use abs value: min(3, max(0, 10-2)) = 3", + ), + CommandTestCase( + "skip_limit_with_query", + docs=[{"_id": i, "x": i} for i in range(10)], + command=lambda ctx: { + "count": ctx.collection, + "query": {"x": {"$gt": 4}}, + "skip": 1, + "limit": 2, + }, + expected={"n": 2, "ok": 1.0}, + msg="count with query (5 match), skip=1, limit=2 should return min(2, max(0, 5-1)) = 2", + ), +] + +# Property [Limit Behavior]: limit caps the count result with special handling +# of zero and negative values. +COUNT_LIMIT_BEHAVIOR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "limit_zero_int", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "limit": 0}, + expected={"n": 5, "ok": 1.0}, + msg="count with limit=0 should return full count (no limit)", + ), + CommandTestCase( + "limit_zero_double", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "limit": DOUBLE_ZERO}, + expected={"n": 5, "ok": 1.0}, + msg="count with limit=0.0 should return full count (no limit)", + ), + CommandTestCase( + "limit_negative_three", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "limit": -3}, + expected={"n": 3, "ok": 1.0}, + msg="count with limit=-3 should be treated as abs(3)", + ), + CommandTestCase( + "limit_negative_exceeds_count", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "limit": -10}, + expected={"n": 5, "ok": 1.0}, + msg="count with limit=-10 and 5 docs should return 5 (abs exceeds count)", + ), + CommandTestCase( + "limit_neg_zero_double", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "limit": DOUBLE_NEGATIVE_ZERO}, + expected={"n": 5, "ok": 1.0}, + msg="count with limit=-0.0 should be treated as 0 (no limit)", + ), + CommandTestCase( + "limit_neg_zero_decimal", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: { + "count": ctx.collection, + "limit": DECIMAL128_NEGATIVE_ZERO, + }, + expected={"n": 5, "ok": 1.0}, + msg='count with limit=Decimal128("-0") should be treated as 0 (no limit)', + ), + CommandTestCase( + "limit_neg_zero_decimal_fractional", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: { + "count": ctx.collection, + "limit": Decimal128("-0.0"), + }, + expected={"n": 5, "ok": 1.0}, + msg='count with limit=Decimal128("-0.0") should be treated as 0 (no limit)', + ), + CommandTestCase( + "limit_negative_int64", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "limit": Int64(-3)}, + expected={"n": 3, "ok": 1.0}, + msg="count with limit=Int64(-3) should be treated as abs(3)", + ), + CommandTestCase( + "limit_negative_decimal128", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "limit": Decimal128("-3")}, + expected={"n": 3, "ok": 1.0}, + msg='count with limit=Decimal128("-3") should be treated as abs(3)', + ), + CommandTestCase( + "limit_int64_max", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "limit": INT64_MAX}, + expected={"n": 5, "ok": 1.0}, + msg="count with limit=Int64(max) should effectively mean no limit", + ), +] + +# Property [Skip Coercion]: non-integer numeric skip values are coerced to +# integers before being applied. +COUNT_SKIP_COERCION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "skip_coerce_double_1_5", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": 1.5}, + expected={"n": 9, "ok": 1.0}, + msg="count skip=1.5 (double) should truncate toward zero to 1", + ), + CommandTestCase( + "skip_coerce_double_2_5", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": 2.5}, + expected={"n": 8, "ok": 1.0}, + msg="count skip=2.5 (double) should truncate toward zero to 2", + ), + CommandTestCase( + "skip_coerce_double_3_5", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": 3.5}, + expected={"n": 7, "ok": 1.0}, + msg="count skip=3.5 (double) should truncate toward zero to 3", + ), + CommandTestCase( + "skip_coerce_decimal128_1_5", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": DECIMAL128_ONE_AND_HALF}, + expected={"n": 8, "ok": 1.0}, + msg='count skip=Decimal128("1.5") should use banker\'s rounding to 2', + ), + CommandTestCase( + "skip_coerce_decimal128_2_5", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": DECIMAL128_TWO_AND_HALF}, + expected={"n": 8, "ok": 1.0}, + msg='count skip=Decimal128("2.5") should use banker\'s rounding to 2', + ), + CommandTestCase( + "skip_coerce_decimal128_3_5", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": Decimal128("3.5")}, + expected={"n": 6, "ok": 1.0}, + msg='count skip=Decimal128("3.5") should use banker\'s rounding to 4', + ), + CommandTestCase( + "skip_coerce_decimal128_4_5", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": Decimal128("4.5")}, + expected={"n": 6, "ok": 1.0}, + msg='count skip=Decimal128("4.5") should use banker\'s rounding to 4', + ), + CommandTestCase( + "skip_coerce_double_nan", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": FLOAT_NAN}, + expected={"n": 10, "ok": 1.0}, + msg="count skip=NaN (double) should be treated as 0", + ), + CommandTestCase( + "skip_coerce_double_neg_nan", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": FLOAT_NEGATIVE_NAN}, + expected={"n": 10, "ok": 1.0}, + msg="count skip=-NaN (double) should be treated as 0", + ), + CommandTestCase( + "skip_coerce_decimal128_nan", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": DECIMAL128_NAN}, + expected={"n": 10, "ok": 1.0}, + msg="count skip=NaN (Decimal128) should be treated as 0", + ), + CommandTestCase( + "skip_coerce_decimal128_neg_nan", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": DECIMAL128_NEGATIVE_NAN}, + expected={"n": 10, "ok": 1.0}, + msg="count skip=-NaN (Decimal128) should be treated as 0", + ), + CommandTestCase( + "skip_coerce_double_pos_inf", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": FLOAT_INFINITY}, + expected={"n": 0, "ok": 1.0}, + msg="count skip=+Infinity (double) should skip all documents", + ), + CommandTestCase( + "skip_coerce_decimal128_pos_inf", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": DECIMAL128_INFINITY}, + expected={"n": 0, "ok": 1.0}, + msg="count skip=+Infinity (Decimal128) should skip all documents", + ), + CommandTestCase( + "skip_coerce_double_neg_zero", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": DOUBLE_NEGATIVE_ZERO}, + expected={"n": 10, "ok": 1.0}, + msg="count skip=-0.0 (double) should be treated as 0", + ), + CommandTestCase( + "skip_coerce_decimal128_neg_zero", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": DECIMAL128_NEGATIVE_ZERO}, + expected={"n": 10, "ok": 1.0}, + msg='count skip=Decimal128("-0") should be treated as 0', + ), + CommandTestCase( + "skip_coerce_subnormal_double", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": DOUBLE_MIN_SUBNORMAL}, + expected={"n": 10, "ok": 1.0}, + msg="count skip=subnormal double should truncate to 0", + ), + CommandTestCase( + "skip_coerce_decimal128_tiny_exponent", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": DECIMAL128_MIN_POSITIVE}, + expected={"n": 10, "ok": 1.0}, + msg='count skip=Decimal128("1E-6176") should round to 0', + ), + CommandTestCase( + "skip_coerce_decimal128_huge_exponent", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": DECIMAL128_LARGE_EXPONENT}, + expected={"n": 0, "ok": 1.0}, + msg='count skip=Decimal128("1E+6144") should skip all documents', + ), +] + +COUNT_SKIP_LIMIT_TESTS: list[CommandTestCase] = ( + COUNT_SKIP_BEHAVIOR_TESTS + + COUNT_SKIP_LIMIT_INTERACTION_TESTS + + COUNT_LIMIT_BEHAVIOR_TESTS + + COUNT_SKIP_COERCION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(COUNT_SKIP_LIMIT_TESTS)) +def test_count_skip_limit(database_client, collection, test): + """Test count command skip and limit behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_type_collation.py b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_type_collation.py new file mode 100644 index 00000000..2796ab5c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_type_collation.py @@ -0,0 +1,144 @@ +"""Tests for count command collation type strictness and locale validation.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + MISSING_FIELD_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Type Strictness: collation (type rejected)]: only document type is +# accepted for the collation field (in addition to null); all other BSON types +# produce a TypeMismatch error. +COUNT_TYPE_STRICTNESS_COLLATION_TYPE_REJECTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"type_collation_{tid}", + docs=[{"_id": 1}], + command=lambda ctx, v=val: {"count": ctx.collection, "collation": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"count should reject {tid} for collation", + ) + for tid, val in [ + ("string", "en"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", Decimal128("1")), + ("bool", True), + ("array", [1, 2]), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02")), + ("regex", Regex("^abc")), + ("code", Code("function(){}")), + ("code_with_scope", Code("function(){}", {"x": 1})), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Type Strictness: collation (locale)]: the locale sub-field is +# required and validates type and value. +COUNT_TYPE_STRICTNESS_COLLATION_LOCALE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "type_collation_locale_missing", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"strength": 2}, + }, + error_code=MISSING_FIELD_ERROR, + msg="count should reject collation with missing locale", + ), + CommandTestCase( + "type_collation_locale_null", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": None}, + }, + error_code=MISSING_FIELD_ERROR, + msg="count should reject collation with null locale", + ), + *[ + CommandTestCase( + f"type_collation_locale_{tid}", + docs=[{"_id": 1}], + command=lambda ctx, v=val: { + "count": ctx.collection, + "collation": {"locale": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"count should reject {tid} for collation locale", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", Decimal128("1")), + ("bool", True), + ("array", ["en"]), + ("object", {"name": "en"}), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02")), + ("regex", Regex("^en")), + ("code", Code("function(){}")), + ("code_with_scope", Code("function(){}", {"x": 1})), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + *[ + CommandTestCase( + f"type_collation_locale_{tid}", + docs=[{"_id": 1}], + command=lambda ctx, v=val: { + "count": ctx.collection, + "collation": {"locale": v}, + }, + error_code=BAD_VALUE_ERROR, + msg=f"count should reject {tid} for collation locale", + ) + for tid, val in [ + ("empty", ""), + ("invalid", "invalid_locale_xyz"), + ("wrong_case", "EN"), + ] + ], +] + +COUNT_TYPE_COLLATION_TESTS: list[CommandTestCase] = ( + COUNT_TYPE_STRICTNESS_COLLATION_TYPE_REJECTED_TESTS + + COUNT_TYPE_STRICTNESS_COLLATION_LOCALE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(COUNT_TYPE_COLLATION_TESTS)) +def test_count_type_collation(database_client, collection, test): + """Test count command collation type strictness.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_type_limit.py b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_type_limit.py new file mode 100644 index 00000000..e2c567e5 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_type_limit.py @@ -0,0 +1,163 @@ +"""Tests for count command limit type strictness.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import BAD_VALUE_ERROR, FAILED_TO_PARSE_ERROR +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_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_ONE_AND_HALF, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + FLOAT_NEGATIVE_NAN, + INT64_MIN, +) + +# Property [Type Strictness: limit (accepted)]: int32, int64, whole-number +# doubles, and whole-number Decimal128 (including trailing zeros and scientific +# notation) are accepted for the limit field. +COUNT_TYPE_STRICTNESS_LIMIT_ACCEPTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "type_limit_int32", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "limit": 3}, + expected={"n": 3, "ok": 1.0}, + msg="count should accept int32 for limit", + ), + CommandTestCase( + "type_limit_int64", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "limit": Int64(3)}, + expected={"n": 3, "ok": 1.0}, + msg="count should accept Int64 for limit", + ), + CommandTestCase( + "type_limit_whole_double", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "limit": 3.0}, + expected={"n": 3, "ok": 1.0}, + msg="count should accept whole-number double for limit", + ), + CommandTestCase( + "type_limit_whole_decimal128", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "limit": Decimal128("3")}, + expected={"n": 3, "ok": 1.0}, + msg="count should accept whole-number Decimal128 for limit", + ), + CommandTestCase( + "type_limit_decimal128_trailing_zeros", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "limit": Decimal128("3.00")}, + expected={"n": 3, "ok": 1.0}, + msg="count should accept Decimal128 with trailing zeros for limit", + ), + CommandTestCase( + "type_limit_decimal128_sci_notation", + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "limit": Decimal128("30E-1")}, + expected={"n": 3, "ok": 1.0}, + msg="count should accept Decimal128 in scientific notation for limit", + ), +] + +# Property [Type Strictness: limit (rejected)]: invalid types and +# non-representable numeric values are rejected for the limit field. +COUNT_TYPE_STRICTNESS_LIMIT_REJECTED_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"type_limit_{tid}", + docs=[{"_id": 1}], + command=lambda ctx, v=val: {"count": ctx.collection, "limit": v}, + error_code=BAD_VALUE_ERROR, + msg=f"count should reject {tid} for limit", + ) + for tid, val in [ + ("string", "hello"), + ("bool", True), + ("array", [1, 2]), + ("object", {"a": 1}), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02")), + ("regex", Regex("^abc")), + ("code", Code("function(){}")), + ("code_with_scope", Code("function(){}", {"x": 1})), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + *[ + CommandTestCase( + f"type_limit_{tid}", + docs=[{"_id": 1}], + command=lambda ctx, v=val: {"count": ctx.collection, "limit": v}, + error_code=FAILED_TO_PARSE_ERROR, + msg=f"count should reject {tid} for limit", + ) + for tid, val in [ + ("fractional_double", 1.5), + ("fractional_decimal128", DECIMAL128_ONE_AND_HALF), + ("nan_double", FLOAT_NAN), + ("neg_nan_double", FLOAT_NEGATIVE_NAN), + ("nan_decimal128", Decimal128("NaN")), + ("neg_nan_decimal128", DECIMAL128_NEGATIVE_NAN), + ("infinity_double", FLOAT_INFINITY), + ("neg_infinity_double", FLOAT_NEGATIVE_INFINITY), + ("infinity_decimal128", DECIMAL128_INFINITY), + ("neg_infinity_decimal128", DECIMAL128_NEGATIVE_INFINITY), + ("exceeds_int64_decimal128", Decimal128("9999999999999999999999")), + ] + ], + CommandTestCase( + "type_limit_int64_min", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "limit": INT64_MIN}, + error_code=BAD_VALUE_ERROR, + msg="count should reject Int64 minimum value for limit", + ), + CommandTestCase( + "type_limit_decimal128_int64_min", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "limit": Decimal128("-9223372036854775808"), + }, + error_code=BAD_VALUE_ERROR, + msg="count should reject Decimal128 representing int64 minimum for limit", + ), +] + +COUNT_TYPE_STRICTNESS_LIMIT_TESTS = ( + COUNT_TYPE_STRICTNESS_LIMIT_ACCEPTED_TESTS + COUNT_TYPE_STRICTNESS_LIMIT_REJECTED_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(COUNT_TYPE_STRICTNESS_LIMIT_TESTS)) +def test_count_type_limit(database_client, collection, test): + """Test count command limit type strictness.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_type_query.py b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_type_query.py new file mode 100644 index 00000000..6b646f3f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_type_query.py @@ -0,0 +1,63 @@ +"""Tests for count command query type strictness.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Type Strictness: query]: only document (object) type is accepted +# for the query field (in addition to null); all other BSON types produce a +# TypeMismatch error. +COUNT_TYPE_STRICTNESS_QUERY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"type_query_{tid}", + docs=[{"_id": 1}], + command=lambda ctx, v=val: {"count": ctx.collection, "query": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"count should reject {tid} for query field", + ) + for tid, val in [ + ("string", "invalid"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", Decimal128("1")), + ("bool", True), + ("array", [1, 2]), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02")), + ("regex", Regex("^abc")), + ("code", Code("function(){}")), + ("code_with_scope", Code("function(){}", {"x": 1})), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + + +@pytest.mark.parametrize("test", pytest_params(COUNT_TYPE_STRICTNESS_QUERY_TESTS)) +def test_count_type_query(database_client, collection, test): + """Test count command query type strictness.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_type_skip.py b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_type_skip.py new file mode 100644 index 00000000..9a2fbd84 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_type_skip.py @@ -0,0 +1,170 @@ +"""Tests for count command skip type strictness.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import BAD_VALUE_ERROR, TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_HALF, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ONE_AND_HALF, + FLOAT_NEGATIVE_INFINITY, +) + +# Property [Type Strictness: skip (rejected)]: all non-numeric BSON types +# produce a TypeMismatch error, and negative values after coercion produce a +# BAD_VALUE_ERROR. +COUNT_TYPE_STRICTNESS_SKIP_REJECTED_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"type_skip_{tid}", + docs=[{"_id": 1}], + command=lambda ctx, v=val: {"count": ctx.collection, "skip": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"count should reject {tid} for skip", + ) + for tid, val in [ + ("string", "hello"), + ("bool", True), + ("array", [1, 2]), + ("object", {"a": 1}), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02")), + ("regex", Regex("^abc")), + ("code", Code("function(){}")), + ("code_with_scope", Code("function(){}", {"x": 1})), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] + ], + CommandTestCase( + "type_skip_negative_int32", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "skip": -1}, + error_code=BAD_VALUE_ERROR, + msg="count should reject negative int32 for skip", + ), + CommandTestCase( + "type_skip_negative_int64", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "skip": Int64(-5)}, + error_code=BAD_VALUE_ERROR, + msg="count should reject negative Int64 for skip", + ), + CommandTestCase( + "type_skip_negative_double", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "skip": -1.5}, + error_code=BAD_VALUE_ERROR, + msg="count should reject double -1.5 for skip (truncates to -1)", + ), + CommandTestCase( + "type_skip_negative_decimal128", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "skip": DECIMAL128_NEGATIVE_ONE_AND_HALF, + }, + error_code=BAD_VALUE_ERROR, + msg='count should reject Decimal128("-1.5") for skip (rounds to -2)', + ), + CommandTestCase( + "type_skip_neg_inf_double", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "skip": FLOAT_NEGATIVE_INFINITY, + }, + error_code=BAD_VALUE_ERROR, + msg="count should reject -Infinity double for skip (converts to int64 min)", + ), + CommandTestCase( + "type_skip_neg_inf_decimal128", + docs=[{"_id": 1}], + command=lambda ctx: { + "count": ctx.collection, + "skip": DECIMAL128_NEGATIVE_INFINITY, + }, + error_code=BAD_VALUE_ERROR, + msg="count should reject -Infinity Decimal128 for skip (converts to int64 min)", + ), +] + +# Property [Type Strictness: skip (accepted)]: numeric types are accepted for +# the skip field. +COUNT_TYPE_STRICTNESS_SKIP_ACCEPTED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "type_skip_int32_accepted", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": 3}, + expected={"n": 7, "ok": 1.0}, + msg="count should accept int32 for skip", + ), + CommandTestCase( + "type_skip_int64_accepted", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": Int64(3)}, + expected={"n": 7, "ok": 1.0}, + msg="count should accept Int64 for skip", + ), + CommandTestCase( + "type_skip_decimal128_accepted", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": Decimal128("3")}, + expected={"n": 7, "ok": 1.0}, + msg="count should accept Decimal128 for skip", + ), + CommandTestCase( + "type_skip_neg_half_double_accepted", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": -0.5}, + expected={"n": 10, "ok": 1.0}, + msg="count should accept double -0.5 for skip (truncates to 0)", + ), + CommandTestCase( + "type_skip_neg_half_decimal128_accepted", + docs=[{"_id": i} for i in range(10)], + command=lambda ctx: {"count": ctx.collection, "skip": DECIMAL128_NEGATIVE_HALF}, + expected={"n": 10, "ok": 1.0}, + msg='count should accept Decimal128("-0.5") for skip (banker\'s rounds to 0)', + ), + CommandTestCase( + "type_skip_neg_0_6_decimal128_rejected", + docs=[{"_id": 1}], + command=lambda ctx: {"count": ctx.collection, "skip": Decimal128("-0.6")}, + error_code=BAD_VALUE_ERROR, + msg='count should reject Decimal128("-0.6") for skip (rounds to -1)', + ), +] + +COUNT_TYPE_STRICTNESS_SKIP_TESTS = ( + COUNT_TYPE_STRICTNESS_SKIP_REJECTED_TESTS + COUNT_TYPE_STRICTNESS_SKIP_ACCEPTED_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(COUNT_TYPE_STRICTNESS_SKIP_TESTS)) +def test_count_type_skip(database_client, collection, test): + """Test count command skip type strictness.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_views.py b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_views.py new file mode 100644 index 00000000..8e9bad9a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_views.py @@ -0,0 +1,228 @@ +"""Tests for count command view support.""" + +from __future__ import annotations + +import pytest +from bson import Decimal128 +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.collections.commands.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + LIMIT_NOT_POSITIVE_ERROR, + OPTION_NOT_SUPPORTED_ON_VIEW_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.target_collection import ( + OrphanedViewCollection, + ViewChainCollection, + ViewCollection, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, +) + +# Property [View Support]: count operates on views with the same semantics as +# on collections. +COUNT_VIEW_SUPPORT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "view_basic_count", + target_collection=ViewCollection(), + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection}, + expected={"n": 5, "ok": 1.0}, + msg="count should work on a simple view", + ), + CommandTestCase( + "view_with_query", + target_collection=ViewCollection(), + docs=[{"_id": i, "x": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "query": {"x": {"$gt": 2}}}, + expected={"n": 2, "ok": 1.0}, + msg="count on view should apply query filter after view pipeline", + ), + CommandTestCase( + "view_pipeline_filters", + target_collection=ViewCollection( + options={"pipeline": [{"$match": {"status": "active"}}]}, + suffix="_vpipe", + ), + docs=[ + {"_id": 1, "status": "active"}, + {"_id": 2, "status": "active"}, + {"_id": 3, "status": "inactive"}, + {"_id": 4, "status": "active"}, + ], + command=lambda ctx: {"count": ctx.collection}, + expected={"n": 3, "ok": 1.0}, + msg="count on view should apply the view pipeline", + ), + CommandTestCase( + "view_pipeline_plus_query", + target_collection=ViewCollection( + options={"pipeline": [{"$match": {"status": "active"}}]}, + suffix="_vpipe", + ), + docs=[ + {"_id": 1, "x": 1, "status": "active"}, + {"_id": 2, "x": 5, "status": "active"}, + {"_id": 3, "x": 10, "status": "inactive"}, + {"_id": 4, "x": 8, "status": "active"}, + ], + command=lambda ctx: {"count": ctx.collection, "query": {"x": {"$gt": 2}}}, + expected={"n": 2, "ok": 1.0}, + msg="count on view should apply query filter after view pipeline", + ), + CommandTestCase( + "view_hint_underlying_index", + target_collection=ViewCollection(), + docs=[{"_id": i, "x": i} for i in range(5)], + indexes=[IndexModel([("x", 1)], name="x_1")], + command=lambda ctx: {"count": ctx.collection, "hint": "x_1"}, + expected={"n": 5, "ok": 1.0}, + msg="count hint on view should resolve against underlying collection indexes", + ), + CommandTestCase( + "view_limit_positive", + target_collection=ViewCollection(), + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "limit": 3}, + expected={"n": 3, "ok": 1.0}, + msg="count with positive limit on view should cap the result", + ), + CommandTestCase( + "view_limit_zero_int", + target_collection=ViewCollection(), + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "limit": 0}, + error_code=LIMIT_NOT_POSITIVE_ERROR, + msg="count with limit=0 on view should produce an error", + ), + CommandTestCase( + "view_limit_neg_zero_double", + target_collection=ViewCollection(), + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "limit": DOUBLE_NEGATIVE_ZERO}, + error_code=LIMIT_NOT_POSITIVE_ERROR, + msg="count with limit=-0.0 on view should produce an error", + ), + CommandTestCase( + "view_limit_zero_decimal", + target_collection=ViewCollection(), + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "limit": DECIMAL128_ZERO}, + error_code=LIMIT_NOT_POSITIVE_ERROR, + msg='count with limit=Decimal128("0") on view should produce an error', + ), + CommandTestCase( + "view_limit_neg_zero_decimal", + target_collection=ViewCollection(), + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: { + "count": ctx.collection, + "limit": DECIMAL128_NEGATIVE_ZERO, + }, + error_code=LIMIT_NOT_POSITIVE_ERROR, + msg='count with limit=Decimal128("-0") on view should produce an error', + ), + CommandTestCase( + "view_limit_neg_zero_decimal_fractional", + target_collection=ViewCollection(), + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "limit": Decimal128("-0.0")}, + error_code=LIMIT_NOT_POSITIVE_ERROR, + msg='count with limit=Decimal128("-0.0") on view should produce an error', + ), + CommandTestCase( + "view_limit_negative", + target_collection=ViewCollection(), + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "limit": -3}, + expected={"n": 3, "ok": 1.0}, + msg="count with negative limit on view should use absolute value", + ), + CommandTestCase( + "view_skip", + target_collection=ViewCollection(), + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: {"count": ctx.collection, "skip": 2}, + expected={"n": 3, "ok": 1.0}, + msg="count skip on view should work identically to regular collections", + ), + CommandTestCase( + "view_collation_override_different", + target_collection=ViewCollection( + options={"collation": {"locale": "en", "strength": 2}}, + suffix="_cv", + ), + docs=[{"_id": 1, "s": "abc"}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "fr"}, + }, + error_code=OPTION_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="count with different collation on collated view should produce an error", + ), + CommandTestCase( + "view_collation_on_uncollated_view", + target_collection=ViewCollection(), + docs=[{"_id": 1, "s": "abc"}], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en"}, + }, + error_code=OPTION_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="count with collation on view without default collation should produce an error", + ), + CommandTestCase( + "view_collation_matching", + target_collection=ViewCollection( + options={"collation": {"locale": "en", "strength": 2}}, + suffix="_cv", + ), + docs=[{"_id": i} for i in range(5)], + command=lambda ctx: { + "count": ctx.collection, + "collation": {"locale": "en", "strength": 2}, + }, + expected={"n": 5, "ok": 1.0}, + msg="count with matching collation on collated view should succeed", + ), + CommandTestCase( + "view_dropped_source", + target_collection=OrphanedViewCollection(), + docs=[{"_id": 1}, {"_id": 2}], + command=lambda ctx: {"count": ctx.collection}, + expected={"n": 0, "ok": 1.0}, + msg="count on view with dropped source collection should return n=0", + ), + CommandTestCase( + "view_nested", + target_collection=ViewChainCollection(depth=2), + docs=[{"_id": i} for i in range(3)], + command=lambda ctx: {"count": ctx.collection}, + expected={"n": 3, "ok": 1.0}, + msg="count on nested view should apply all pipelines correctly", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(COUNT_VIEW_SUPPORT_TESTS)) +def test_count_views(database_client, collection, test): + """Test count command view support.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_with_expr.py b/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_with_expr.py deleted file mode 100644 index 5ef5978a..00000000 --- a/documentdb_tests/compatibility/tests/core/aggregation/commands/count/test_count_with_expr.py +++ /dev/null @@ -1,25 +0,0 @@ -""" -Tests for $expr in count command contexts. -""" - -from documentdb_tests.framework.assertions import assertSuccess -from documentdb_tests.framework.executor import execute_command - -BASIC_DOCS = [ - {"_id": 1, "a": 5, "b": 3}, - {"_id": 2, "a": 1, "b": 10}, - {"_id": 3, "a": -1, "b": 0}, -] - - -def test_expr_in_count(collection): - """Test $expr in count command.""" - collection.insert_many(BASIC_DOCS) - result = execute_command( - collection, - { - "count": collection.name, - "query": {"$expr": {"$gt": ["$a", 0]}}, - }, - ) - assertSuccess(result, 2, raw_res=True, transform=lambda r: r.get("n")) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/utils/command_test_case.py b/documentdb_tests/compatibility/tests/core/collections/commands/utils/command_test_case.py index 7d6072b2..4d990382 100644 --- a/documentdb_tests/compatibility/tests/core/collections/commands/utils/command_test_case.py +++ b/documentdb_tests/compatibility/tests/core/collections/commands/utils/command_test_case.py @@ -76,22 +76,28 @@ class CommandTestCase(BaseTestCase): def prepare(self, db: Database, collection: Collection) -> Collection: """Resolve the target collection and apply indexes/docs. + Documents and indexes are inserted into the collection returned + by ``target_collection.writable(source, resolved)``. For views + this is the source; for regular collections it is the resolved + collection itself. + - If ``docs=None``, the collection is not created and will not exist. - If ``docs=[]``, the collection is explicitly created but left empty. - If ``docs=[...]``, the collection is created and documents are inserted. """ - collection = self.target_collection.resolve(db, collection) + resolved = self.target_collection.resolve(db, collection) + target = self.target_collection.writable(collection, resolved) if self.indexes: - collection.create_indexes(self.indexes) + target.create_indexes(self.indexes) if self.docs is not None: - if collection.name not in collection.database.list_collection_names(): - collection.database.create_collection(collection.name) + if target.name not in target.database.list_collection_names(): + target.database.create_collection(target.name) if self.docs: - collection.insert_many(self.docs) + target.insert_many(self.docs) if self.siblings: for sibling in self.siblings: sibling.create(db, collection) - return collection + return resolved def build_command(self, ctx: CommandContext) -> dict[str, Any]: """Resolve the command dict from a callable or plain dict.""" diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 97932e87..39f7ccb1 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -20,6 +20,7 @@ UNKNOWN_REPL_WRITE_CONCERN_ERROR = 79 INDEX_OPTIONS_CONFLICT_ERROR = 85 INDEX_KEY_SPECS_CONFLICT_ERROR = 86 +NOT_A_REPLICA_SET_ERROR = 123 INCOMPATIBLE_COLLATION_VERSION_ERROR = 161 VIEW_DEPTH_LIMIT_ERROR = 165 COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR = 166 diff --git a/documentdb_tests/framework/target_collection.py b/documentdb_tests/framework/target_collection.py index f4d31f75..6020ee0c 100644 --- a/documentdb_tests/framework/target_collection.py +++ b/documentdb_tests/framework/target_collection.py @@ -22,16 +22,30 @@ class TargetCollection: def resolve(self, db: Database, collection: Collection) -> Collection: return collection + def writable(self, source: Collection, resolved: Collection) -> Collection: + """Return the collection where docs and indexes should be inserted.""" + return resolved + @dataclass(frozen=True) class ViewCollection(TargetCollection): - """A view on the fixture collection.""" + """A view on the fixture collection. + + Pass any extra keyword arguments accepted by the ``create`` command + (e.g. ``pipeline``, ``collation``) via the ``options`` dict. + """ + + options: dict[str, Any] = field(default_factory=dict) + suffix: str = "_view" def resolve(self, db: Database, collection: Collection) -> Collection: - view_name = f"{collection.name}_view" - db.command("create", view_name, viewOn=collection.name, pipeline=[]) + view_name = f"{collection.name}{self.suffix}" + db.command("create", view_name, viewOn=collection.name, **self.options) return db[view_name] + def writable(self, source: Collection, resolved: Collection) -> Collection: + return source + @dataclass(frozen=True) class SystemViewsCollection(ViewCollection): @@ -132,6 +146,9 @@ def resolve(self, db: Database, collection: Collection) -> Collection: source = name return db[source] + def writable(self, source: Collection, resolved: Collection) -> Collection: + return source + @dataclass(frozen=True) class ExistingCollection(TargetCollection): @@ -182,20 +199,23 @@ def resolve(self, db: Database, collection: Collection) -> Collection: return db[f"system.buckets.{name}"] -@dataclass(frozen=True) -class ViewWithPipelineCollection(TargetCollection): +def ViewWithPipelineCollection() -> ViewCollection: """A view on the fixture collection with a non-empty pipeline.""" + return ViewCollection(options={"pipeline": [{"$match": {"x": 1}}]}, suffix="_vpipe") + + +@dataclass(frozen=True) +class OrphanedViewCollection(TargetCollection): + """A view whose source collection does not exist.""" def resolve(self, db: Database, collection: Collection) -> Collection: - view_name = f"{collection.name}_vpipe" - db.command( - "create", - view_name, - viewOn=collection.name, - pipeline=[{"$match": {"x": 1}}], - ) + view_name = f"{collection.name}_orphan" + db.command("create", view_name, viewOn="nonexistent_source") return db[view_name] + def writable(self, source: Collection, resolved: Collection) -> Collection: + return source + @dataclass(frozen=True) class ValidatedCollection(TargetCollection):