From b90a0dca3852d9071ccbb4568ac34ed97d518841 Mon Sep 17 00:00:00 2001 From: "Victor [C] Tsang" Date: Fri, 15 May 2026 06:15:00 +0000 Subject: [PATCH 1/3] Added core indexes property tests for partial Signed-off-by: Victor [C] Tsang --- .../indexes/commands/utils/index_test_case.py | 2 + .../test_partial_bson_type_validation.py | 80 +++ .../properties/partial/test_partial_create.py | 585 +++++++++++++++ .../properties/partial/test_partial_errors.py | 273 +++++++ .../partial/test_partial_query_behavior.py | 670 ++++++++++++++++++ .../partial/test_partial_timeseries.py | 72 ++ .../test_properties_combinations.py | 269 ++++++- 7 files changed, 1944 insertions(+), 7 deletions(-) create mode 100644 documentdb_tests/compatibility/tests/core/indexes/properties/partial/test_partial_bson_type_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/indexes/properties/partial/test_partial_create.py create mode 100644 documentdb_tests/compatibility/tests/core/indexes/properties/partial/test_partial_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/indexes/properties/partial/test_partial_query_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/indexes/properties/partial/test_partial_timeseries.py diff --git a/documentdb_tests/compatibility/tests/core/indexes/commands/utils/index_test_case.py b/documentdb_tests/compatibility/tests/core/indexes/commands/utils/index_test_case.py index f5fce7a8..91aa4cef 100644 --- a/documentdb_tests/compatibility/tests/core/indexes/commands/utils/index_test_case.py +++ b/documentdb_tests/compatibility/tests/core/indexes/commands/utils/index_test_case.py @@ -51,8 +51,10 @@ class IndexQueryTestCase(IndexTestCase): filter: Query filter for find command. collation: Collation to use on the query. sort: Optional sort specification. + hint: Optional index hint for the query. """ filter: Optional[dict] = None collation: Optional[dict] = None sort: Optional[dict] = None + hint: Optional[Any] = None diff --git a/documentdb_tests/compatibility/tests/core/indexes/properties/partial/test_partial_bson_type_validation.py b/documentdb_tests/compatibility/tests/core/indexes/properties/partial/test_partial_bson_type_validation.py new file mode 100644 index 00000000..387791fd --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/indexes/properties/partial/test_partial_bson_type_validation.py @@ -0,0 +1,80 @@ +""" +Tests for partialFilterExpression BSON type validation. + +Verifies that partialFilterExpression rejects invalid BSON types with expected +error codes and accepts valid BSON types (object) without error. +""" + +import pytest + +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccessPartial +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_command + +pytestmark = pytest.mark.index + +PARTIAL_FILTER_PARAMS = [ + BsonTypeTestCase( + id="partialFilterExpression", + msg="partialFilterExpression should reject non-object types", + keyword="partialFilterExpression", + valid_types=[BsonType.OBJECT], + valid_inputs={BsonType.OBJECT: {"a": {"$gt": 0}}}, + default_error_code=TYPE_MISMATCH_ERROR, + ), +] + +REJECTION_CASES = generate_bson_rejection_test_cases(PARTIAL_FILTER_PARAMS) +ACCEPTANCE_CASES = generate_bson_acceptance_test_cases(PARTIAL_FILTER_PARAMS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", REJECTION_CASES) +def test_partial_filter_rejects_invalid_bson_type(collection, bson_type, sample_value, spec): + """Test createIndexes rejects invalid BSON types for partialFilterExpression.""" + result = execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [ + {"key": {"a": 1}, "name": "idx_bson", "partialFilterExpression": sample_value} + ], + }, + ) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ACCEPTANCE_CASES) +def test_partial_filter_accepts_valid_bson_type(collection, bson_type, sample_value, spec): + """Test createIndexes accepts valid BSON types for partialFilterExpression + and that the resulting index only includes documents matching the filter.""" + collection.insert_many( + [ + {"_id": 1, "a": 10}, # matches a > 0 → indexed + {"_id": 2, "a": 20}, # matches a > 0 → indexed + {"_id": 3, "a": -5}, # doesn't match → NOT indexed + ] + ) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [ + {"key": {"a": 1}, "name": "idx_bson", "partialFilterExpression": sample_value} + ], + }, + ) + count_result = execute_command( + collection, + {"count": collection.name, "query": {}, "hint": {"a": 1}}, + ) + assertSuccessPartial( + count_result, + {"n": 2, "ok": 1.0}, + msg=f"Partial index ({bson_type.value}) should only include docs matching the filter", + ) diff --git a/documentdb_tests/compatibility/tests/core/indexes/properties/partial/test_partial_create.py b/documentdb_tests/compatibility/tests/core/indexes/properties/partial/test_partial_create.py new file mode 100644 index 00000000..46deea00 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/indexes/properties/partial/test_partial_create.py @@ -0,0 +1,585 @@ +"""Tests for partial index creation — valid operators, formats, type filters, and signatures.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.indexes.commands.utils.index_test_case import ( + IndexTestCase, + index_created_response, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.index + +PARTIAL_VALID_OPERATORS: list[IndexTestCase] = [ + IndexTestCase( + id="equality", + indexes=( + {"key": {"a": 1}, "name": "idx_eq", "partialFilterExpression": {"status": "active"}}, + ), + msg="Should create partial index with equality filter", + ), + IndexTestCase( + id="dollar_eq", + indexes=( + { + "key": {"a": 1}, + "name": "idx_deq", + "partialFilterExpression": {"status": {"$eq": "active"}}, + }, + ), + msg="Should create partial index with $eq filter", + ), + IndexTestCase( + id="exists_true", + indexes=( + { + "key": {"a": 1}, + "name": "idx_exists", + "partialFilterExpression": {"a": {"$exists": True}}, + }, + ), + msg="Should create partial index with $exists: true", + ), + IndexTestCase( + id="gt", + indexes=( + {"key": {"a": 1}, "name": "idx_gt", "partialFilterExpression": {"a": {"$gt": 5}}}, + ), + msg="Should create partial index with $gt", + ), + IndexTestCase( + id="gte", + indexes=( + {"key": {"a": 1}, "name": "idx_gte", "partialFilterExpression": {"a": {"$gte": 5}}}, + ), + msg="Should create partial index with $gte", + ), + IndexTestCase( + id="lt", + indexes=( + {"key": {"a": 1}, "name": "idx_lt", "partialFilterExpression": {"a": {"$lt": 100}}}, + ), + msg="Should create partial index with $lt", + ), + IndexTestCase( + id="lte", + indexes=( + {"key": {"a": 1}, "name": "idx_lte", "partialFilterExpression": {"a": {"$lte": 100}}}, + ), + msg="Should create partial index with $lte", + ), + IndexTestCase( + id="type_operator", + indexes=( + { + "key": {"a": 1}, + "name": "idx_type", + "partialFilterExpression": {"a": {"$type": "string"}}, + }, + ), + msg="Should create partial index with $type", + ), + IndexTestCase( + id="and", + indexes=( + { + "key": {"a": 1}, + "name": "idx_and", + "partialFilterExpression": {"$and": [{"a": {"$gt": 0}}, {"a": {"$lt": 100}}]}, + }, + ), + msg="Should create partial index with $and", + ), + IndexTestCase( + id="or", + indexes=( + { + "key": {"a": 1}, + "name": "idx_or", + "partialFilterExpression": {"$or": [{"a": {"$gt": 50}}, {"b": {"$exists": True}}]}, + }, + ), + msg="Should create partial index with $or", + ), + IndexTestCase( + id="in", + indexes=( + { + "key": {"a": 1}, + "name": "idx_in", + "partialFilterExpression": {"status": {"$in": ["active", "pending"]}}, + }, + ), + msg="Should create partial index with $in", + ), + IndexTestCase( + id="nested_and_or", + indexes=( + { + "key": {"a": 1}, + "name": "idx_nested", + "partialFilterExpression": { + "$and": [ + {"$or": [{"a": {"$gt": 0}}, {"b": {"$exists": True}}]}, + {"c": {"$lt": 10}}, + ] + }, + }, + ), + msg="Should create partial index with nested $and/$or", + ), + IndexTestCase( + id="all", + indexes=( + { + "key": {"a": 1}, + "name": "idx_all", + "partialFilterExpression": {"a": {"$all": [1, 2]}}, + }, + ), + msg="Should create partial index with $all", + ), + IndexTestCase( + id="geoWithin", + indexes=( + { + "key": {"a": 1}, + "name": "idx_geo_within", + "partialFilterExpression": {"loc": {"$geoWithin": {"$center": [[0, 0], 5]}}}, + }, + ), + msg="Should create partial index with $geoWithin", + ), + IndexTestCase( + id="geoIntersects", + indexes=( + { + "key": {"a": 1}, + "name": "idx_geo_intersects", + "partialFilterExpression": { + "loc": { + "$geoIntersects": {"$geometry": {"type": "Point", "coordinates": [0, 0]}} + } + }, + }, + ), + msg="Should create partial index with $geoIntersects", + ), + IndexTestCase( + id="eq_null", + indexes=( + { + "key": {"a": 1}, + "name": "idx_eq_null", + "partialFilterExpression": {"a": {"$eq": None}}, + }, + ), + msg="Should create partial index with $eq null", + ), + IndexTestCase( + id="filter_on_id_field", + indexes=( + { + "key": {"a": 1}, + "name": "idx_id_filter", + "partialFilterExpression": {"_id": {"$gt": 0}}, + }, + ), + msg="Should create partial index with filter on _id field", + ), +] + +PARTIAL_FORMAT_VARIATIONS: list[IndexTestCase] = [ + IndexTestCase( + id="empty_filter", + indexes=({"key": {"a": 1}, "name": "idx_empty", "partialFilterExpression": {}},), + msg="Should create partial index with empty partialFilterExpression", + ), + IndexTestCase( + id="filter_on_same_field", + indexes=( + {"key": {"a": 1}, "name": "idx_same", "partialFilterExpression": {"a": {"$gt": 0}}}, + ), + msg="Should create partial index with filter on same field as key", + ), + IndexTestCase( + id="filter_different_field", + indexes=( + { + "key": {"a": 1}, + "name": "idx_diff", + "partialFilterExpression": {"b": {"$exists": True}}, + }, + ), + msg="Should create partial index with filter on different field", + ), + IndexTestCase( + id="filter_multiple_fields", + indexes=( + { + "key": {"a": 1}, + "name": "idx_multi", + "partialFilterExpression": {"b": {"$gt": 0}, "c": {"$exists": True}}, + }, + ), + msg="Should create partial index with filter on multiple fields", + ), + IndexTestCase( + id="with_unique", + indexes=( + { + "key": {"a": 1}, + "name": "idx_uniq", + "partialFilterExpression": {"a": {"$gt": 0}}, + "unique": True, + }, + ), + msg="Should create partial index combined with unique", + ), + IndexTestCase( + id="compound_index", + indexes=( + { + "key": {"a": 1, "b": -1}, + "name": "idx_comp", + "partialFilterExpression": {"a": {"$gt": 0}}, + }, + ), + msg="Should create partial index on compound key", + ), + IndexTestCase( + id="hashed_index", + indexes=( + { + "key": {"a": "hashed"}, + "name": "idx_hash", + "partialFilterExpression": {"b": {"$gt": 0}}, + }, + ), + msg="Should create partial index on hashed key", + ), + IndexTestCase( + id="2dsphere_index", + indexes=( + { + "key": {"loc": "2dsphere"}, + "name": "idx_2ds", + "partialFilterExpression": {"active": True}, + }, + ), + msg="Should create partial index on 2dsphere key", + ), + IndexTestCase( + id="wildcard_index", + indexes=( + { + "key": {"$**": 1}, + "name": "idx_wc", + "partialFilterExpression": {"a": {"$exists": True}}, + }, + ), + msg="Should create partial index on wildcard key", + ), +] + +PARTIAL_TYPE_FILTER_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="type_double", + indexes=( + { + "key": {"a": 1}, + "name": "idx_t_double", + "partialFilterExpression": {"a": {"$type": "double"}}, + }, + ), + msg="Should create partial index with $type double", + ), + IndexTestCase( + id="type_string", + indexes=( + { + "key": {"a": 1}, + "name": "idx_t_string", + "partialFilterExpression": {"a": {"$type": "string"}}, + }, + ), + msg="Should create partial index with $type string", + ), + IndexTestCase( + id="type_object", + indexes=( + { + "key": {"a": 1}, + "name": "idx_t_object", + "partialFilterExpression": {"a": {"$type": "object"}}, + }, + ), + msg="Should create partial index with $type object", + ), + IndexTestCase( + id="type_array", + indexes=( + { + "key": {"a": 1}, + "name": "idx_t_array", + "partialFilterExpression": {"a": {"$type": "array"}}, + }, + ), + msg="Should create partial index with $type array", + ), + IndexTestCase( + id="type_bool", + indexes=( + { + "key": {"a": 1}, + "name": "idx_t_bool", + "partialFilterExpression": {"a": {"$type": "bool"}}, + }, + ), + msg="Should create partial index with $type bool", + ), + IndexTestCase( + id="type_date", + indexes=( + { + "key": {"a": 1}, + "name": "idx_t_date", + "partialFilterExpression": {"a": {"$type": "date"}}, + }, + ), + msg="Should create partial index with $type date", + ), + IndexTestCase( + id="type_null", + indexes=( + { + "key": {"a": 1}, + "name": "idx_t_null", + "partialFilterExpression": {"a": {"$type": "null"}}, + }, + ), + msg="Should create partial index with $type null", + ), + IndexTestCase( + id="type_int", + indexes=( + { + "key": {"a": 1}, + "name": "idx_t_int", + "partialFilterExpression": {"a": {"$type": "int"}}, + }, + ), + msg="Should create partial index with $type int", + ), + IndexTestCase( + id="type_long", + indexes=( + { + "key": {"a": 1}, + "name": "idx_t_long", + "partialFilterExpression": {"a": {"$type": "long"}}, + }, + ), + msg="Should create partial index with $type long", + ), + IndexTestCase( + id="type_decimal", + indexes=( + { + "key": {"a": 1}, + "name": "idx_t_decimal", + "partialFilterExpression": {"a": {"$type": "decimal"}}, + }, + ), + msg="Should create partial index with $type decimal", + ), + IndexTestCase( + id="type_number", + indexes=( + { + "key": {"a": 1}, + "name": "idx_t_number", + "partialFilterExpression": {"a": {"$type": "number"}}, + }, + ), + msg="Should create partial index with $type number (alias)", + ), + IndexTestCase( + id="type_multiple", + indexes=( + { + "key": {"a": 1}, + "name": "idx_t_multi", + "partialFilterExpression": {"a": {"$type": ["string", "int"]}}, + }, + ), + msg="Should create partial index with $type array of multiple types", + ), + IndexTestCase( + id="type_numeric_code", + indexes=( + { + "key": {"a": 1}, + "name": "idx_t_numeric", + "partialFilterExpression": {"a": {"$type": 2}}, + }, + ), + msg="Should create partial index with $type numeric code (2 = string)", + ), +] + +PARTIAL_COLLECTION_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="on_nonexistent_collection", + indexes=( + {"key": {"a": 1}, "name": "idx_partial", "partialFilterExpression": {"a": {"$gt": 0}}}, + ), + expected={"ok": 1.0, "createdCollectionAutomatically": True, "numIndexesAfter": 2}, + msg="Should implicitly create collection with partial index", + ), +] + +PARTIAL_CREATE_TESTS = ( + PARTIAL_VALID_OPERATORS + + PARTIAL_FORMAT_VARIATIONS + + PARTIAL_TYPE_FILTER_TESTS + + PARTIAL_COLLECTION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(PARTIAL_CREATE_TESTS)) +def test_partial_create(collection, test): + """Test createIndex with valid partialFilterExpression succeeds.""" + result = execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + expected = test.expected if test.expected is not None else index_created_response() + assertSuccessPartial(result, expected, msg=test.msg) + + +PARTIAL_SIGNATURE_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="different_filter_creates_separate", + indexes=( + { + "key": {"a": 1}, + "name": "idx_partial_gt5", + "partialFilterExpression": {"a": {"$gt": 5}}, + }, + ), + setup_indexes=[ + { + "key": {"a": 1}, + "name": "idx_partial_gt10", + "partialFilterExpression": {"a": {"$gt": 10}}, + } + ], + expected=index_created_response(num_indexes_before=2, num_indexes_after=3), + msg="Different partialFilterExpression on same key creates separate index", + ), + IndexTestCase( + id="same_filter_noop", + indexes=( + {"key": {"a": 1}, "name": "idx_partial", "partialFilterExpression": {"a": {"$gt": 5}}}, + ), + setup_indexes=[ + {"key": {"a": 1}, "name": "idx_partial", "partialFilterExpression": {"a": {"$gt": 5}}} + ], + expected=index_created_response(num_indexes_before=2, num_indexes_after=2), + msg="Same partialFilterExpression on same key is a no-op", + ), + IndexTestCase( + id="partial_separate_from_basic", + indexes=( + {"key": {"a": 1}, "name": "idx_partial", "partialFilterExpression": {"a": {"$gt": 0}}}, + ), + setup_indexes=[{"key": {"a": 1}, "name": "idx_basic"}], + expected=index_created_response(num_indexes_before=2, num_indexes_after=3), + msg="Partial index should be separate from basic index on same key", + ), + IndexTestCase( + id="and_clause_order_is_same_signature", + indexes=( + { + "key": {"a": 1}, + "name": "idx_and_ab", + "partialFilterExpression": {"$and": [{"b": 2}, {"a": 1}]}, + }, + ), + setup_indexes=[ + { + "key": {"a": 1}, + "name": "idx_and_ab", + "partialFilterExpression": {"$and": [{"a": 1}, {"b": 2}]}, + } + ], + expected=index_created_response(num_indexes_before=2, num_indexes_after=2), + msg="$and with reordered clauses is normalized to same signature (no-op)", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(PARTIAL_SIGNATURE_TESTS)) +def test_partial_signature(collection, test): + """Test partialFilterExpression as index signature differentiator.""" + if test.setup_indexes: + execute_command( + collection, + {"createIndexes": collection.name, "indexes": test.setup_indexes}, + ) + result = execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + assertSuccessPartial(result, test.expected, msg=test.msg) + + +def test_partial_create_on_capped(database_client, collection): + """Test createIndex with partialFilterExpression on capped collection.""" + capped_name = f"{collection.name}_capped" + database_client.create_collection(capped_name, capped=True, size=100000) + capped_coll = database_client[capped_name] + result = execute_command( + capped_coll, + { + "createIndexes": capped_coll.name, + "indexes": [ + { + "key": {"a": 1}, + "name": "idx_partial", + "partialFilterExpression": {"a": {"$gt": 0}}, + } + ], + }, + ) + assertSuccessPartial( + result, {"ok": 1.0, "numIndexesAfter": 2}, msg="Should create partial index on capped" + ) + + +def test_partial_create_on_clustered(database_client, collection): + """Test createIndex with partialFilterExpression on clustered collection.""" + clustered_name = f"{collection.name}_clustered" + database_client.create_collection( + clustered_name, clusteredIndex={"key": {"_id": 1}, "unique": True} + ) + clustered_coll = database_client[clustered_name] + result = execute_command( + clustered_coll, + { + "createIndexes": clustered_coll.name, + "indexes": [ + { + "key": {"a": 1}, + "name": "idx_partial", + "partialFilterExpression": {"a": {"$gt": 0}}, + } + ], + }, + ) + assertSuccessPartial( + result, {"ok": 1.0, "numIndexesAfter": 1}, msg="Should create partial index on clustered" + ) diff --git a/documentdb_tests/compatibility/tests/core/indexes/properties/partial/test_partial_errors.py b/documentdb_tests/compatibility/tests/core/indexes/properties/partial/test_partial_errors.py new file mode 100644 index 00000000..35d206bc --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/indexes/properties/partial/test_partial_errors.py @@ -0,0 +1,273 @@ +"""Tests for partial index error cases — invalid creation, constraint violations, rejections.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.indexes.commands.utils.index_test_case import ( + IndexTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + CANNOT_CREATE_INDEX_ERROR, + COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + EXPR_IN_ARRAY_FILTERS_ERROR, + INDEX_OPTIONS_CONFLICT_ERROR, + INVALID_OPTIONS_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.index + +PARTIAL_INVALID_OPERATORS: list[IndexTestCase] = [ + IndexTestCase( + id="ne", + indexes=( + {"key": {"a": 1}, "name": "idx_ne", "partialFilterExpression": {"a": {"$ne": 5}}}, + ), + error_code=CANNOT_CREATE_INDEX_ERROR, + msg="Should reject $ne in partialFilterExpression", + ), + IndexTestCase( + id="nin", + indexes=( + { + "key": {"a": 1}, + "name": "idx_nin", + "partialFilterExpression": {"a": {"$nin": [1, 2]}}, + }, + ), + error_code=CANNOT_CREATE_INDEX_ERROR, + msg="Should reject $nin in partialFilterExpression", + ), + IndexTestCase( + id="not", + indexes=( + { + "key": {"a": 1}, + "name": "idx_not", + "partialFilterExpression": {"a": {"$not": {"$gt": 5}}}, + }, + ), + error_code=CANNOT_CREATE_INDEX_ERROR, + msg="Should reject $not in partialFilterExpression", + ), + IndexTestCase( + id="nor", + indexes=( + {"key": {"a": 1}, "name": "idx_nor", "partialFilterExpression": {"$nor": [{"a": 1}]}}, + ), + error_code=CANNOT_CREATE_INDEX_ERROR, + msg="Should reject $nor in partialFilterExpression", + ), + IndexTestCase( + id="regex", + indexes=( + { + "key": {"a": 1}, + "name": "idx_regex", + "partialFilterExpression": {"a": {"$regex": "^test"}}, + }, + ), + error_code=CANNOT_CREATE_INDEX_ERROR, + msg="Should reject $regex in partialFilterExpression", + ), + IndexTestCase( + id="expr", + indexes=( + { + "key": {"a": 1}, + "name": "idx_expr", + "partialFilterExpression": {"$expr": {"$gt": ["$a", 5]}}, + }, + ), + error_code=EXPR_IN_ARRAY_FILTERS_ERROR, + msg="Should reject $expr in partialFilterExpression", + ), + IndexTestCase( + id="where", + indexes=( + { + "key": {"a": 1}, + "name": "idx_where", + "partialFilterExpression": {"$where": "this.a > 5"}, + }, + ), + error_code=BAD_VALUE_ERROR, + msg="Should reject $where in partialFilterExpression", + ), + IndexTestCase( + id="elemMatch", + indexes=( + { + "key": {"a": 1}, + "name": "idx_elem", + "partialFilterExpression": {"arr": {"$elemMatch": {"x": 1}}}, + }, + ), + error_code=CANNOT_CREATE_INDEX_ERROR, + msg="Should reject $elemMatch in partialFilterExpression", + ), + IndexTestCase( + id="size", + indexes=( + {"key": {"a": 1}, "name": "idx_size", "partialFilterExpression": {"a": {"$size": 3}}}, + ), + error_code=CANNOT_CREATE_INDEX_ERROR, + msg="Should reject $size in partialFilterExpression", + ), + IndexTestCase( + id="mod", + indexes=( + { + "key": {"a": 1}, + "name": "idx_mod", + "partialFilterExpression": {"a": {"$mod": [2, 0]}}, + }, + ), + error_code=CANNOT_CREATE_INDEX_ERROR, + msg="Should reject $mod in partialFilterExpression", + ), + IndexTestCase( + id="text", + indexes=( + { + "key": {"a": 1}, + "name": "idx_text", + "partialFilterExpression": {"$text": {"$search": "hello"}}, + }, + ), + error_code=BAD_VALUE_ERROR, + msg="Should reject $text in partialFilterExpression", + ), + IndexTestCase( + id="exists_false", + indexes=( + { + "key": {"a": 1}, + "name": "idx_exists_false", + "partialFilterExpression": {"a": {"$exists": False}}, + }, + ), + error_code=CANNOT_CREATE_INDEX_ERROR, + msg="Should reject $exists: false in partialFilterExpression", + ), + IndexTestCase( + id="exists_zero", + indexes=( + { + "key": {"a": 1}, + "name": "idx_exists_zero", + "partialFilterExpression": {"a": {"$exists": 0}}, + }, + ), + error_code=CANNOT_CREATE_INDEX_ERROR, + msg="Should reject $exists: 0 (falsy int) in partialFilterExpression", + ), + IndexTestCase( + id="empty_and", + indexes=( + {"key": {"a": 1}, "name": "idx_empty_and", "partialFilterExpression": {"$and": []}}, + ), + error_code=BAD_VALUE_ERROR, + msg="Should reject empty $and in partialFilterExpression", + ), + IndexTestCase( + id="empty_or", + indexes=( + {"key": {"a": 1}, "name": "idx_empty_or", "partialFilterExpression": {"$or": []}}, + ), + error_code=BAD_VALUE_ERROR, + msg="Should reject empty $or in partialFilterExpression", + ), + IndexTestCase( + id="sparse_combination", + indexes=( + { + "key": {"a": 1}, + "name": "idx_partial_sparse", + "partialFilterExpression": {"a": {"$gt": 0}}, + "sparse": True, + }, + ), + error_code=CANNOT_CREATE_INDEX_ERROR, + msg="Should reject sparse combined with partialFilterExpression", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(PARTIAL_INVALID_OPERATORS)) +def test_partial_errors(collection, test): + """Test partial index error cases — invalid creation.""" + result = execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + assertFailureCode(result, test.error_code, msg=test.msg) + + +PARTIAL_TIMESERIES_ERRORS: list[IndexTestCase] = [ + IndexTestCase( + id="ttl_data_field_filter", + indexes=( + { + "key": {"ts": 1}, + "name": "idx_ttl_data", + "expireAfterSeconds": 3600, + "partialFilterExpression": {"value": {"$gt": 0}}, + }, + ), + error_code=INVALID_OPTIONS_ERROR, + msg="Should reject TTL partial index with filter on data field in timeseries", + ), + IndexTestCase( + id="collation_conflict", + indexes=( + { + "key": {"meta.name": 1}, + "name": "idx_collation_partial", + "partialFilterExpression": {"meta.active": True}, + "collation": {"locale": "en", "strength": 2}, + }, + ), + error_code=INDEX_OPTIONS_CONFLICT_ERROR, + msg="Should reject collation + partialFilterExpression on timeseries", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(PARTIAL_TIMESERIES_ERRORS)) +def test_partial_timeseries_errors(database_client, collection, test): + """Test partial index error cases on timeseries collections.""" + ts_name = f"{collection.name}_ts" + database_client.command( + {"create": ts_name, "timeseries": {"timeField": "ts", "metaField": "meta"}} + ) + ts_coll = database_client[ts_name] + result = execute_command( + ts_coll, + {"createIndexes": ts_coll.name, "indexes": list(test.indexes)}, + ) + assertFailureCode(result, test.error_code, msg=test.msg) + + +def test_partial_create_on_view_error(database_client, collection): + """Test createIndex with partialFilterExpression on view returns error.""" + view_name = f"{collection.name}_view" + database_client.create_collection(collection.name) + database_client.command({"create": view_name, "viewOn": collection.name, "pipeline": []}) + view_coll = database_client[view_name] + result = execute_command( + view_coll, + { + "createIndexes": view_coll.name, + "indexes": [ + { + "key": {"a": 1}, + "name": "idx_partial", + "partialFilterExpression": {"a": {"$gt": 0}}, + } + ], + }, + ) + assertFailureCode(result, COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR) diff --git a/documentdb_tests/compatibility/tests/core/indexes/properties/partial/test_partial_query_behavior.py b/documentdb_tests/compatibility/tests/core/indexes/properties/partial/test_partial_query_behavior.py new file mode 100644 index 00000000..9729d8cc --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/indexes/properties/partial/test_partial_query_behavior.py @@ -0,0 +1,670 @@ +"""Tests for partial index query behavior — coverage, filters, nested fields, edge cases.""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.indexes.commands.utils.index_test_case import ( + IndexQueryTestCase, +) +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.index + +_DOCS_GT5 = ( + {"_id": 1, "a": 3}, + {"_id": 2, "a": 7}, + {"_id": 3, "a": 10}, + {"_id": 4, "a": 1}, +) + +_IDX_GT5 = ( + {"key": {"a": 1}, "name": "idx_partial_gt5", "partialFilterExpression": {"a": {"$gt": 5}}}, +) + +PARTIAL_QUERY_USAGE: list[IndexQueryTestCase] = [ + IndexQueryTestCase( + id="exact_match_uses_index", + doc=_DOCS_GT5, + indexes=_IDX_GT5, + filter={"a": {"$gt": 5}}, + hint={"a": 1}, + sort={"_id": 1}, + expected=[{"_id": 2, "a": 7}, {"_id": 3, "a": 10}], + msg="Query matching partialFilterExpression exactly uses the index", + ), + IndexQueryTestCase( + id="superset_uses_index", + doc=_DOCS_GT5, + indexes=_IDX_GT5, + filter={"a": {"$gt": 8}}, + hint={"a": 1}, + sort={"_id": 1}, + expected=[{"_id": 3, "a": 10}], + msg="Query with superset predicate (more restrictive) uses the index", + ), + IndexQueryTestCase( + id="subset_does_not_cover", + doc=_DOCS_GT5, + indexes=_IDX_GT5, + filter={"a": {"$gt": 2}}, + hint={"a": 1}, + sort={"_id": 1}, + expected=[{"_id": 2, "a": 7}, {"_id": 3, "a": 10}], + msg="Query with weaker predicate only returns docs in the index when hint forces use", + ), +] + +_DOCS_EXISTS_B = ( + {"_id": 1, "a": 10, "b": "x"}, + {"_id": 2, "a": 20}, + {"_id": 3, "a": 30, "b": "y"}, +) + +_IDX_EXISTS_B = ( + {"key": {"a": 1}, "name": "idx_exists_b", "partialFilterExpression": {"b": {"$exists": True}}}, +) + +PARTIAL_QUERY_EXISTS: list[IndexQueryTestCase] = [ + IndexQueryTestCase( + id="exists_query_covers_filter", + doc=_DOCS_EXISTS_B, + indexes=_IDX_EXISTS_B, + filter={"b": {"$exists": True}}, + hint={"a": 1}, + sort={"_id": 1}, + expected=[{"_id": 1, "a": 10, "b": "x"}, {"_id": 3, "a": 30, "b": "y"}], + msg="Query including $exists: true on filter field uses partial index", + ), + IndexQueryTestCase( + id="without_filter_incomplete", + doc=_DOCS_EXISTS_B, + indexes=_IDX_EXISTS_B, + filter={"a": {"$gt": 0}}, + hint={"a": 1}, + sort={"_id": 1}, + expected=[{"_id": 1, "a": 10, "b": "x"}, {"_id": 3, "a": 30, "b": "y"}], + msg="Query without filter field returns only indexed docs when hint forces index", + ), +] + +PARTIAL_QUERY_RANGE: list[IndexQueryTestCase] = [ + IndexQueryTestCase( + id="range_conjunction", + doc=( + {"_id": 1, "a": 1}, + {"_id": 2, "a": 5}, + {"_id": 3, "a": 10}, + {"_id": 4, "a": 15}, + ), + indexes=( + { + "key": {"a": 1}, + "name": "idx_range", + "partialFilterExpression": {"a": {"$gte": 5, "$lte": 10}}, + }, + ), + filter={"a": {"$gte": 5, "$lte": 10}}, + hint={"a": 1}, + sort={"_id": 1}, + expected=[{"_id": 2, "a": 5}, {"_id": 3, "a": 10}], + msg="Partial index with range conjunction works correctly", + ), +] + +PARTIAL_COMPLEX_FILTERS: list[IndexQueryTestCase] = [ + IndexQueryTestCase( + id="and_multiple_conditions", + doc=( + {"_id": 1, "a": 3, "b": 1}, + {"_id": 2, "a": 7, "b": 5}, + {"_id": 3, "a": 10, "b": 2}, + ), + indexes=( + { + "key": {"a": 1}, + "name": "idx_and", + "partialFilterExpression": {"$and": [{"a": {"$gt": 5}}, {"b": {"$gt": 3}}]}, + }, + ), + filter={"$and": [{"a": {"$gt": 5}}, {"b": {"$gt": 3}}]}, + hint={"a": 1}, + sort={"_id": 1}, + expected=[{"_id": 2, "a": 7, "b": 5}], + msg="Partial index with $and combining multiple conditions", + ), + IndexQueryTestCase( + id="or_conditions", + doc=( + {"_id": 1, "a": 3}, + {"_id": 2, "a": 7}, + {"_id": 3, "a": 50}, + {"_id": 4, "a": 100}, + ), + indexes=( + { + "key": {"a": 1}, + "name": "idx_or", + "partialFilterExpression": {"$or": [{"a": {"$lt": 5}}, {"a": {"$gt": 80}}]}, + }, + ), + filter={"$or": [{"a": {"$lt": 5}}, {"a": {"$gt": 80}}]}, + hint={"a": 1}, + sort={"_id": 1}, + expected=[{"_id": 1, "a": 3}, {"_id": 4, "a": 100}], + msg="Partial index with $or combining multiple conditions", + ), + IndexQueryTestCase( + id="in_large_array", + doc=( + {"_id": 1, "status": "active"}, + {"_id": 2, "status": "pending"}, + {"_id": 3, "status": "closed"}, + {"_id": 4, "status": "active"}, + ), + indexes=( + { + "key": {"status": 1}, + "name": "idx_in", + "partialFilterExpression": {"status": {"$in": ["active", "pending"]}}, + }, + ), + filter={"status": {"$in": ["active", "pending"]}}, + hint={"status": 1}, + sort={"_id": 1}, + expected=[ + {"_id": 1, "status": "active"}, + {"_id": 2, "status": "pending"}, + {"_id": 4, "status": "active"}, + ], + msg="Partial index with $in with multiple values", + ), + IndexQueryTestCase( + id="in_query_subset_of_filter", + doc=( + {"_id": 1, "status": "active"}, + {"_id": 2, "status": "pending"}, + {"_id": 3, "status": "closed"}, + ), + indexes=( + { + "key": {"status": 1}, + "name": "idx_in", + "partialFilterExpression": {"status": {"$in": ["active", "pending", "review"]}}, + }, + ), + filter={"status": {"$in": ["active", "pending"]}}, + hint={"status": 1}, + sort={"_id": 1}, + expected=[{"_id": 1, "status": "active"}, {"_id": 2, "status": "pending"}], + msg="Query $in subset of filter $in — uses partial index correctly", + ), + IndexQueryTestCase( + id="in_query_superset_of_filter", + doc=( + {"_id": 1, "status": "active"}, + {"_id": 2, "status": "pending"}, + {"_id": 3, "status": "closed"}, + ), + indexes=( + { + "key": {"status": 1}, + "name": "idx_in", + "partialFilterExpression": {"status": {"$in": ["active"]}}, + }, + ), + filter={"status": {"$in": ["active", "pending"]}}, + hint={"status": 1}, + sort={"_id": 1}, + expected=[{"_id": 1, "status": "active"}], + msg="Query $in superset of filter $in — only indexed docs returned with hint", + ), +] + +PARTIAL_NESTED_FIELDS: list[IndexQueryTestCase] = [ + IndexQueryTestCase( + id="nested_field_exists", + doc=( + {"_id": 1, "a": {"b": 1}}, + {"_id": 2, "a": {"c": 2}}, + {"_id": 3, "a": {"b": 5}}, + ), + indexes=( + { + "key": {"a.b": 1}, + "name": "idx_nested", + "partialFilterExpression": {"a.b": {"$exists": True}}, + }, + ), + filter={"a.b": {"$exists": True}}, + hint={"a.b": 1}, + sort={"_id": 1}, + expected=[{"_id": 1, "a": {"b": 1}}, {"_id": 3, "a": {"b": 5}}], + msg="Partial index on nested field with $exists: true", + ), + IndexQueryTestCase( + id="nested_field_gt", + doc=( + {"_id": 1, "a": {"b": 1}}, + {"_id": 2, "a": {"b": 10}}, + {"_id": 3, "a": {"b": 20}}, + ), + indexes=( + { + "key": {"a.b": 1}, + "name": "idx_nested_gt", + "partialFilterExpression": {"a.b": {"$gt": 5}}, + }, + ), + filter={"a.b": {"$gt": 5}}, + hint={"a.b": 1}, + sort={"_id": 1}, + expected=[{"_id": 2, "a": {"b": 10}}, {"_id": 3, "a": {"b": 20}}], + msg="Partial index on nested field with $gt", + ), + IndexQueryTestCase( + id="array_element_path", + doc=( + {"_id": 1, "arr": [10, 20, 30]}, + {"_id": 2, "arr": [1, 2, 3]}, + {"_id": 3, "arr": [5, 50, 500]}, + ), + indexes=( + { + "key": {"arr.0": 1}, + "name": "idx_arr0", + "partialFilterExpression": {"arr.0": {"$gt": 4}}, + }, + ), + filter={"arr.0": {"$gt": 4}}, + hint={"arr.0": 1}, + sort={"_id": 1}, + expected=[{"_id": 1, "arr": [10, 20, 30]}, {"_id": 3, "arr": [5, 50, 500]}], + msg="Partial index on arr.0 targets first array element", + ), +] + +PARTIAL_NON_KEY_FIELD: list[IndexQueryTestCase] = [ + IndexQueryTestCase( + id="key_a_filter_b", + doc=( + {"_id": 1, "a": 10, "b": 1}, + {"_id": 2, "a": 20, "b": 5}, + {"_id": 3, "a": 30, "b": 10}, + ), + indexes=( + { + "key": {"a": 1}, + "name": "idx_a_filter_b", + "partialFilterExpression": {"b": {"$gt": 3}}, + }, + ), + filter={"b": {"$gt": 3}}, + hint={"a": 1}, + sort={"_id": 1}, + expected=[{"_id": 2, "a": 20, "b": 5}, {"_id": 3, "a": 30, "b": 10}], + msg="Index on field 'a' with filter on field 'b'", + ), + IndexQueryTestCase( + id="compound_key_filter_on_third_field", + doc=( + {"_id": 1, "a": 1, "b": 2, "c": 10}, + {"_id": 2, "a": 3, "b": 4, "c": 1}, + {"_id": 3, "a": 5, "b": 6, "c": 20}, + ), + indexes=( + { + "key": {"a": 1, "b": 1}, + "name": "idx_ab_filter_c", + "partialFilterExpression": {"c": {"$gt": 5}}, + }, + ), + filter={"c": {"$gt": 5}}, + hint={"a": 1, "b": 1}, + sort={"_id": 1}, + expected=[{"_id": 1, "a": 1, "b": 2, "c": 10}, {"_id": 3, "a": 5, "b": 6, "c": 20}], + msg="Compound index {a: 1, b: 1} with filter on field 'c'", + ), +] + +_DOCS_EXISTS_A = ( + {"_id": 1, "a": 10}, + {"_id": 2, "a": None}, + {"_id": 3}, +) + +_IDX_EXISTS_A = ( + { + "key": {"a": 1}, + "name": "idx_partial_exists", + "partialFilterExpression": {"a": {"$exists": True}}, + }, +) + +PARTIAL_NULL_MISSING: list[IndexQueryTestCase] = [ + IndexQueryTestCase( + id="exists_includes_null", + doc=_DOCS_EXISTS_A, + indexes=_IDX_EXISTS_A, + filter={"a": None}, + hint={"a": 1}, + sort={"_id": 1}, + expected=[{"_id": 2, "a": None}], + msg="Partial index with $exists: true includes documents with null value", + ), + IndexQueryTestCase( + id="exists_includes_valued", + doc=_DOCS_EXISTS_A, + indexes=_IDX_EXISTS_A, + filter={"a": 10}, + hint={"a": 1}, + sort={"_id": 1}, + expected=[{"_id": 1, "a": 10}], + msg="Partial index with $exists: true includes documents with actual values", + ), + IndexQueryTestCase( + id="gt_filter_null_handling", + # Verifies null (_id: 4) and missing (_id: 5) do NOT satisfy $gt N — + # differentiator from gt_zero_boundary, which only covers numeric boundaries. + doc=( + {"_id": 1, "a": 0}, + {"_id": 2, "a": 5}, + {"_id": 3, "a": 10}, + {"_id": 4, "a": None}, + {"_id": 5}, + ), + indexes=( + {"key": {"a": 1}, "name": "idx_gt5", "partialFilterExpression": {"a": {"$gt": 5}}}, + ), + filter={"a": {"$gt": 5}}, + hint={"a": 1}, + sort={"_id": 1}, + expected=[{"_id": 3, "a": 10}], + msg="Partial index with $gt filter — documents at or below threshold not indexed", + ), +] + +PARTIAL_EDGE_CASES: list[IndexQueryTestCase] = [ + IndexQueryTestCase( + id="gt_zero_boundary", + doc=( + {"_id": 1, "a": -1}, + {"_id": 2, "a": 0}, + {"_id": 3, "a": 1}, + ), + indexes=( + {"key": {"a": 1}, "name": "idx_gt0", "partialFilterExpression": {"a": {"$gt": 0}}}, + ), + filter={"a": {"$gt": 0}}, + hint={"a": 1}, + sort={"_id": 1}, + expected=[{"_id": 3, "a": 1}], + msg="Filter {a: {$gt: 0}} — document with a=0 NOT indexed", + ), + IndexQueryTestCase( + id="gte_zero_boundary", + doc=( + {"_id": 1, "a": -1}, + {"_id": 2, "a": 0}, + {"_id": 3, "a": 1}, + ), + indexes=( + {"key": {"a": 1}, "name": "idx_gte0", "partialFilterExpression": {"a": {"$gte": 0}}}, + ), + filter={"a": {"$gte": 0}}, + hint={"a": 1}, + sort={"_id": 1}, + expected=[{"_id": 2, "a": 0}, {"_id": 3, "a": 1}], + msg="Filter {a: {$gte: 0}} — document with a=0 IS indexed", + ), +] + +PARTIAL_ARRAY_MULTIKEY: list[IndexQueryTestCase] = [ + IndexQueryTestCase( + id="array_field_with_gt_filter", + doc=( + {"_id": 1, "a": [3, 7, 10]}, + {"_id": 2, "a": [1, 2]}, + {"_id": 3, "a": [8, 20]}, + ), + indexes=( + {"key": {"a": 1}, "name": "idx_gt5", "partialFilterExpression": {"a": {"$gt": 5}}}, + ), + filter={"a": {"$gt": 5}}, + hint={"a": 1}, + sort={"_id": 1}, + expected=[{"_id": 1, "a": [3, 7, 10]}, {"_id": 3, "a": [8, 20]}], + msg="Array with any element > 5 matches partial filter", + ), +] + +PARTIAL_NUMERIC_EQUIVALENCE: list[IndexQueryTestCase] = [ + IndexQueryTestCase( + id="int_long_equivalence", + doc=( + {"_id": 1, "a": 3}, + {"_id": 2, "a": 7}, + {"_id": 3, "a": 10}, + ), + indexes=( + {"key": {"a": 1}, "name": "idx_int", "partialFilterExpression": {"a": {"$gt": 5}}}, + ), + filter={"a": {"$gt": Int64(5)}}, + hint={"a": 1}, + sort={"_id": 1}, + expected=[{"_id": 2, "a": 7}, {"_id": 3, "a": 10}], + msg="{a: {$gt: int(5)}} matches same docs as {a: {$gt: long(5)}}", + ), + IndexQueryTestCase( + id="double_int_equivalence", + doc=( + {"_id": 1, "a": 3}, + {"_id": 2, "a": 7}, + {"_id": 3, "a": 10}, + ), + indexes=( + {"key": {"a": 1}, "name": "idx_double", "partialFilterExpression": {"a": {"$gt": 5.0}}}, + ), + filter={"a": {"$gt": 5}}, + hint={"a": 1}, + sort={"_id": 1}, + expected=[{"_id": 2, "a": 7}, {"_id": 3, "a": 10}], + msg="{a: {$gt: 5.0}} matches same docs as {a: {$gt: 5}}", + ), + IndexQueryTestCase( + id="decimal128_int_equivalence", + doc=( + {"_id": 1, "a": 3}, + {"_id": 2, "a": 7}, + {"_id": 3, "a": 10}, + ), + indexes=( + { + "key": {"a": 1}, + "name": "idx_dec", + "partialFilterExpression": {"a": {"$gt": Decimal128("5")}}, + }, + ), + filter={"a": {"$gt": 5}}, + hint={"a": 1}, + sort={"_id": 1}, + expected=[{"_id": 2, "a": 7}, {"_id": 3, "a": 10}], + msg="{a: {$gt: Decimal128('5')}} matches same docs as {a: {$gt: int(5)}}", + ), +] + +PARTIAL_NAN_INFINITY: list[IndexQueryTestCase] = [ + IndexQueryTestCase( + id="gt_nan", + doc=( + {"_id": 1, "a": float("nan")}, + {"_id": 2, "a": 3}, + {"_id": 3, "a": 10}, + ), + indexes=( + { + "key": {"a": 1}, + "name": "idx_nan", + "partialFilterExpression": {"a": {"$gt": float("nan")}}, + }, + ), + filter={"a": {"$gt": float("nan")}}, + hint={"a": 1}, + sort={"_id": 1}, + expected=[], + msg="partialFilterExpression {$gt: NaN} indexes no documents (NaN comparison always false)", + ), + IndexQueryTestCase( + id="gt_infinity", + doc=( + {"_id": 1, "a": 3}, + {"_id": 2, "a": 10}, + {"_id": 3, "a": float("inf")}, + ), + indexes=( + { + "key": {"a": 1}, + "name": "idx_inf", + "partialFilterExpression": {"a": {"$gt": float("inf")}}, + }, + ), + filter={"a": {"$gt": float("inf")}}, + hint={"a": 1}, + sort={"_id": 1}, + expected=[], + msg="partialFilterExpression {$gt: Infinity} matches nothing", + ), +] + +PARTIAL_BSON_TYPE_DISTINCTION: list[IndexQueryTestCase] = [ + IndexQueryTestCase( + id="bool_vs_int_distinction", + doc=( + {"_id": 1, "a": True}, + {"_id": 2, "a": 1}, + {"_id": 3, "a": False}, + {"_id": 4, "a": 0}, + ), + indexes=( + {"key": {"a": 1}, "name": "idx_bool_true", "partialFilterExpression": {"a": True}}, + ), + filter={"a": True}, + hint={"a": 1}, + sort={"_id": 1}, + expected=[{"_id": 1, "a": True}], + msg="partialFilterExpression {a: true} vs {a: 1} — different documents match", + ), + IndexQueryTestCase( + id="null_matches_explicit_null_only", + doc=( + {"_id": 1, "a": None}, + {"_id": 2}, + {"_id": 3, "a": 0}, + ), + indexes=({"key": {"a": 1}, "name": "idx_null", "partialFilterExpression": {"a": None}},), + filter={"a": None}, + hint={"a": 1}, + sort={"_id": 1}, + expected=[{"_id": 1, "a": None}, {"_id": 2}], + msg="partialFilterExpression {a: null} matches docs with explicit null and missing", + ), +] + + +PARTIAL_QUERY_FIND = ( + PARTIAL_QUERY_USAGE + + PARTIAL_QUERY_EXISTS + + PARTIAL_QUERY_RANGE + + PARTIAL_COMPLEX_FILTERS + + PARTIAL_NESTED_FIELDS + + PARTIAL_NON_KEY_FIELD + + PARTIAL_NULL_MISSING + + PARTIAL_EDGE_CASES + + PARTIAL_ARRAY_MULTIKEY + + PARTIAL_NUMERIC_EQUIVALENCE + + PARTIAL_NAN_INFINITY + + PARTIAL_BSON_TYPE_DISTINCTION +) + + +@pytest.mark.parametrize("test", pytest_params(PARTIAL_QUERY_FIND)) +def test_partial_query_find(collection, test): + """Test partial index query behavior using the find command.""" + collection.insert_many(list(test.doc)) + execute_command(collection, {"createIndexes": collection.name, "indexes": list(test.indexes)}) + result = execute_command( + collection, + {"find": collection.name, "filter": test.filter, "hint": test.hint, "sort": test.sort}, + ) + assertSuccess(result, test.expected, msg=test.msg) + + +PARTIAL_QUERY_COUNT: list[IndexQueryTestCase] = [ + IndexQueryTestCase( + id="exists_excludes_missing", + doc=_DOCS_EXISTS_A, + indexes=_IDX_EXISTS_A, + filter={}, + hint={"a": 1}, + expected={"n": 2, "ok": 1.0}, + msg="Partial index with $exists: true excludes documents missing the field", + ), + IndexQueryTestCase( + id="type_number_indexes_all_numerics", + doc=( + {"_id": 1, "a": 1}, + {"_id": 2, "a": 2.5}, + {"_id": 3, "a": Int64(3)}, + {"_id": 4, "a": "not_number"}, + {"_id": 5, "a": True}, + ), + indexes=( + { + "key": {"a": 1}, + "name": "idx_type_num", + "partialFilterExpression": {"a": {"$type": "number"}}, + }, + ), + filter={"a": {"$type": "number"}}, + hint={"a": 1}, + expected={"n": 3, "ok": 1.0}, + msg="Filter {a: {$type: 'number'}} indexes all numeric types", + ), + IndexQueryTestCase( + id="multikey_partial_index", + # Empty-array edge case: tags: [] satisfies {$exists: true} and is indexed + # as a single `undefined` multikey entry, so it counts toward the partial + # index. _id: 4 (no `tags` field) does not match $exists and is excluded. + # Expected n=3: _id 1 (["a","b"]), 2 (["c"]), 3 ([]). + doc=( + {"_id": 1, "tags": ["a", "b"]}, + {"_id": 2, "tags": ["c"]}, + {"_id": 3, "tags": []}, + {"_id": 4}, + ), + indexes=( + { + "key": {"tags": 1}, + "name": "idx_tags_exists", + "partialFilterExpression": {"tags": {"$exists": True}}, + }, + ), + filter={"tags": {"$exists": True}}, + hint={"tags": 1}, + expected={"n": 3, "ok": 1.0}, + msg="Multikey partial index with $exists indexes docs with array field", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(PARTIAL_QUERY_COUNT)) +def test_partial_query_count(collection, test): + """Test partial index query behavior using the count command.""" + collection.insert_many(list(test.doc)) + execute_command(collection, {"createIndexes": collection.name, "indexes": list(test.indexes)}) + result = execute_command( + collection, + {"count": collection.name, "query": test.filter, "hint": test.hint}, + ) + assertSuccess(result, test.expected, raw_res=True, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/indexes/properties/partial/test_partial_timeseries.py b/documentdb_tests/compatibility/tests/core/indexes/properties/partial/test_partial_timeseries.py new file mode 100644 index 00000000..40356d61 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/indexes/properties/partial/test_partial_timeseries.py @@ -0,0 +1,72 @@ +"""Tests for partial index on timeseries collections.""" + +from datetime import datetime, timezone + +import pytest + +from documentdb_tests.compatibility.tests.core.indexes.commands.utils.index_test_case import ( + IndexTestCase, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.index + + +PARTIAL_TIMESERIES_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="metric_field_gt", + indexes=( + { + "key": {"value": 1}, + "name": "idx_metric_gt", + "partialFilterExpression": {"value": {"$gt": 10}}, + }, + ), + expected={"ok": 1.0, "numIndexesAfter": 2}, + msg="Partial index with comparison operator on metric field", + ), + IndexTestCase( + id="ttl_meta_filter", + indexes=( + { + "key": {"ts": 1}, + "name": "idx_ttl_meta", + "expireAfterSeconds": 3600, + "partialFilterExpression": {"meta.active": True}, + }, + ), + expected={"ok": 1.0, "numIndexesAfter": 2}, + msg="TTL index with partialFilterExpression on metaField succeeds", + ), + IndexTestCase( + id="time_field_gt", + indexes=( + { + "key": {"ts": 1}, + "name": "idx_time_gt", + "partialFilterExpression": { + "ts": {"$gt": datetime(2024, 1, 1, tzinfo=timezone.utc)} + }, + }, + ), + expected={"ok": 1.0, "numIndexesAfter": 2}, + msg="Partial index with $gt on time field created successfully", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(PARTIAL_TIMESERIES_TESTS)) +def test_partial_timeseries(database_client, collection, test): + """Test partial index creation on timeseries collections.""" + ts_name = f"{collection.name}_ts" + database_client.command( + {"create": ts_name, "timeseries": {"timeField": "ts", "metaField": "meta"}} + ) + ts_coll = database_client[ts_name] + result = execute_command( + ts_coll, + {"createIndexes": ts_coll.name, "indexes": list(test.indexes)}, + ) + assertSuccessPartial(result, test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/indexes/properties/test_properties_combinations.py b/documentdb_tests/compatibility/tests/core/indexes/properties/test_properties_combinations.py index 422c6c43..032514bc 100644 --- a/documentdb_tests/compatibility/tests/core/indexes/properties/test_properties_combinations.py +++ b/documentdb_tests/compatibility/tests/core/indexes/properties/test_properties_combinations.py @@ -1,23 +1,30 @@ """Tests for index property combinations. Validates that indexes work correctly with combined properties: -TTL with sparse/partial/unique/collation, and collation with -sparse/background options. +TTL with sparse/partial/unique/collation, sparse with unique/collation, +and collation with background options. """ +from datetime import datetime, timezone + import pytest from documentdb_tests.compatibility.tests.core.indexes.commands.utils.index_test_case import ( IndexTestCase, index_created_response, ) -from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.assertions import ( + assertFailureCode, + assertSuccess, + assertSuccessPartial, +) +from documentdb_tests.framework.error_codes import DUPLICATE_KEY_ERROR from documentdb_tests.framework.executor import execute_command from documentdb_tests.framework.parametrize import pytest_params pytestmark = pytest.mark.index -PROPERTY_COMBINATION_TESTS: list[IndexTestCase] = [ +PROPERTY_COMBINATION_CREATE_TESTS: list[IndexTestCase] = [ IndexTestCase( id="ttl_with_sparse", indexes=( @@ -103,14 +110,262 @@ ), msg="Should create index with background option and collation", ), + IndexTestCase( + id="sparse_separate_from_unique", + indexes=({"key": {"a": 1}, "name": "idx_sparse", "sparse": True},), + setup_indexes=[{"key": {"a": 1}, "name": "idx_unique", "unique": True}], + expected=index_created_response(num_indexes_before=2, num_indexes_after=3), + msg="Sparse index should be separate from unique index on same key", + ), + IndexTestCase( + id="unique_sparse_separate_from_sparse", + indexes=({"key": {"a": 1}, "name": "idx_unique_sparse", "sparse": True, "unique": True},), + setup_indexes=[{"key": {"a": 1}, "name": "idx_sparse", "sparse": True}], + expected=index_created_response(num_indexes_before=2, num_indexes_after=3), + msg="Unique+sparse should be separate from sparse-only on same key", + ), + IndexTestCase( + id="text_with_partial", + indexes=( + { + "key": {"content": "text"}, + "name": "idx_text_partial", + "partialFilterExpression": {"status": "published"}, + }, + ), + msg="Should allow text index + partialFilterExpression", + ), + IndexTestCase( + id="hidden_with_partial", + indexes=( + { + "key": {"a": 1}, + "name": "idx_hidden_partial", + "hidden": True, + "partialFilterExpression": {"a": {"$gt": 0}}, + }, + ), + msg="Should allow hidden + partialFilterExpression", + ), + IndexTestCase( + id="collation_with_partial", + indexes=( + { + "key": {"name": 1}, + "name": "idx_collation_partial", + "collation": {"locale": "en", "strength": 2}, + "partialFilterExpression": {"name": {"$exists": True}}, + }, + ), + msg="Should allow collation + partialFilterExpression", + ), + IndexTestCase( + id="collation_partial_signature", + indexes=( + { + "key": {"name": 1}, + "name": "idx_collation_partial_fr", + "collation": {"locale": "fr"}, + "partialFilterExpression": {"name": {"$exists": True}}, + }, + ), + setup_indexes=[ + { + "key": {"name": 1}, + "name": "idx_collation_partial_en", + "collation": {"locale": "en"}, + "partialFilterExpression": {"name": {"$exists": True}}, + } + ], + expected=index_created_response(num_indexes_before=2, num_indexes_after=3), + msg="Same partial filter with different collation creates separate index", + ), ] -@pytest.mark.parametrize("test", pytest_params(PROPERTY_COMBINATION_TESTS)) -def test_property_combination(collection, test): +@pytest.mark.parametrize("test", pytest_params(PROPERTY_COMBINATION_CREATE_TESTS)) +def test_property_combination_create(collection, test): """Test that indexes can be created with combined properties.""" + if hasattr(test, "setup_indexes") and test.setup_indexes: + execute_command( + collection, + {"createIndexes": collection.name, "indexes": test.setup_indexes}, + ) + result = execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + expected = test.expected if test.expected is not None else index_created_response() + assertSuccessPartial(result, expected, msg=test.msg) + + +SPARSE_TTL_COUNT_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="ttl_combination", + indexes=( + { + "key": {"expires": 1}, + "name": "idx_sparse_ttl", + "sparse": True, + "expireAfterSeconds": 3600, + }, + ), + doc=( + {"_id": 1, "expires": datetime(2099, 1, 1, tzinfo=timezone.utc)}, + {"_id": 2}, + ), + expected={"n": 1, "ok": 1.0}, + command_options={"query": {}, "hint": {"expires": 1}}, + msg="Sparse + TTL index — documents without TTL field not indexed", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SPARSE_TTL_COUNT_TESTS)) +def test_sparse_ttl_count(collection, test): + """Test sparse + TTL index behavior via count with hint.""" + execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + if test.doc: + collection.insert_many(list(test.doc)) + result = execute_command( + collection, + {"count": collection.name, **test.command_options}, + ) + assertSuccess(result, test.expected, raw_res=True) + + +SPARSE_UNIQUE_SUCCESS_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="unique_allows_multiple_missing", + indexes=({"key": {"a": 1}, "name": "idx_sparse_unique", "sparse": True, "unique": True},), + doc=({"_id": 1},), + expected={"n": 1, "ok": 1.0}, + command_options={"documents": [{"_id": 2}]}, + msg="Sparse + unique allows multiple documents missing the indexed field", + ), + IndexTestCase( + id="unique_allows_one_null", + indexes=({"key": {"a": 1}, "name": "idx_sparse_unique", "sparse": True, "unique": True},), + doc=({"_id": 1, "a": 5},), + expected={"n": 1, "ok": 1.0}, + command_options={"documents": [{"_id": 2, "a": None}]}, + msg="Sparse + unique allows first document with null value", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SPARSE_UNIQUE_SUCCESS_TESTS)) +def test_sparse_unique_success(collection, test): + """Test sparse + unique allows valid inserts.""" + execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + if test.doc: + collection.insert_many(list(test.doc)) result = execute_command( + collection, + {"insert": collection.name, **test.command_options}, + ) + assertSuccess(result, test.expected, raw_res=True) + + +SPARSE_UNIQUE_FAILURE_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="unique_rejects_second_null", + indexes=({"key": {"a": 1}, "name": "idx_sparse_unique", "sparse": True, "unique": True},), + doc=({"_id": 1, "a": None},), + error_code=DUPLICATE_KEY_ERROR, + command_options={"documents": [{"_id": 2, "a": None}]}, + msg="Sparse + unique rejects second document with null value", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SPARSE_UNIQUE_FAILURE_TESTS)) +def test_sparse_unique_failure(collection, test): + """Test sparse + unique rejects invalid inserts.""" + execute_command( collection, {"createIndexes": collection.name, "indexes": list(test.indexes)}, ) - assertSuccessPartial(result, index_created_response(), msg=test.msg) + if test.doc: + collection.insert_many(list(test.doc)) + result = execute_command( + collection, + {"insert": collection.name, **test.command_options}, + ) + assertFailureCode(result, test.error_code, test.msg) + + +_PARTIAL_UNIQUE_IDX = ( + { + "key": {"a": 1}, + "name": "idx_partial_unique", + "partialFilterExpression": {"a": {"$gt": 0}}, + "unique": True, + }, +) + +PARTIAL_UNIQUE_ALLOWED: list[IndexTestCase] = [ + IndexTestCase( + id="duplicate_not_matching", + indexes=_PARTIAL_UNIQUE_IDX, + doc=({"_id": 1, "a": -1},), + input={"_id": 2, "a": -1}, + msg="Should allow duplicate outside filter", + ), + IndexTestCase( + id="missing_field", + indexes=_PARTIAL_UNIQUE_IDX, + doc=({"_id": 1},), + input={"_id": 2}, + msg="Should allow multiple docs with missing field", + ), + IndexTestCase( + id="zero_duplicates", + indexes=_PARTIAL_UNIQUE_IDX, + doc=({"_id": 1, "a": 0},), + input={"_id": 2, "a": 0}, + msg="Should allow duplicate zero (not in filter)", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(PARTIAL_UNIQUE_ALLOWED)) +def test_partial_unique_allows_duplicates_outside_filter(collection, test): + """Test partial unique index allows duplicates for documents not matching filter.""" + execute_command(collection, {"createIndexes": collection.name, "indexes": list(test.indexes)}) + collection.insert_many(list(test.doc)) + result = execute_command( + collection, + {"insert": collection.name, "documents": [test.input]}, + ) + assertSuccessPartial(result, {"ok": 1.0, "n": 1}, msg=test.msg) + + +PARTIAL_UNIQUE_REJECTED: list[IndexTestCase] = [ + IndexTestCase( + id="duplicate_matching", + indexes=_PARTIAL_UNIQUE_IDX, + doc=({"_id": 1, "a": 5},), + input={"_id": 2, "a": 5}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should reject duplicate in partial unique index", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(PARTIAL_UNIQUE_REJECTED)) +def test_partial_unique_rejects_duplicates_inside_filter(collection, test): + """Test partial unique index rejects duplicates for documents matching filter.""" + execute_command(collection, {"createIndexes": collection.name, "indexes": list(test.indexes)}) + collection.insert_many(list(test.doc)) + result = execute_command( + collection, + {"insert": collection.name, "documents": [test.input]}, + ) + assertFailureCode(result, test.error_code, msg=test.msg) From 8a116b726bc4c1f5ac12d8deae951ee4df2a9b9e Mon Sep 17 00:00:00 2001 From: Victor Tsang Date: Fri, 15 May 2026 14:06:09 -0700 Subject: [PATCH 2/3] renamed error constant to QUERY_FEATURE_NOT_ALLOWED Signed-off-by: Victor Tsang --- .../core/indexes/properties/partial/test_partial_errors.py | 4 ++-- .../tests/core/operator/query/misc/expr/test_expr_errors.py | 4 ++-- documentdb_tests/framework/error_codes.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/documentdb_tests/compatibility/tests/core/indexes/properties/partial/test_partial_errors.py b/documentdb_tests/compatibility/tests/core/indexes/properties/partial/test_partial_errors.py index 35d206bc..e1944694 100644 --- a/documentdb_tests/compatibility/tests/core/indexes/properties/partial/test_partial_errors.py +++ b/documentdb_tests/compatibility/tests/core/indexes/properties/partial/test_partial_errors.py @@ -10,9 +10,9 @@ BAD_VALUE_ERROR, CANNOT_CREATE_INDEX_ERROR, COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, - EXPR_IN_ARRAY_FILTERS_ERROR, INDEX_OPTIONS_CONFLICT_ERROR, INVALID_OPTIONS_ERROR, + QUERY_FEATURE_NOT_ALLOWED, ) from documentdb_tests.framework.executor import execute_command from documentdb_tests.framework.parametrize import pytest_params @@ -81,7 +81,7 @@ "partialFilterExpression": {"$expr": {"$gt": ["$a", 5]}}, }, ), - error_code=EXPR_IN_ARRAY_FILTERS_ERROR, + error_code=QUERY_FEATURE_NOT_ALLOWED, msg="Should reject $expr in partialFilterExpression", ), IndexTestCase( diff --git a/documentdb_tests/compatibility/tests/core/operator/query/misc/expr/test_expr_errors.py b/documentdb_tests/compatibility/tests/core/operator/query/misc/expr/test_expr_errors.py index 412ee23b..5aa7ac69 100644 --- a/documentdb_tests/compatibility/tests/core/operator/query/misc/expr/test_expr_errors.py +++ b/documentdb_tests/compatibility/tests/core/operator/query/misc/expr/test_expr_errors.py @@ -14,12 +14,12 @@ from documentdb_tests.framework.assertions import assertFailureCode, assertResult from documentdb_tests.framework.error_codes import ( BAD_VALUE_ERROR, - EXPR_IN_ARRAY_FILTERS_ERROR, EXPRESSION_IN_NOT_ARRAY_ERROR, EXPRESSION_TYPE_MISMATCH_ERROR, FAILED_TO_PARSE_ERROR, INVALID_DOLLAR_FIELD_PATH, LET_UNDEFINED_VARIABLE_ERROR, + QUERY_FEATURE_NOT_ALLOWED, UNRECOGNIZED_EXPRESSION_ERROR, ) from documentdb_tests.framework.executor import execute_command @@ -102,7 +102,7 @@ def test_expr_in_array_filters(collection): ], }, ) - assertFailureCode(result, EXPR_IN_ARRAY_FILTERS_ERROR) + assertFailureCode(result, QUERY_FEATURE_NOT_ALLOWED) def test_expr_in_elemmatch_query(collection): diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 1af1756b..985c7513 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -27,7 +27,7 @@ VIEW_PIPELINE_TOO_LARGE_ERROR = 195 INVALID_INDEX_SPEC_OPTION_ERROR = 197 INVALID_UUID_ERROR = 207 -EXPR_IN_ARRAY_FILTERS_ERROR = 224 +QUERY_FEATURE_NOT_ALLOWED = 224 MAX_NESTED_SUB_PIPELINE_ERROR = 232 CONVERSION_FAILURE_ERROR = 241 NO_QUERY_EXECUTION_PLANS_ERROR = 291 From 82dcc13950410267145602621183e954c2cc4dd7 Mon Sep 17 00:00:00 2001 From: Victor Tsang Date: Tue, 19 May 2026 11:07:51 -0700 Subject: [PATCH 3/3] added _id error test Signed-off-by: Victor Tsang --- .../properties/partial/test_partial_errors.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/documentdb_tests/compatibility/tests/core/indexes/properties/partial/test_partial_errors.py b/documentdb_tests/compatibility/tests/core/indexes/properties/partial/test_partial_errors.py index e1944694..2f50c104 100644 --- a/documentdb_tests/compatibility/tests/core/indexes/properties/partial/test_partial_errors.py +++ b/documentdb_tests/compatibility/tests/core/indexes/properties/partial/test_partial_errors.py @@ -11,6 +11,7 @@ CANNOT_CREATE_INDEX_ERROR, COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, INDEX_OPTIONS_CONFLICT_ERROR, + INVALID_INDEX_SPEC_OPTION_ERROR, INVALID_OPTIONS_ERROR, QUERY_FEATURE_NOT_ALLOWED, ) @@ -193,6 +194,18 @@ error_code=CANNOT_CREATE_INDEX_ERROR, msg="Should reject sparse combined with partialFilterExpression", ), + IndexTestCase( + id="id_index_partial", + indexes=( + { + "key": {"_id": 1}, + "name": "_id_", + "partialFilterExpression": {"_id": {"$gt": 0}}, + }, + ), + error_code=INVALID_INDEX_SPEC_OPTION_ERROR, + msg="Should reject _id indexes as partial indexes", + ), ]