From e707fe2a3b146f1c11cde6e60f37e68574bd5e5d Mon Sep 17 00:00:00 2001 From: "Victor [C] Tsang" Date: Fri, 10 Jul 2026 15:45:48 +0000 Subject: [PATCH] Add indexes types tests for hidden Signed-off-by: Victor [C] Tsang --- .../indexes/properties/hidden/__init__.py | 0 .../properties/hidden/test_hidden_behavior.py | 521 ++++++++++ .../hidden/test_hidden_bson_validation.py | 81 ++ .../hidden/test_hidden_create_index.py | 352 +++++++ .../properties/hidden/test_hidden_errors.py | 349 +++++++ .../hidden/test_hidden_query_planning.py | 939 ++++++++++++++++++ .../properties/hidden/utils/__init__.py | 0 .../properties/hidden/utils/helpers.py | 108 ++ 8 files changed, 2350 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/indexes/properties/hidden/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/indexes/properties/hidden/test_hidden_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/indexes/properties/hidden/test_hidden_bson_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/indexes/properties/hidden/test_hidden_create_index.py create mode 100644 documentdb_tests/compatibility/tests/core/indexes/properties/hidden/test_hidden_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/indexes/properties/hidden/test_hidden_query_planning.py create mode 100644 documentdb_tests/compatibility/tests/core/indexes/properties/hidden/utils/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/indexes/properties/hidden/utils/helpers.py diff --git a/documentdb_tests/compatibility/tests/core/indexes/properties/hidden/__init__.py b/documentdb_tests/compatibility/tests/core/indexes/properties/hidden/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/indexes/properties/hidden/test_hidden_behavior.py b/documentdb_tests/compatibility/tests/core/indexes/properties/hidden/test_hidden_behavior.py new file mode 100644 index 000000000..113f23ef9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/indexes/properties/hidden/test_hidden_behavior.py @@ -0,0 +1,521 @@ +"""Tests for hidden index runtime behavior — stats, writes, and TTL.""" + +import time +from datetime import datetime, timedelta, timezone + +import pytest +from bson import Int64 + +from documentdb_tests.compatibility.tests.core.indexes.commands.utils.index_test_case import ( + IndexTestCase, +) +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +from .utils.helpers import get_index_doc, get_index_ops + +pytestmark = pytest.mark.index + + +@pytest.fixture +def fast_ttl_monitor(collection): + """Set ttlMonitorSleepSecs to 1 for the test, restore afterward.""" + original = execute_admin_command(collection, {"getParameter": 1, "ttlMonitorSleepSecs": 1}) + set_result = execute_admin_command(collection, {"setParameter": 1, "ttlMonitorSleepSecs": 1}) + if isinstance(set_result, Exception): + pytest.skip("engine does not support setParameter ttlMonitorSleepSecs") + yield + if isinstance(original, dict) and "ttlMonitorSleepSecs" in original: + execute_admin_command( + collection, + {"setParameter": 1, "ttlMonitorSleepSecs": original["ttlMonitorSleepSecs"]}, + ) + + +INDEXSTATS_COUNTER_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="reset_on_hide", + indexes=({"key": {"a": 1}, "name": "a_1"},), + input={"pre_use": True, "toggle_sequence": [True]}, + expected=False, + msg="Hiding an index should reset its $indexStats accesses to zero", + ), + IndexTestCase( + id="unhide_stays_zero", + indexes=({"key": {"a": 1}, "name": "a_1", "hidden": True},), + input={"pre_use": False, "toggle_sequence": [False]}, + expected=False, + msg="Unhiding an index alone should not increment its accesses counter", + ), + IndexTestCase( + id="find_after_unhide_increments", + indexes=({"key": {"a": 1}, "name": "a_1", "hidden": True},), + input={"pre_use": False, "toggle_sequence": [False], "post_use": True}, + expected=True, + msg="A query after unhide should increment the accesses counter", + ), + IndexTestCase( + id="unhide_resets_counter", + indexes=({"key": {"a": 1}, "name": "a_1"},), + input={"pre_use": True, "toggle_sequence": [True, False]}, + expected=False, + msg="Unhiding a hidden index should leave the accesses counter at zero", + ), + IndexTestCase( + id="idempotent_unhide_no_reset", + indexes=({"key": {"a": 1}, "name": "a_1"},), + input={"pre_use": True, "toggle_sequence": [False]}, + expected=True, + msg="An idempotent unhide should not reset the accesses counter", + ), + IndexTestCase( + id="multiple_reset_cycle", + indexes=({"key": {"a": 1}, "name": "a_1"},), + input={"pre_use": True, "toggle_sequence": [True, False, True]}, + expected=False, + msg="Repeated hide/unhide toggles should each reset the counter to zero", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(INDEXSTATS_COUNTER_TESTS)) +def test_indexstats_counter(collection, test): + """Test $indexStats accesses counter behavior on hide/unhide.""" + collection.insert_many([{"a": i} for i in range(10)]) + execute_command( + collection, + {"createIndexes": collection.name, "indexes": [test.indexes[0]]}, + ) + if test.input.get("pre_use"): + execute_command( + collection, {"find": collection.name, "filter": {"a": {"$gte": 0}}, "hint": "a_1"} + ) + for hidden_value in test.input["toggle_sequence"]: + execute_command( + collection, + {"collMod": collection.name, "index": {"name": "a_1", "hidden": hidden_value}}, + ) + if test.input.get("post_use"): + execute_command( + collection, {"find": collection.name, "filter": {"a": {"$gte": 0}}, "hint": "a_1"} + ) + result = execute_command( + collection, {"aggregate": collection.name, "pipeline": [{"$indexStats": {}}], "cursor": {}} + ) + assertSuccess( + result, + test.expected, + raw_res=True, + transform=lambda r: next( + ( + doc["accesses"]["ops"] > 0 + for doc in r["cursor"]["firstBatch"] + if doc.get("name") == "a_1" + ), + None, + ), + msg=test.msg, + ) + + +WRITE_BEHAVIOR_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="insert_maintained", + input={"insert_docs": [{"a": i} for i in range(20)], "query_filter": {"a": 5}}, + expected=[5], + msg="Inserts while hidden should be reflected in the index (found via hint)", + ), + IndexTestCase( + id="update_adds_new_entry", + input={ + "insert_docs": [{"_id": 1, "a": 1}], + "update": {"q": {"a": 1}, "u": {"$set": {"a": 2}}}, + "query_filter": {"a": 2}, + }, + expected=[2], + msg="Update while hidden should create the new index entry", + ), + IndexTestCase( + id="update_removes_old_entry", + input={ + "insert_docs": [{"_id": 1, "a": 1}], + "update": {"q": {"a": 1}, "u": {"$set": {"a": 2}}}, + "query_filter": {"a": 1}, + }, + expected=[], + msg="Update while hidden should remove the old index entry", + ), + IndexTestCase( + id="delete_removes_entry", + input={ + "insert_docs": [{"a": 1}, {"a": 2}], + "delete": {"q": {"a": 1}, "limit": 0}, + "query_filter": {"a": 1}, + }, + expected=[], + msg="Delete while hidden should remove the corresponding index entry", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(WRITE_BEHAVIOR_TESTS)) +def test_write_maintained_in_hidden_index(collection, test): + """Test writes while hidden are reflected after unhide.""" + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"a": 1}, "name": "a_1", "hidden": True}], + }, + ) + execute_command(collection, {"insert": collection.name, "documents": test.input["insert_docs"]}) + if "update" in test.input: + execute_command( + collection, + {"update": collection.name, "updates": [test.input["update"]]}, + ) + if "delete" in test.input: + execute_command( + collection, + {"delete": collection.name, "deletes": [test.input["delete"]]}, + ) + execute_command( + collection, {"collMod": collection.name, "index": {"name": "a_1", "hidden": False}} + ) + result = execute_command( + collection, + {"find": collection.name, "filter": test.input["query_filter"], "hint": "a_1"}, + ) + assertSuccess( + result, + test.expected, + transform=lambda docs: [d["a"] for d in docs], + msg=test.msg, + ) + + +WRITE_SPECIAL_INDEX_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="multikey_maintained", + indexes=({"key": {"tags": 1}, "name": "idx", "hidden": True},), + input={ + "insert_docs": [{"_id": 1, "tags": ["a", "b", "c"]}, {"_id": 2, "tags": ["d"]}], + "query_filter": {"tags": "b"}, + }, + expected=[1], + msg="Array (multikey) writes while hidden should be indexed per element", + ), + IndexTestCase( + id="partial_maintained", + indexes=( + { + "key": {"a": 1}, + "name": "idx", + "partialFilterExpression": {"a": {"$gte": 10}}, + "hidden": True, + }, + ), + input={ + "insert_docs": [{"_id": 1, "a": 5}, {"_id": 2, "a": 15}, {"_id": 3, "a": 20}], + "query_filter": {"a": {"$gte": 10}}, + }, + expected=[2, 3], + msg="Partial index while hidden should index only matching documents", + ), + IndexTestCase( + id="compound_maintained", + indexes=({"key": {"a": 1, "b": 1}, "name": "idx", "hidden": True},), + input={ + "insert_docs": [{"_id": 1, "a": 1, "b": 2}, {"_id": 2, "a": 1, "b": 3}], + "query_filter": {"a": 1, "b": 3}, + }, + expected=[2], + msg="Compound index writes while hidden should be maintained across both keys", + ), + IndexTestCase( + id="sparse_maintained", + indexes=({"key": {"a": 1}, "name": "idx", "sparse": True, "hidden": True},), + input={ + "insert_docs": [{"_id": 1, "a": 1}, {"_id": 2}, {"_id": 3, "a": 3}], + "query_filter": {}, + }, + expected=[1, 3], + msg="Sparse index while hidden should skip documents missing the field", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(WRITE_SPECIAL_INDEX_TESTS)) +def test_special_index_writes_maintained_while_hidden(collection, test): + """Test writes to special-type hidden indexes are maintained after unhide.""" + execute_command(collection, {"createIndexes": collection.name, "indexes": [test.indexes[0]]}) + execute_command(collection, {"insert": collection.name, "documents": test.input["insert_docs"]}) + execute_command( + collection, {"collMod": collection.name, "index": {"name": "idx", "hidden": False}} + ) + result = execute_command( + collection, + {"find": collection.name, "filter": test.input["query_filter"], "hint": "idx"}, + ) + assertSuccess( + result, + test.expected, + transform=lambda docs: sorted(d["_id"] for d in docs), + msg=test.msg, + ) + + +def test_indexstats_no_increment_while_hidden_zero_ops(collection): + """Test queries on a hidden index don't increment its accesses counter.""" + collection.insert_many([{"a": i} for i in range(10)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"a": 1}, "name": "a_1", "hidden": True}], + }, + ) + execute_command(collection, {"find": collection.name, "filter": {"a": 5}}) + result = execute_command( + collection, {"aggregate": collection.name, "pipeline": [{"$indexStats": {}}], "cursor": {}} + ) + ops = get_index_ops(result, "a_1") + assertSuccess( + ops, + Int64(0), + raw_res=True, + msg="A query while hidden should not increment the index accesses counter", + ) + + +def test_indexstats_shows_hidden_index_spec_hidden_true(collection): + """Test $indexStats reports spec.hidden=true for hidden index.""" + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"a": 1}, "name": "a_1", "hidden": True}], + }, + ) + result = execute_command( + collection, {"aggregate": collection.name, "pipeline": [{"$indexStats": {}}], "cursor": {}} + ) + index_doc = get_index_doc(result, "a_1") + hidden_val = index_doc.get("spec", {}).get("hidden", "__ABSENT__") + assertSuccess( + hidden_val, + True, + raw_res=True, + msg="$indexStats should show the hidden index with spec.hidden=true", + ) + + +def test_indexstats_filter_on_spec_hidden(collection): + """Test $indexStats can be filtered on spec.hidden to find hidden indexes.""" + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [ + {"key": {"a": 1}, "name": "a_1", "hidden": True}, + {"key": {"b": 1}, "name": "b_1"}, + ], + }, + ) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [{"$indexStats": {}}, {"$match": {"spec.hidden": True}}], + "cursor": {}, + }, + ) + assertSuccess( + result, + ["a_1"], + transform=lambda docs: sorted(d["name"] for d in docs), + msg="Filtering $indexStats on spec.hidden should return only hidden indexes", + ) + + +def test_indexstats_nonhidden_accesses_increment_nonzero(collection): + """Test $indexStats reports non-zero usage after querying a non-hidden index.""" + collection.insert_many([{"a": i} for i in range(10)]) + execute_command( + collection, + {"createIndexes": collection.name, "indexes": [{"key": {"a": 1}, "name": "a_1"}]}, + ) + execute_command( + collection, {"find": collection.name, "filter": {"a": {"$gte": 0}}, "hint": "a_1"} + ) + result = execute_command( + collection, {"aggregate": collection.name, "pipeline": [{"$indexStats": {}}], "cursor": {}} + ) + ops = get_index_ops(result, "a_1") + assertSuccess( + ops >= 1, + True, + raw_res=True, + msg="Using a non-hidden index should increment its $indexStats accesses", + ) + + +def test_indexstats_reset_only_affects_toggled_index_a_reset(collection): + """Test hiding a_1 resets its counter to zero.""" + collection.insert_many([{"a": i, "b": i} for i in range(10)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"a": 1}, "name": "a_1"}, {"key": {"b": 1}, "name": "b_1"}], + }, + ) + execute_command( + collection, {"find": collection.name, "filter": {"a": {"$gte": 0}}, "hint": "a_1"} + ) + execute_command( + collection, {"find": collection.name, "filter": {"b": {"$gte": 0}}, "hint": "b_1"} + ) + execute_command( + collection, {"collMod": collection.name, "index": {"name": "a_1", "hidden": True}} + ) + result = execute_command( + collection, {"aggregate": collection.name, "pipeline": [{"$indexStats": {}}], "cursor": {}} + ) + a_ops = get_index_ops(result, "a_1") + assertSuccess( + a_ops, + Int64(0), + raw_res=True, + msg="Hiding a_1 should reset its counter to zero", + ) + + +def test_indexstats_reset_only_affects_toggled_index_b_unchanged(collection): + """Test hiding a_1 does not affect b_1's counter.""" + collection.insert_many([{"a": i, "b": i} for i in range(10)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"a": 1}, "name": "a_1"}, {"key": {"b": 1}, "name": "b_1"}], + }, + ) + execute_command( + collection, {"find": collection.name, "filter": {"a": {"$gte": 0}}, "hint": "a_1"} + ) + execute_command( + collection, {"find": collection.name, "filter": {"b": {"$gte": 0}}, "hint": "b_1"} + ) + execute_command( + collection, {"collMod": collection.name, "index": {"name": "a_1", "hidden": True}} + ) + result = execute_command( + collection, {"aggregate": collection.name, "pipeline": [{"$indexStats": {}}], "cursor": {}} + ) + b_ops = get_index_ops(result, "b_1") + assertSuccess( + b_ops >= 1, + True, + raw_res=True, + msg="Hiding a_1 should not affect b_1's counter", + ) + + +def test_all_inserts_indexed_while_hidden(collection): + """Test all documents inserted while hidden are present after unhide.""" + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"a": 1}, "name": "a_1", "hidden": True}], + }, + ) + execute_command( + collection, + {"insert": collection.name, "documents": [{"a": i} for i in range(50)]}, + ) + execute_command( + collection, {"collMod": collection.name, "index": {"name": "a_1", "hidden": False}} + ) + result = execute_command( + collection, {"find": collection.name, "filter": {"a": {"$gte": 0}}, "hint": "a_1"} + ) + assertSuccess( + result, + 50, + transform=lambda docs: len(docs), + msg="All documents written while hidden should be present in the index", + ) + + +@pytest.mark.slow +@pytest.mark.ttl +@pytest.mark.no_parallel +def test_ttl_expiry_while_hidden(collection, fast_ttl_monitor): + """Test documents expire via a hidden TTL index.""" + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [ + {"key": {"createdAt": 1}, "name": "ttl_1", "expireAfterSeconds": 0, "hidden": True} + ], + }, + ) + past = datetime.now(timezone.utc) - timedelta(days=1) + execute_command( + collection, + {"insert": collection.name, "documents": [{"createdAt": past} for _ in range(5)]}, + ) + deadline = time.time() + 90 + count = None + while time.time() < deadline: + res = execute_command(collection, {"count": collection.name}) + count = res.get("n") if isinstance(res, dict) else None + if count == 0: + break + time.sleep(2) + assertSuccess( + count, + 0, + raw_res=True, + msg="A hidden TTL index should still expire documents in the background", + ) + + +@pytest.mark.slow +@pytest.mark.ttl +@pytest.mark.no_parallel +def test_ttl_expiry_after_hiding_existing_ttl_index(collection, fast_ttl_monitor): + """Test a TTL index continues expiring documents after being hidden.""" + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"createdAt": 1}, "name": "ttl_1", "expireAfterSeconds": 0}], + }, + ) + execute_command( + collection, {"collMod": collection.name, "index": {"name": "ttl_1", "hidden": True}} + ) + past = datetime.now(timezone.utc) - timedelta(days=1) + execute_command( + collection, + {"insert": collection.name, "documents": [{"createdAt": past} for _ in range(5)]}, + ) + deadline = time.time() + 90 + count = None + while time.time() < deadline: + res = execute_command(collection, {"count": collection.name}) + count = res.get("n") if isinstance(res, dict) else None + if count == 0: + break + time.sleep(2) + assertSuccess( + count, + 0, + raw_res=True, + msg="Hiding a TTL index should not stop background expiry", + ) diff --git a/documentdb_tests/compatibility/tests/core/indexes/properties/hidden/test_hidden_bson_validation.py b/documentdb_tests/compatibility/tests/core/indexes/properties/hidden/test_hidden_bson_validation.py new file mode 100644 index 000000000..80498a668 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/indexes/properties/hidden/test_hidden_bson_validation.py @@ -0,0 +1,81 @@ +"""Tests for hidden index BSON type validation.""" + +import pytest + +from documentdb_tests.framework.assertions import assertFailureCode, assertNotError +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 ( + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command + +pytestmark = pytest.mark.index + + +HIDDEN_BSON_PARAMS = [ + BsonTypeTestCase( + id="createIndexes_hidden", + msg="createIndexes hidden should reject non-boolean types", + keyword="hidden", + valid_types=[BsonType.BOOL], + default_error_code=TYPE_MISMATCH_ERROR, + ), + BsonTypeTestCase( + id="collMod_hidden", + msg="collMod index.hidden should reject non-boolean/non-numeric types", + keyword="hidden", + valid_types=[BsonType.BOOL, BsonType.DOUBLE, BsonType.INT, BsonType.LONG, BsonType.DECIMAL], + default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: INVALID_OPTIONS_ERROR}, + ), +] + +REJECTION_CASES = generate_bson_rejection_test_cases(HIDDEN_BSON_PARAMS) +ACCEPTANCE_CASES = generate_bson_acceptance_test_cases(HIDDEN_BSON_PARAMS) + + +def _build_command(collection, spec, sample_value): + """Build and execute the appropriate command for the given spec and sample value.""" + if spec.id == "createIndexes_hidden": + return execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"b": 1}, "name": "idx", "hidden": sample_value}], + }, + ) + else: + return execute_command( + collection, + {"collMod": collection.name, "index": {"name": "a_1", "hidden": sample_value}}, + ) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", REJECTION_CASES) +def test_hidden_rejects_invalid_bson_type(collection, bson_type, sample_value, spec): + """Test hidden-related options reject invalid BSON types.""" + collection.insert_one({"x": 1}) + execute_command( + collection, + {"createIndexes": collection.name, "indexes": [{"key": {"a": 1}, "name": "a_1"}]}, + ) + result = _build_command(collection, spec, sample_value) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ACCEPTANCE_CASES) +def test_hidden_accepts_valid_bson_type(collection, bson_type, sample_value, spec): + """Test hidden-related options accept valid BSON types.""" + collection.insert_one({"x": 1}) + execute_command( + collection, + {"createIndexes": collection.name, "indexes": [{"key": {"a": 1}, "name": "a_1"}]}, + ) + result = _build_command(collection, spec, sample_value) + assertNotError(result, msg=f"{spec.id} should accept {bson_type.value}") diff --git a/documentdb_tests/compatibility/tests/core/indexes/properties/hidden/test_hidden_create_index.py b/documentdb_tests/compatibility/tests/core/indexes/properties/hidden/test_hidden_create_index.py new file mode 100644 index 000000000..ced358279 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/indexes/properties/hidden/test_hidden_create_index.py @@ -0,0 +1,352 @@ +"""Tests for hidden index creation, toggling, and listing.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.indexes.commands.utils.index_test_case import ( + IndexTestCase, +) +from documentdb_tests.compatibility.tests.core.indexes.properties.hidden.utils.helpers import ( + get_index_spec, + hidden_field, +) +from documentdb_tests.framework.assertions import ( + assertNotError, + assertSuccess, + assertSuccessPartial, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.index + + +CREATE_HIDDEN_FIELD_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="hidden_false_not_stored", + indexes=({"key": {"a": 1}, "name": "a_1", "hidden": False},), + expected="__ABSENT__", + msg="hidden:false should not be stored (field absent in getIndexes)", + ), + IndexTestCase( + id="no_hidden_option_not_stored", + indexes=({"key": {"a": 1}, "name": "a_1"},), + expected="__ABSENT__", + msg="Index without hidden option should default to non-hidden", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(CREATE_HIDDEN_FIELD_TESTS)) +def test_create_index_hidden_field_value(collection, test): + """Test hidden option stores the correct value in listIndexes.""" + execute_command(collection, {"createIndexes": collection.name, "indexes": [test.indexes[0]]}) + result = execute_command(collection, {"listIndexes": collection.name}) + assertSuccess( + result, + test.expected, + raw_res=True, + transform=lambda r: hidden_field(r, "a_1"), + msg=test.msg, + ) + + +HIDE_UNHIDE_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="hide_by_key_pattern", + indexes=({"key": {"a": 1}, "name": "a_1"},), + input={"keyPattern": {"a": 1}, "hidden": True}, + expected=True, + msg="Hiding by key pattern should set hidden:true", + ), + IndexTestCase( + id="hide_compound_by_full_key", + indexes=({"key": {"a": 1, "b": 1}, "name": "a_1_b_1"},), + input={"keyPattern": {"a": 1, "b": 1}, "hidden": True}, + expected=True, + msg="Full compound key pattern should hide the compound index", + ), + IndexTestCase( + id="unhide_by_key_pattern", + indexes=({"key": {"a": 1}, "name": "a_1", "hidden": True},), + input={"keyPattern": {"a": 1}, "hidden": False}, + expected="__ABSENT__", + msg="Unhiding by key pattern should remove the hidden field", + ), + IndexTestCase( + id="hide_then_list_shows_true", + indexes=({"key": {"a": 1}, "name": "a_1"},), + input={"name": "a_1", "hidden": True}, + expected=True, + msg="Hiding should make listIndexes report hidden:true", + ), + IndexTestCase( + id="unhide_removes_field_not_false", + indexes=({"key": {"a": 1}, "name": "a_1", "hidden": True},), + input={"name": "a_1", "hidden": False}, + expected="__ABSENT__", + msg="Unhiding should remove the hidden field, not set it to false", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(HIDE_UNHIDE_TESTS)) +def test_collmod_hide_unhide(collection, test): + """Test collMod hide/unhide sets the correct hidden field value.""" + execute_command(collection, {"createIndexes": collection.name, "indexes": [test.indexes[0]]}) + execute_command(collection, {"collMod": collection.name, "index": test.input}) + result = execute_command(collection, {"listIndexes": collection.name}) + idx_name = test.indexes[0].get("name", "a_1") + assertSuccess( + result, + test.expected, + raw_res=True, + transform=lambda r: hidden_field(r, idx_name), + msg=test.msg, + ) + + +IDEMPOTENT_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="hide_already_hidden", + indexes=({"key": {"a": 1}, "name": "a_1", "hidden": True},), + input={"name": "a_1", "hidden": True}, + msg="Hiding an already-hidden index should be idempotent", + ), + IndexTestCase( + id="unhide_already_unhidden", + indexes=({"key": {"a": 1}, "name": "a_1"},), + input={"name": "a_1", "hidden": False}, + msg="Unhiding an already-unhidden index should be idempotent", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(IDEMPOTENT_TESTS)) +def test_collmod_idempotent(collection, test): + """Test collMod hide/unhide is idempotent.""" + execute_command(collection, {"createIndexes": collection.name, "indexes": [test.indexes[0]]}) + result = execute_command(collection, {"collMod": collection.name, "index": test.input}) + assertNotError(result, msg=test.msg) + + +MULTIPLE_INDEX_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="multiple_hidden_coexist", + indexes=( + {"key": {"a": 1}, "name": "a_1", "hidden": True}, + {"key": {"b": 1}, "name": "b_1", "hidden": True}, + ), + expected=(True, True), + msg="Both indexes should be hidden simultaneously", + ), + IndexTestCase( + id="mixed_hidden_and_non_hidden", + indexes=( + {"key": {"a": 1}, "name": "a_1", "hidden": True}, + {"key": {"b": 1}, "name": "b_1"}, + ), + expected=(True, "__ABSENT__"), + msg="Hidden index shows hidden:true; non-hidden index omits the field", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(MULTIPLE_INDEX_TESTS)) +def test_multiple_hidden_indexes(collection, test): + """Test multiple hidden indexes listing behavior.""" + execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + result = execute_command(collection, {"listIndexes": collection.name}) + assertSuccess( + result, + test.expected, + raw_res=True, + transform=lambda r: (hidden_field(r, "a_1"), hidden_field(r, "b_1")), + msg=test.msg, + ) + + +def test_create_hidden_index_response_structure(collection): + """Test hidden:true returns numIndexesBefore/After and ok.""" + result = execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"a": 1}, "name": "a_1", "hidden": True}], + }, + ) + assertSuccessPartial( + result, + {"numIndexesBefore": 1, "numIndexesAfter": 2, "ok": 1.0}, + msg="createIndexes hidden:true should return expected response fields", + ) + + +def test_hide_null_index_arg_is_noop(collection): + """Test collMod with null index argument is a no-op.""" + execute_command( + collection, + {"createIndexes": collection.name, "indexes": [{"key": {"a": 1}, "name": "a_1"}]}, + ) + result = execute_command(collection, {"collMod": collection.name, "index": None}) + assertNotError(result, msg="collMod with a null index argument should be a no-op success") + + +def test_collmod_modifies_ttl_and_hidden_together(collection): + """Test collMod can change expireAfterSeconds and hidden together.""" + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"d": 1}, "name": "d_1", "expireAfterSeconds": 100}], + }, + ) + execute_command( + collection, + { + "collMod": collection.name, + "index": {"name": "d_1", "expireAfterSeconds": 200, "hidden": True}, + }, + ) + result = execute_command(collection, {"listIndexes": collection.name}) + assertSuccess( + result, + {"hidden": True, "expireAfterSeconds": 200}, + raw_res=True, + transform=lambda r: { + "hidden": get_index_spec(r, "d_1").get("hidden"), + "expireAfterSeconds": get_index_spec(r, "d_1").get("expireAfterSeconds"), + }, + msg="collMod should apply both expireAfterSeconds and hidden changes", + ) + + +def test_collmod_hidden_response_ok(collection): + """Test collMod hiding returns ok:1.""" + execute_command( + collection, + {"createIndexes": collection.name, "indexes": [{"key": {"a": 1}, "name": "a_1"}]}, + ) + result = execute_command( + collection, {"collMod": collection.name, "index": {"name": "a_1", "hidden": True}} + ) + assertSuccessPartial(result, {"ok": 1.0}, msg="collMod hiding an index should return ok:1") + + +def test_hidden_field_is_bson_boolean_true(collection): + """Test hidden field is BSON boolean true.""" + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"a": 1}, "name": "a_1", "hidden": True}], + }, + ) + result = execute_command(collection, {"listIndexes": collection.name}) + assertSuccess( + result, + (True, "bool"), + raw_res=True, + transform=lambda r: (hidden_field(r, "a_1"), type(hidden_field(r, "a_1")).__name__), + msg="hidden field must be BSON boolean true (not int or string)", + ) + + +def test_collstats_includes_hidden_index(collection): + """Test $collStats includes hidden index in indexSizes.""" + collection.insert_many([{"a": i} for i in range(10)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"a": 1}, "name": "a_1", "hidden": True}], + }, + ) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [{"$collStats": {"storageStats": {}}}], + "cursor": {}, + }, + ) + assertSuccess( + result, + True, + raw_res=True, + transform=lambda r: any( + "a_1" in doc.get("storageStats", {}).get("indexSizes", {}) + for doc in r["cursor"]["firstBatch"] + ), + msg="$collStats should account for the hidden index in indexSizes", + ) + + +def test_hiding_one_does_not_affect_others(collection): + """Test hiding one index doesn't affect others.""" + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [ + {"key": {"a": 1}, "name": "a_1"}, + {"key": {"b": 1}, "name": "b_1"}, + ], + }, + ) + execute_command( + collection, {"collMod": collection.name, "index": {"name": "a_1", "hidden": True}} + ) + result = execute_command(collection, {"listIndexes": collection.name}) + assertSuccess( + result, + (True, "__ABSENT__"), + raw_res=True, + transform=lambda r: (hidden_field(r, "a_1"), hidden_field(r, "b_1")), + msg="Hiding a_1 should not change b_1's hidden state", + ) + + +@pytest.mark.no_parallel +def test_capped_hide_shows_hidden_true(collection, database_client): + """Test hiding an index on a capped collection reports hidden:true.""" + capped_name = f"{collection.name}_capped" + coll = database_client[capped_name] + execute_command(coll, {"create": capped_name, "capped": True, "size": 1_000_000}) + execute_command(coll, {"insert": capped_name, "documents": [{"a": i} for i in range(20)]}) + execute_command( + coll, {"createIndexes": capped_name, "indexes": [{"key": {"a": 1}, "name": "a_1"}]} + ) + execute_command(coll, {"collMod": capped_name, "index": {"name": "a_1", "hidden": True}}) + result = execute_command(coll, {"listIndexes": capped_name}) + assertSuccess( + result, + True, + raw_res=True, + transform=lambda r: hidden_field(r, "a_1"), + msg="Hiding an index on a capped collection should report hidden:true", + ) + + +@pytest.mark.no_parallel +def test_capped_unhide_removes_hidden_field(collection, database_client): + """Test unhiding an index on a capped collection removes the hidden field.""" + capped_name = f"{collection.name}_capped" + coll = database_client[capped_name] + execute_command(coll, {"create": capped_name, "capped": True, "size": 1_000_000}) + execute_command(coll, {"insert": capped_name, "documents": [{"a": i} for i in range(20)]}) + execute_command( + coll, {"createIndexes": capped_name, "indexes": [{"key": {"a": 1}, "name": "a_1"}]} + ) + execute_command(coll, {"collMod": capped_name, "index": {"name": "a_1", "hidden": True}}) + execute_command(coll, {"collMod": capped_name, "index": {"name": "a_1", "hidden": False}}) + result = execute_command(coll, {"listIndexes": capped_name}) + assertSuccess( + result, + "__ABSENT__", + raw_res=True, + transform=lambda r: hidden_field(r, "a_1"), + msg="Unhiding an index on a capped collection should remove the hidden field", + ) diff --git a/documentdb_tests/compatibility/tests/core/indexes/properties/hidden/test_hidden_errors.py b/documentdb_tests/compatibility/tests/core/indexes/properties/hidden/test_hidden_errors.py new file mode 100644 index 000000000..d042680c8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/indexes/properties/hidden/test_hidden_errors.py @@ -0,0 +1,349 @@ +"""Tests for hidden index error cases.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.indexes.commands.utils.index_test_case import ( + IndexTestCase, +) +from documentdb_tests.framework.assertions import assertFailure, assertFailureCode, assertSuccess +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + CANNOT_CREATE_INDEX_ERROR, + COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + DUPLICATE_KEY_ERROR, + INDEX_NOT_FOUND_ERROR, + INDEX_OPTIONS_CONFLICT_ERROR, + INVALID_NAMESPACE_ERROR, + INVALID_OPTIONS_ERROR, + NO_QUERY_EXECUTION_PLANS_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.index + + +HIDE_UNHIDE_ERROR_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="hide_nonmatching_key_pattern", + indexes=({"key": {"a": 1}, "name": "a_1"},), + input={"keyPattern": {"nope": 1}, "hidden": True}, + error_code=INDEX_NOT_FOUND_ERROR, + msg="Hiding a non-matching key pattern should fail with IndexNotFound", + ), + IndexTestCase( + id="hide_nonexistent_name", + indexes=({"key": {"a": 1}, "name": "a_1"},), + input={"name": "does_not_exist", "hidden": True}, + error_code=INDEX_NOT_FOUND_ERROR, + msg="Hiding a non-existent index name should fail with IndexNotFound", + ), + IndexTestCase( + id="hide_empty_name", + indexes=({"key": {"a": 1}, "name": "a_1"},), + input={"name": "", "hidden": True}, + error_code=INDEX_NOT_FOUND_ERROR, + msg="Hiding with an empty index name should fail with IndexNotFound", + ), + IndexTestCase( + id="hide_partial_compound_key", + indexes=({"key": {"a": 1, "b": 1}, "name": "a_1_b_1"},), + input={"keyPattern": {"a": 1}, "hidden": True}, + error_code=INDEX_NOT_FOUND_ERROR, + msg="A partial key pattern should not match a compound index", + ), + IndexTestCase( + id="unhide_nonmatching_key_pattern", + indexes=({"key": {"a": 1}, "name": "a_1", "hidden": True},), + input={"keyPattern": {"nope": 1}, "hidden": False}, + error_code=INDEX_NOT_FOUND_ERROR, + msg="Unhiding a non-matching key pattern should fail with IndexNotFound", + ), + IndexTestCase( + id="unhide_nonexistent_name", + indexes=({"key": {"a": 1}, "name": "a_1", "hidden": True},), + input={"name": "does_not_exist", "hidden": False}, + error_code=INDEX_NOT_FOUND_ERROR, + msg="Unhiding a non-existent index name should fail with IndexNotFound", + ), + IndexTestCase( + id="hide_id_by_key_pattern", + indexes=(), + input={"keyPattern": {"_id": 1}, "hidden": True}, + error_code=BAD_VALUE_ERROR, + msg="Hiding the _id index should fail with BadValue", + ), + IndexTestCase( + id="hide_id_by_name", + indexes=(), + input={"name": "_id_", "hidden": True}, + error_code=BAD_VALUE_ERROR, + msg="Hiding the _id index by name should fail with BadValue", + ), + IndexTestCase( + id="integer_index_arg", + indexes=({"key": {"a": 1}, "name": "a_1"},), + input=5, + error_code=TYPE_MISMATCH_ERROR, + msg="An integer index argument should be rejected as a type error", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(HIDE_UNHIDE_ERROR_TESTS)) +def test_collmod_hide_unhide_errors(collection, test): + """Test collMod hide/unhide rejects invalid targets.""" + collection.insert_one({"x": 1}) # ensure collection exists + if test.indexes: + execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + result = execute_command(collection, {"collMod": collection.name, "index": test.input}) + assertFailureCode(result, test.error_code, msg=test.msg) + + +HINT_HIDDEN_ERROR_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="find_hint_by_name", + input={"command": "find", "hint": "a_1"}, + error_code=BAD_VALUE_ERROR, + msg="find hint on a hidden index name should fail with BadValue", + ), + IndexTestCase( + id="find_hint_by_key_pattern", + input={"command": "find", "hint": {"a": 1}}, + error_code=BAD_VALUE_ERROR, + msg="find hint on a hidden index key pattern should fail with BadValue", + ), + IndexTestCase( + id="aggregate_hint", + input={"command": "aggregate", "hint": "a_1"}, + error_code=BAD_VALUE_ERROR, + msg="aggregate hint on a hidden index should fail with BadValue", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(HINT_HIDDEN_ERROR_TESTS)) +def test_hint_hidden_index_errors(collection, test): + """Test hinting a hidden index is rejected.""" + collection.insert_many([{"a": i} for i in range(5)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"a": 1}, "name": "a_1", "hidden": True}], + }, + ) + if test.input["command"] == "find": + result = execute_command( + collection, {"find": collection.name, "filter": {"a": 1}, "hint": test.input["hint"]} + ) + else: + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [{"$match": {"a": 1}}], + "hint": test.input["hint"], + "cursor": {}, + }, + ) + assertFailureCode(result, test.error_code, msg=test.msg) + + +CONFLICT_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="hidden_true_on_existing_non_hidden", + indexes=({"key": {"a": 1}, "name": "a_1"},), + input={"key": {"a": 1}, "name": "a_1", "hidden": True}, + error_code=INDEX_OPTIONS_CONFLICT_ERROR, + msg="Re-creating an existing index with a different hidden value should conflict", + ), + IndexTestCase( + id="hidden_false_on_existing_hidden", + indexes=({"key": {"a": 1}, "name": "a_1", "hidden": True},), + input={"key": {"a": 1}, "name": "a_1", "hidden": False}, + error_code=INDEX_OPTIONS_CONFLICT_ERROR, + msg="Re-creating a hidden index with hidden:false should conflict", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(CONFLICT_TESTS)) +def test_create_index_conflict_errors(collection, test): + """Test conflicting hidden option on existing index.""" + execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + result = execute_command( + collection, + {"createIndexes": collection.name, "indexes": [test.input]}, + ) + assertFailureCode(result, test.error_code, msg=test.msg) + + +VIEW_ERROR_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="create_hidden_index_on_view", + input={"command": "createIndexes"}, + error_code=COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="Creating a hidden index on a view should be rejected", + ), + IndexTestCase( + id="hide_index_on_view", + input={"command": "collMod", "hidden": True}, + error_code=INVALID_OPTIONS_ERROR, + msg="Hiding an index on a view should be rejected", + ), + IndexTestCase( + id="unhide_index_on_view", + input={"command": "collMod", "hidden": False}, + error_code=INVALID_OPTIONS_ERROR, + msg="Unhiding an index on a view should be rejected", + ), +] + + +@pytest.mark.no_parallel +@pytest.mark.parametrize("test", pytest_params(VIEW_ERROR_TESTS)) +def test_hidden_index_on_view_errors(collection, database_client, test): + """Test hidden index operations on a view are rejected.""" + source_name = f"{collection.name}_source" + view_name = f"{collection.name}_view" + source = database_client[source_name] + execute_command(source, {"insert": source_name, "documents": [{"a": 1}, {"a": 2}]}) + view = database_client[view_name] + execute_command(view, {"create": view_name, "viewOn": source_name, "pipeline": []}) + + if test.input["command"] == "createIndexes": + result = execute_command( + view, + { + "createIndexes": view_name, + "indexes": [{"key": {"a": 1}, "name": "a_1", "hidden": True}], + }, + ) + else: + result = execute_command( + view, {"collMod": view_name, "index": {"name": "a_1", "hidden": test.input["hidden"]}} + ) + assertFailureCode(result, test.error_code, msg=test.msg) + + +UNIQUE_CONSTRAINT_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="duplicate_insert_while_hidden", + input={"operation": "insert"}, + indexes=({"key": {"a": 1}, "name": "a_1", "unique": True, "hidden": True},), + error_code=DUPLICATE_KEY_ERROR, + msg="A hidden unique index should still reject duplicate inserts", + ), + IndexTestCase( + id="duplicate_update_while_hidden", + input={"operation": "update"}, + indexes=({"key": {"a": 1}, "name": "a_1", "unique": True, "hidden": True},), + error_code=DUPLICATE_KEY_ERROR, + msg="A hidden unique index should still reject duplicate updates", + ), + IndexTestCase( + id="duplicate_insert_after_hiding", + input={"operation": "hide_then_insert"}, + indexes=({"key": {"a": 1}, "name": "a_1", "unique": True},), + error_code=DUPLICATE_KEY_ERROR, + msg="Unique constraint must remain active regardless of hidden state", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(UNIQUE_CONSTRAINT_TESTS)) +def test_unique_constraint_enforced_while_hidden(collection, test): + """Test hidden unique indexes still enforce uniqueness.""" + execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + execute_command(collection, {"insert": collection.name, "documents": [{"a": 1}]}) + + if test.input["operation"] == "insert": + result = execute_command(collection, {"insert": collection.name, "documents": [{"a": 1}]}) + elif test.input["operation"] == "update": + execute_command(collection, {"insert": collection.name, "documents": [{"a": 2}]}) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"a": 2}, "u": {"$set": {"a": 1}}}]}, + ) + else: # hide_then_insert + execute_command( + collection, {"collMod": collection.name, "index": {"name": "a_1", "hidden": True}} + ) + result = execute_command(collection, {"insert": collection.name, "documents": [{"a": 1}]}) + + assertFailureCode(result, test.error_code, msg=test.msg) + + +def test_hidden_text_index_text_query_errors(collection): + """Test $text fails when only text index is hidden.""" + collection.insert_many([{"txt": "hello world"}, {"txt": "goodbye"}]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"txt": "text"}, "name": "txt_text", "hidden": True}], + }, + ) + result = execute_command( + collection, {"find": collection.name, "filter": {"$text": {"$search": "hello"}}} + ) + assertFailureCode( + result, + NO_QUERY_EXECUTION_PLANS_ERROR, + msg="$text should fail when the only text index is hidden", + ) + + +def test_find_hint_after_unhide_succeeds(collection): + """Test hint on unhidden index succeeds.""" + collection.insert_many([{"a": i} for i in range(5)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"a": 1}, "name": "a_1", "hidden": True}], + }, + ) + execute_command( + collection, {"collMod": collection.name, "index": {"name": "a_1", "hidden": False}} + ) + result = execute_command( + collection, {"find": collection.name, "filter": {"a": 1}, "hint": "a_1"} + ) + assertSuccess( + result, + [{"a": 1}], + transform=lambda docs: [{"a": d["a"]} for d in docs], + msg="Hint on an unhidden index should succeed and return matching documents", + ) + + +@pytest.mark.no_parallel +def test_create_hidden_index_on_system_collection_errors(database_client): + """Test hidden:true on a system collection is rejected.""" + execute_command(database_client["system.views"], {"create": "system.views"}) + result = execute_command( + database_client["system.views"], + { + "createIndexes": "system.views", + "indexes": [{"key": {"a": 1}, "name": "a_1", "hidden": True}], + }, + ) + assertFailure( + result, + True, + transform=lambda actual: actual["code"] + in {BAD_VALUE_ERROR, CANNOT_CREATE_INDEX_ERROR, INVALID_NAMESPACE_ERROR}, + msg="Creating a hidden index on a system collection should be rejected", + ) diff --git a/documentdb_tests/compatibility/tests/core/indexes/properties/hidden/test_hidden_query_planning.py b/documentdb_tests/compatibility/tests/core/indexes/properties/hidden/test_hidden_query_planning.py new file mode 100644 index 000000000..6f4151cc9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/indexes/properties/hidden/test_hidden_query_planning.py @@ -0,0 +1,939 @@ +"""Tests for hidden index query-planner behavior.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.indexes.properties.hidden.utils.helpers import ( + all_plan_index_names, + all_plans_execution_index_names, + is_covered, + ixscan_index_names, + uses_collscan, + uses_index, +) +from documentdb_tests.framework.assertions import assertNotError, assertSuccess +from documentdb_tests.framework.executor import execute_command + +pytestmark = pytest.mark.index + + +def test_query_uses_index_when_not_hidden(collection): + """Test non-hidden index is used by the planner.""" + collection.insert_many([{"a": i} for i in range(20)]) + execute_command( + collection, + {"createIndexes": collection.name, "indexes": [{"key": {"a": 1}, "name": "a_1"}]}, + ) + result = execute_command( + collection, + {"explain": {"find": collection.name, "filter": {"a": 5}}, "verbosity": "queryPlanner"}, + ) + assertSuccess( + result, + True, + raw_res=True, + transform=lambda r: uses_index(r, "a_1"), + msg="Non-hidden index should be used by the query planner", + ) + + +def test_query_uses_collscan_when_index_hidden(collection): + """Test a hidden index's field falls back to a collection scan.""" + collection.insert_many([{"a": i} for i in range(20)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"a": 1}, "name": "a_1", "hidden": True}], + }, + ) + result = execute_command( + collection, + {"explain": {"find": collection.name, "filter": {"a": 5}}, "verbosity": "queryPlanner"}, + ) + assertSuccess( + result, + (False, True), + raw_res=True, + transform=lambda r: (uses_index(r, "a_1"), uses_collscan(r)), + msg="Hidden index should not be used; a collection scan should be used instead", + ) + + +def test_hidden_index_absent_from_execution_stats_plans(collection): + """Test hidden index absent from executionStats plans.""" + collection.insert_many([{"a": i} for i in range(20)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"a": 1}, "name": "a_1", "hidden": True}], + }, + ) + result = execute_command( + collection, + {"explain": {"find": collection.name, "filter": {"a": 5}}, "verbosity": "executionStats"}, + ) + assertSuccess( + result, + False, + raw_res=True, + transform=lambda r: "a_1" in all_plan_index_names(r), + msg="Hidden index must not appear as winning or rejected plan in executionStats", + ) + + +def test_hidden_index_absent_from_all_plans_execution(collection): + """Test hidden index absent from allPlansExecution candidate plans.""" + collection.insert_many([{"a": i, "b": i, "c": i} for i in range(1000)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [ + {"key": {"a": 1, "b": 1}, "name": "ab"}, + {"key": {"a": 1, "c": 1}, "name": "ac"}, + {"key": {"a": 1, "d": 1}, "name": "ad_hidden", "hidden": True}, + ], + }, + ) + result = execute_command( + collection, + { + "explain": {"find": collection.name, "filter": {"a": {"$gte": 500}}}, + "verbosity": "allPlansExecution", + }, + ) + assertSuccess( + result, + (True, False), + raw_res=True, + transform=lambda r: ( + len(r["executionStats"]["allPlansExecution"]) > 0, + "ad_hidden" in all_plans_execution_index_names(r), + ), + msg="Hidden index must not appear as a candidate in allPlansExecution", + ) + + +def test_planner_uses_index_after_unhide(collection): + """Test planner uses index immediately after unhide.""" + collection.insert_many([{"a": i} for i in range(20)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"a": 1}, "name": "a_1", "hidden": True}], + }, + ) + execute_command( + collection, {"collMod": collection.name, "index": {"name": "a_1", "hidden": False}} + ) + result = execute_command( + collection, + {"explain": {"find": collection.name, "filter": {"a": 5}}, "verbosity": "queryPlanner"}, + ) + assertSuccess( + result, + True, + raw_res=True, + transform=lambda r: uses_index(r, "a_1"), + msg="Index should be immediately usable by the planner after unhide", + ) + + +def test_planner_uses_non_hidden_index_in_mix(collection): + """Test planner uses non-hidden index in a mix.""" + collection.insert_many([{"a": i, "b": i} for i in range(20)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [ + {"key": {"a": 1}, "name": "a_1", "hidden": True}, + {"key": {"b": 1}, "name": "b_1"}, + ], + }, + ) + result = execute_command( + collection, + {"explain": {"find": collection.name, "filter": {"b": 5}}, "verbosity": "queryPlanner"}, + ) + assertSuccess( + result, + True, + raw_res=True, + transform=lambda r: uses_index(r, "b_1"), + msg="Non-hidden index should be used when a mix of indexes exists", + ) + + +def test_planner_ignores_hidden_index_in_mix(collection): + """Test hidden index ignored even when other indexes exist.""" + collection.insert_many([{"a": i, "b": i} for i in range(20)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [ + {"key": {"a": 1}, "name": "a_1", "hidden": True}, + {"key": {"b": 1}, "name": "b_1"}, + ], + }, + ) + result = execute_command( + collection, + {"explain": {"find": collection.name, "filter": {"a": 5}}, "verbosity": "queryPlanner"}, + ) + assertSuccess( + result, + False, + raw_res=True, + transform=lambda r: uses_index(r, "a_1"), + msg="Hidden index must not be used even when other indexes are present", + ) + + +def test_planner_uses_visible_index_among_multiple_hidden(collection): + """Test planner uses visible index when others are hidden.""" + collection.insert_many([{"a": i, "b": i, "c": i} for i in range(20)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [ + {"key": {"a": 1}, "name": "a_1", "hidden": True}, + {"key": {"b": 1}, "name": "b_1", "hidden": True}, + {"key": {"c": 1}, "name": "c_1"}, + ], + }, + ) + result = execute_command( + collection, + {"explain": {"find": collection.name, "filter": {"c": 5}}, "verbosity": "queryPlanner"}, + ) + assertSuccess( + result, + True, + raw_res=True, + transform=lambda r: uses_index(r, "c_1"), + msg="Planner should use the only visible index when others are hidden", + ) + + +def test_meta_indexkey_absent_when_index_hidden(collection): + """Test $meta:indexKey absent when index is hidden.""" + collection.insert_many([{"_id": i, "a": i} for i in range(10)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"a": 1}, "name": "a_1", "hidden": True}], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"a": 5}, "projection": {"ik": {"$meta": "indexKey"}}}, + ) + assertSuccess( + result, + False, + transform=lambda docs: any("ik" in d for d in docs), + msg="$meta indexKey should not return an index key for a hidden index", + ) + + +def test_meta_indexkey_present_after_unhide(collection): + """Test $meta:indexKey present after unhide.""" + collection.insert_many([{"_id": i, "a": i} for i in range(10)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"a": 1}, "name": "a_1", "hidden": True}], + }, + ) + execute_command( + collection, {"collMod": collection.name, "index": {"name": "a_1", "hidden": False}} + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"a": 5}, "projection": {"ik": {"$meta": "indexKey"}}}, + ) + assertSuccess( + result, + True, + transform=lambda docs: all("ik" in d for d in docs) and len(docs) > 0, + msg="$meta indexKey should return the index key once the index is unhidden", + ) + + +def test_meta_indexkey_present_with_non_hidden_index(collection): + """Test $meta:indexKey present for non-hidden index.""" + collection.insert_many([{"_id": i, "a": i} for i in range(10)]) + execute_command( + collection, + {"createIndexes": collection.name, "indexes": [{"key": {"a": 1}, "name": "a_1"}]}, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"a": 5}, "projection": {"ik": {"$meta": "indexKey"}}}, + ) + assertSuccess( + result, + True, + transform=lambda docs: all("ik" in d for d in docs) and len(docs) > 0, + msg="$meta indexKey should return the index key for a non-hidden index", + ) + + +def test_hidden_sparse_index_not_used(collection): + """Test hidden sparse index not used.""" + collection.insert_many([{"a": i} for i in range(20)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"a": 1}, "name": "a_sparse", "sparse": True, "hidden": True}], + }, + ) + result = execute_command( + collection, + {"explain": {"find": collection.name, "filter": {"a": 5}}, "verbosity": "queryPlanner"}, + ) + assertSuccess( + result, + False, + raw_res=True, + transform=lambda r: uses_index(r, "a_sparse"), + msg="Hidden sparse index should not be used", + ) + + +def test_hidden_partial_index_not_used(collection): + """Test hidden partial index not used.""" + collection.insert_many([{"a": i} for i in range(20)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [ + { + "key": {"a": 1}, + "name": "a_partial", + "partialFilterExpression": {"a": {"$gt": 0}}, + "hidden": True, + } + ], + }, + ) + result = execute_command( + collection, + {"explain": {"find": collection.name, "filter": {"a": 5}}, "verbosity": "queryPlanner"}, + ) + assertSuccess( + result, + False, + raw_res=True, + transform=lambda r: uses_index(r, "a_partial"), + msg="Hidden partial index should not be used", + ) + + +def test_hidden_compound_index_not_used(collection): + """Test hidden compound index not used.""" + collection.insert_many([{"a": i, "b": i} for i in range(20)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"a": 1, "b": 1}, "name": "a_1_b_1", "hidden": True}], + }, + ) + result = execute_command( + collection, + { + "explain": {"find": collection.name, "filter": {"a": 5, "b": 5}}, + "verbosity": "queryPlanner", + }, + ) + assertSuccess( + result, + False, + raw_res=True, + transform=lambda r: uses_index(r, "a_1_b_1"), + msg="Hidden compound index should not be used", + ) + + +def test_hidden_wildcard_index_not_used(collection): + """Test hidden wildcard index not used.""" + collection.insert_many([{"a": i} for i in range(20)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"$**": 1}, "name": "wild", "hidden": True}], + }, + ) + result = execute_command( + collection, + {"explain": {"find": collection.name, "filter": {"a": 5}}, "verbosity": "queryPlanner"}, + ) + assertSuccess( + result, + False, + raw_res=True, + transform=lambda r: uses_index(r, "wild"), + msg="Hidden wildcard index should not be used", + ) + + +def test_hidden_wildcard_dotted_field_not_used(collection): + """Test hidden dotted-path wildcard index not used.""" + collection.insert_many([{"a": {"b": i}} for i in range(20)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"a.$**": 1}, "name": "a_wild", "hidden": True}], + }, + ) + result = execute_command( + collection, + {"explain": {"find": collection.name, "filter": {"a.b": 5}}, "verbosity": "queryPlanner"}, + ) + assertSuccess( + result, + False, + raw_res=True, + transform=lambda r: uses_index(r, "a_wild"), + msg="Hidden dotted wildcard index should not be used", + ) + + +def test_hidden_hashed_index_not_used(collection): + """Test hidden hashed index not used.""" + collection.insert_many([{"a": i} for i in range(20)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"a": "hashed"}, "name": "a_hashed", "hidden": True}], + }, + ) + result = execute_command( + collection, + {"explain": {"find": collection.name, "filter": {"a": 5}}, "verbosity": "queryPlanner"}, + ) + assertSuccess( + result, + False, + raw_res=True, + transform=lambda r: uses_index(r, "a_hashed"), + msg="Hidden hashed index should not be used", + ) + + +def test_hidden_2dsphere_index_not_used(collection): + """Test hidden 2dsphere index not used for geo queries.""" + collection.insert_many( + [{"loc": {"type": "Point", "coordinates": [i * 0.001, 0]}} for i in range(20)] + ) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"loc": "2dsphere"}, "name": "loc_2dsphere", "hidden": True}], + }, + ) + result = execute_command( + collection, + { + "explain": { + "find": collection.name, + "filter": {"loc": {"$geoWithin": {"$centerSphere": [[0, 0], 1]}}}, + }, + "verbosity": "queryPlanner", + }, + ) + assertSuccess( + result, + False, + raw_res=True, + transform=lambda r: uses_index(r, "loc_2dsphere"), + msg="Hidden 2dsphere index should not be used for geo queries", + ) + + +def test_unhide_restores_2dsphere_use(collection): + """Test unhiding 2dsphere index restores geo query use.""" + collection.insert_many( + [{"loc": {"type": "Point", "coordinates": [i * 0.001, 0]}} for i in range(20)] + ) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"loc": "2dsphere"}, "name": "loc_2dsphere", "hidden": True}], + }, + ) + execute_command( + collection, {"collMod": collection.name, "index": {"name": "loc_2dsphere", "hidden": False}} + ) + result = execute_command( + collection, + { + "explain": { + "find": collection.name, + "filter": {"loc": {"$geoWithin": {"$centerSphere": [[0, 0], 1]}}}, + }, + "verbosity": "queryPlanner", + }, + ) + assertSuccess( + result, + True, + raw_res=True, + transform=lambda r: uses_index(r, "loc_2dsphere"), + msg="Unhidden 2dsphere index should be usable for geo queries", + ) + + +def test_unhide_restores_wildcard_use(collection): + """Test unhiding a wildcard index restores planner use.""" + collection.insert_many([{"a": i} for i in range(20)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"$**": 1}, "name": "wild", "hidden": True}], + }, + ) + execute_command( + collection, {"collMod": collection.name, "index": {"name": "wild", "hidden": False}} + ) + result = execute_command( + collection, + {"explain": {"find": collection.name, "filter": {"a": 5}}, "verbosity": "queryPlanner"}, + ) + assertSuccess( + result, + True, + raw_res=True, + transform=lambda r: uses_index(r, "wild"), + msg="Unhidden wildcard index should be usable", + ) + + +_FILTER_QUERY = {"a": {"$gte": 0}, "b": {"$gte": 0}} + + +def test_index_filter_uses_a_listed_index(collection): + """Test planner uses a filter index when none are hidden.""" + collection.insert_many([{"a": i, "b": i} for i in range(50)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [ + {"key": {"a": 1}, "name": "a_1"}, + {"key": {"b": 1}, "name": "b_1"}, + ], + }, + ) + execute_command( + collection, + { + "planCacheSetFilter": collection.name, + "query": _FILTER_QUERY, + "indexes": [{"a": 1}, {"b": 1}], + }, + ) + result = execute_command( + collection, + { + "explain": {"find": collection.name, "filter": _FILTER_QUERY}, + "verbosity": "queryPlanner", + }, + ) + assertSuccess( + result, + True, + raw_res=True, + transform=lambda r: bool(set(ixscan_index_names(r)) & {"a_1", "b_1"}), + msg="Planner should use one of the filter's indexes when none are hidden", + ) + + +def test_hiding_one_filter_index_uses_the_other(collection): + """Test hiding one filter index makes planner use the other.""" + collection.insert_many([{"a": i, "b": i} for i in range(50)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [ + {"key": {"a": 1}, "name": "a_1"}, + {"key": {"b": 1}, "name": "b_1"}, + ], + }, + ) + execute_command( + collection, + { + "planCacheSetFilter": collection.name, + "query": _FILTER_QUERY, + "indexes": [{"a": 1}, {"b": 1}], + }, + ) + execute_command( + collection, {"collMod": collection.name, "index": {"name": "a_1", "hidden": True}} + ) + result = execute_command( + collection, + { + "explain": {"find": collection.name, "filter": _FILTER_QUERY}, + "verbosity": "queryPlanner", + }, + ) + assertSuccess( + result, + (True, False), + raw_res=True, + transform=lambda r: (uses_index(r, "b_1"), uses_index(r, "a_1")), + msg="Hiding one filter index should make the planner use the other", + ) + + +def test_hiding_all_filter_indexes_uses_collscan(collection): + """Test hiding all filter indexes falls back to collscan.""" + collection.insert_many([{"a": i, "b": i} for i in range(50)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [ + {"key": {"a": 1}, "name": "a_1"}, + {"key": {"b": 1}, "name": "b_1"}, + ], + }, + ) + execute_command( + collection, + { + "planCacheSetFilter": collection.name, + "query": _FILTER_QUERY, + "indexes": [{"a": 1}, {"b": 1}], + }, + ) + execute_command( + collection, {"collMod": collection.name, "index": {"name": "a_1", "hidden": True}} + ) + execute_command( + collection, {"collMod": collection.name, "index": {"name": "b_1", "hidden": True}} + ) + result = execute_command( + collection, + { + "explain": {"find": collection.name, "filter": _FILTER_QUERY}, + "verbosity": "queryPlanner", + }, + ) + assertSuccess( + result, + True, + raw_res=True, + transform=uses_collscan, + msg="Hiding all filter indexes should fall back to a collection scan", + ) + + +def test_hidden_index_remains_in_filter_list(collection): + """Test hiding an index doesn't remove it from the filter list.""" + collection.insert_many([{"a": i, "b": i} for i in range(50)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [ + {"key": {"a": 1}, "name": "a_1"}, + {"key": {"b": 1}, "name": "b_1"}, + ], + }, + ) + execute_command( + collection, + { + "planCacheSetFilter": collection.name, + "query": _FILTER_QUERY, + "indexes": [{"a": 1}, {"b": 1}], + }, + ) + execute_command( + collection, {"collMod": collection.name, "index": {"name": "a_1", "hidden": True}} + ) + result = execute_command(collection, {"planCacheListFilters": collection.name}) + assertSuccess( + result, + [{"a": 1}, {"b": 1}], + raw_res=True, + transform=lambda r: sorted( + (idx for f in r["filters"] for idx in f["indexes"]), + key=lambda d: list(d.keys()), + ), + msg="Hiding an index should not change the plan cache filter list", + ) + + +def test_set_filter_referencing_hidden_index_succeeds(collection): + """Test planCacheSetFilter can reference a hidden index.""" + collection.insert_many([{"a": i, "b": i} for i in range(50)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [ + {"key": {"a": 1}, "name": "a_1", "hidden": True}, + {"key": {"b": 1}, "name": "b_1"}, + ], + }, + ) + result = execute_command( + collection, + {"planCacheSetFilter": collection.name, "query": _FILTER_QUERY, "indexes": [{"a": 1}]}, + ) + assertNotError(result, msg="Setting an index filter on a hidden index should succeed") + + +def test_only_hidden_index_in_filter_uses_collscan(collection): + """Test filter with only hidden index falls back to collscan.""" + collection.insert_many([{"a": i, "b": i} for i in range(50)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [ + {"key": {"a": 1}, "name": "a_1"}, + {"key": {"b": 1}, "name": "b_1"}, + ], + }, + ) + execute_command( + collection, + {"planCacheSetFilter": collection.name, "query": _FILTER_QUERY, "indexes": [{"a": 1}]}, + ) + execute_command( + collection, {"collMod": collection.name, "index": {"name": "a_1", "hidden": True}} + ) + result = execute_command( + collection, + { + "explain": {"find": collection.name, "filter": _FILTER_QUERY}, + "verbosity": "queryPlanner", + }, + ) + assertSuccess( + result, + True, + raw_res=True, + transform=uses_collscan, + msg="A filter whose only index is hidden should use a collection scan", + ) + + +def test_unhide_filter_index_resumes_use(collection): + """Test unhiding the sole filter index restores planner use.""" + collection.insert_many([{"a": i, "b": i} for i in range(50)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [ + {"key": {"a": 1}, "name": "a_1"}, + {"key": {"b": 1}, "name": "b_1"}, + ], + }, + ) + execute_command( + collection, + {"planCacheSetFilter": collection.name, "query": _FILTER_QUERY, "indexes": [{"a": 1}]}, + ) + execute_command( + collection, {"collMod": collection.name, "index": {"name": "a_1", "hidden": True}} + ) + execute_command( + collection, {"collMod": collection.name, "index": {"name": "a_1", "hidden": False}} + ) + result = execute_command( + collection, + { + "explain": {"find": collection.name, "filter": _FILTER_QUERY}, + "verbosity": "queryPlanner", + }, + ) + assertSuccess( + result, + True, + raw_res=True, + transform=lambda r: uses_index(r, "a_1"), + msg="Unhiding the filter's index should make the planner use it again", + ) + + +def test_unhide_all_restores_original_plan(collection): + """Test unhiding all filter indexes restores planner use.""" + collection.insert_many([{"a": i, "b": i} for i in range(50)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [ + {"key": {"a": 1}, "name": "a_1"}, + {"key": {"b": 1}, "name": "b_1"}, + ], + }, + ) + execute_command( + collection, + { + "planCacheSetFilter": collection.name, + "query": _FILTER_QUERY, + "indexes": [{"a": 1}, {"b": 1}], + }, + ) + execute_command( + collection, {"collMod": collection.name, "index": {"name": "a_1", "hidden": True}} + ) + execute_command( + collection, {"collMod": collection.name, "index": {"name": "b_1", "hidden": True}} + ) + execute_command( + collection, {"collMod": collection.name, "index": {"name": "a_1", "hidden": False}} + ) + execute_command( + collection, {"collMod": collection.name, "index": {"name": "b_1", "hidden": False}} + ) + result = execute_command( + collection, + { + "explain": {"find": collection.name, "filter": _FILTER_QUERY}, + "verbosity": "queryPlanner", + }, + ) + assertSuccess( + result, + True, + raw_res=True, + transform=lambda r: len(ixscan_index_names(r)) > 0 + and set(ixscan_index_names(r)) <= {"a_1", "b_1"}, + msg="Unhiding all filter indexes should restore planner use of a filtered index", + ) + + +def test_hidden_index_not_used_for_sort(collection): + """Test hidden index not used to satisfy a sort.""" + collection.insert_many([{"a": i} for i in range(20)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"a": 1}, "name": "a_1", "hidden": True}], + }, + ) + result = execute_command( + collection, + { + "explain": {"find": collection.name, "filter": {}, "sort": {"a": 1}}, + "verbosity": "queryPlanner", + }, + ) + assertSuccess( + result, + False, + raw_res=True, + transform=lambda r: uses_index(r, "a_1"), + msg="Hidden index should not be used to satisfy a sort", + ) + + +def test_covered_query_covered_when_index_visible(collection): + """Test visible index produces a covered query plan.""" + collection.insert_many([{"a": i} for i in range(20)]) + execute_command( + collection, + {"createIndexes": collection.name, "indexes": [{"key": {"a": 1}, "name": "a_1"}]}, + ) + result = execute_command( + collection, + { + "explain": { + "find": collection.name, + "filter": {"a": {"$gte": 0}}, + "projection": {"_id": 0, "a": 1}, + }, + "verbosity": "queryPlanner", + }, + ) + assertSuccess( + result, + True, + raw_res=True, + transform=is_covered, + msg="A visible index should produce a covered query plan", + ) + + +def test_covered_query_not_covered_when_index_hidden(collection): + """Test hidden index doesn't produce a covered query plan.""" + collection.insert_many([{"a": i} for i in range(20)]) + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"a": 1}, "name": "a_1", "hidden": True}], + }, + ) + result = execute_command( + collection, + { + "explain": { + "find": collection.name, + "filter": {"a": {"$gte": 0}}, + "projection": {"_id": 0, "a": 1}, + }, + "verbosity": "queryPlanner", + }, + ) + assertSuccess( + result, + False, + raw_res=True, + transform=is_covered, + msg="A hidden index must not produce a covered query plan", + ) + + +@pytest.mark.no_parallel +def test_capped_hidden_index_not_used(collection, database_client): + """Test hidden index on capped collection is excluded from planning.""" + capped_name = f"{collection.name}_capped" + coll = database_client[capped_name] + execute_command(coll, {"create": capped_name, "capped": True, "size": 1_000_000}) + execute_command(coll, {"insert": capped_name, "documents": [{"a": i} for i in range(20)]}) + execute_command( + coll, + { + "createIndexes": capped_name, + "indexes": [{"key": {"a": 1}, "name": "a_1", "hidden": True}], + }, + ) + result = execute_command( + coll, {"explain": {"find": capped_name, "filter": {"a": 5}}, "verbosity": "queryPlanner"} + ) + assertSuccess( + result, + False, + raw_res=True, + transform=lambda r: uses_index(r, "a_1"), + msg="A hidden index on a capped collection should not be used by the planner", + ) diff --git a/documentdb_tests/compatibility/tests/core/indexes/properties/hidden/utils/__init__.py b/documentdb_tests/compatibility/tests/core/indexes/properties/hidden/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/indexes/properties/hidden/utils/helpers.py b/documentdb_tests/compatibility/tests/core/indexes/properties/hidden/utils/helpers.py new file mode 100644 index 000000000..8e315fb4b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/indexes/properties/hidden/utils/helpers.py @@ -0,0 +1,108 @@ +"""Helpers for hidden index property tests.""" + +from typing import Any, Iterator, List, Optional + + +def _walk(node: Any) -> Iterator[dict]: + """Yield every dict node in a nested structure.""" + if isinstance(node, dict): + yield node + for value in node.values(): + yield from _walk(value) + elif isinstance(node, list): + for item in node: + yield from _walk(item) + + +def ixscan_index_names(explain_result: dict) -> List[str]: + """Return index names of all IXSCAN stages in the winning plan.""" + query_planner = explain_result.get("queryPlanner", {}) + winning_plan = query_planner.get("winningPlan", {}) + return [ + node["indexName"] + for node in _walk(winning_plan) + if node.get("stage") == "IXSCAN" and "indexName" in node + ] + + +def all_plan_index_names(explain_result: dict) -> List[str]: + """Return index names from the winning plan and rejected plans.""" + query_planner = explain_result.get("queryPlanner", {}) + return [ + node["indexName"] + for node in _walk(query_planner) + if node.get("stage") == "IXSCAN" and "indexName" in node + ] + + +def all_plans_execution_index_names(explain_result: dict) -> List[str]: + """Return index names from executionStats.allPlansExecution candidate plans.""" + execution_stats = explain_result.get("executionStats", {}) + all_plans_execution = execution_stats.get("allPlansExecution", []) + return [ + node["indexName"] + for candidate in all_plans_execution + for node in _walk(candidate.get("executionStages", {})) + if node.get("stage") == "IXSCAN" and "indexName" in node + ] + + +def uses_index(explain_result: dict, index_name: Optional[str] = None) -> bool: + """Return True if the winning plan uses an IXSCAN (optionally a specific one).""" + names = ixscan_index_names(explain_result) + if index_name is None: + return bool(names) + return index_name in names + + +def uses_collscan(explain_result: dict) -> bool: + """Return True if the winning plan contains a COLLSCAN stage.""" + query_planner = explain_result.get("queryPlanner", {}) + winning_plan = query_planner.get("winningPlan", {}) + return any(node.get("stage") == "COLLSCAN" for node in _walk(winning_plan)) + + +def is_covered(explain_result: dict) -> bool: + """Return True if the winning plan is a covered query (no FETCH).""" + wp = explain_result.get("queryPlanner", {}).get("winningPlan", {}) + return bool( + wp.get("stage") == "IXSCAN" + or ( + wp.get("stage") == "PROJECTION_COVERED" + and wp.get("inputStage", {}).get("stage") == "IXSCAN" + ) + ) + + +def get_index_spec(list_indexes_result: dict, index_name: str) -> Optional[Any]: + """Return the index spec for the given name, or None if not found.""" + if isinstance(list_indexes_result, Exception): + return None + for spec in list_indexes_result["cursor"]["firstBatch"]: + if spec.get("name") == index_name: + return spec + return None + + +def hidden_field(list_indexes_result: dict, index_name: str) -> Any: + """Return the hidden field value for an index, or "__ABSENT__" if not present.""" + spec = get_index_spec(list_indexes_result, index_name) + if spec is None: + return "__NO_SUCH_INDEX__" + return spec.get("hidden", "__ABSENT__") + + +def get_index_doc(result, index_name): + """Extract the $indexStats document for a given index name, or None if not found.""" + for doc in result["cursor"]["firstBatch"]: + if doc.get("name") == index_name: + return doc + return None + + +def get_index_ops(result, index_name): + """Extract the accesses.ops value for a given index name, or None if not found.""" + doc = get_index_doc(result, index_name) + if doc is None: + return None + return doc["accesses"]["ops"]