diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/__init__.py b/documentdb_tests/compatibility/tests/core/query_planning/__init__.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/__init__.py rename to documentdb_tests/compatibility/tests/core/query_planning/__init__.py diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/__init__.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/__init__.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_behavior.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_behavior.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_behavior.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_behavior.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_collation_collection.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_collation_collection.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_collation_collection.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_collation_collection.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_collection_errors.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_collection_errors.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_collection_errors.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_collection_errors.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_core.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_core.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_core.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_core.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_dependencies.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_dependencies.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_dependencies.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_dependencies.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_field_type.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_field_type.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_field_type.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_field_type.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_query_comment_type.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_query_comment_type.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_query_comment_type.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_query_comment_type.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_sort_projection_type.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_sort_projection_type.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_planCacheClear_sort_projection_type.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_planCacheClear_sort_projection_type.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_smoke_planCacheClear.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_smoke_planCacheClear.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClear/test_smoke_planCacheClear.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClear/test_smoke_planCacheClear.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_smoke_planCacheClearFilters.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClearFilters/test_smoke_planCacheClearFilters.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheClearFilters/test_smoke_planCacheClearFilters.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheClearFilters/test_smoke_planCacheClearFilters.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_smoke_planCacheListFilters.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheListFilters/test_smoke_planCacheListFilters.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheListFilters/test_smoke_planCacheListFilters.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheListFilters/test_smoke_planCacheListFilters.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheSetFilter/test_smoke_planCacheSetFilter.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheSetFilter/test_smoke_planCacheSetFilter.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/planCacheSetFilter/test_smoke_planCacheSetFilter.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/planCacheSetFilter/test_smoke_planCacheSetFilter.py diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/removeQuerySettings/test_smoke_removeQuerySettings.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_smoke_removeQuerySettings.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/removeQuerySettings/test_smoke_removeQuerySettings.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_smoke_removeQuerySettings.py diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/__init__.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_behavior.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_behavior.py new file mode 100644 index 000000000..c3353a7c8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_behavior.py @@ -0,0 +1,376 @@ +"""Tests for setQuerySettings command behavioral verification. + +Validates that query settings are retrievable via $querySettings aggregation +stage, removable via removeQuerySettings, and that the response structure +includes expected fields like queryShapeHash and representativeQuery. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.query_planning.utils.settings_test_case import ( + SettingsTestCase, +) +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +from .utils.setQuerySettings_common import get_query_settings + +# Property [Response Structure]: setQuerySettings response includes hash, query, and settings. +SET_QUERY_SETTINGS_RESPONSE_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "response_contains_hash", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"b1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"b1": 1}, + "$db": ctx.database, + } + } + ], + msg="response should contain queryShapeHash", + ), + SettingsTestCase( + "response_contains_representative_query", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"b2": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"b2": 1}, + "$db": ctx.database, + } + } + ], + msg="response should contain representativeQuery", + ), + SettingsTestCase( + "response_settings_echo", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"b3": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected=lambda ctx: { + "ok": 1.0, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"b3": 1}, + "$db": ctx.database, + } + } + ], + msg="response should echo applied settings", + ), +] + + +@pytest.mark.admin +@pytest.mark.replica_set +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_RESPONSE_TESTS)) +def test_setQuerySettings_response(collection, test): + """Test setQuerySettings response structure.""" + ctx = CommandContext.from_collection(collection) + try: + result = execute_admin_command(collection, test.build_command(ctx)) + expected = test.build_expected(ctx) + # Also verify the dynamic fields are present + if test.id == "response_contains_hash": + expected["queryShapeHash"] = result.get("queryShapeHash") + elif test.id == "response_contains_representative_query": + expected["representativeQuery"] = result.get("representativeQuery") + assertSuccessPartial(result, expected, msg=test.msg) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass + + +# Property [removeQuerySettings]: settings can be removed by query or hash. +SET_QUERY_SETTINGS_REMOVE_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "removeQuerySettings_by_query", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"b5": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"b5": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"b5": 1}, + "$db": ctx.database, + } + } + ], + msg="removeQuerySettings by query should succeed", + ), + SettingsTestCase( + "removeQuerySettings_by_hash", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"b6": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: {"removeQuerySettings": ctx.setup_results[0]["queryShapeHash"]}, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"b6": 1}, + "$db": ctx.database, + } + } + ], + msg="removeQuerySettings by hash should succeed", + ), +] + + +@pytest.mark.admin +@pytest.mark.replica_set +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_REMOVE_TESTS)) +def test_setQuerySettings_remove(collection, test): + """Test removeQuerySettings removes settings.""" + ctx = CommandContext.from_collection(collection) + try: + for cmd in test.build_setup(ctx): + r = execute_admin_command(collection, cmd) + ctx.setup_results.append(r) + result = execute_admin_command(collection, test.build_command(ctx)) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass + + +# Property [$querySettings Retrieval]: settings are visible via $querySettings aggregation stage. +SET_QUERY_SETTINGS_QS_STAGE_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "querySettings_stage_retrieval", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"b4": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + expected=lambda ctx: {"queryShapeHash": ctx.setup_results[0]["queryShapeHash"]}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"b4": 1}, + "$db": ctx.database, + } + } + ], + msg="$querySettings should return the created setting", + ), + SettingsTestCase( + "querySettings_stage_shows_settings", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"b9": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + expected=lambda ctx: { + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"b9": 1}, + "$db": ctx.database, + } + } + ], + msg="$querySettings should include indexHints in settings", + ), + SettingsTestCase( + "querySettings_stage_shows_representative_query", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"b10": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + # expected is built dynamically in the runner (self-referential) + expected=None, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"b10": 1}, + "$db": ctx.database, + } + } + ], + msg="$querySettings should include representativeQuery", + ), +] + + +@pytest.mark.admin +@pytest.mark.replica_set +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_QS_STAGE_TESTS)) +def test_setQuerySettings_qs_stage(collection, test): + """Test that settings are visible via $querySettings aggregation stage.""" + ctx = CommandContext.from_collection(collection) + try: + for cmd in test.build_setup(ctx): + r = execute_admin_command(collection, cmd) + ctx.setup_results.append(r) + expected_hash = ctx.setup_results[0]["queryShapeHash"] + settings = get_query_settings(collection) + matching = [s for s in settings if s.get("queryShapeHash") == expected_hash] + entry = matching[0] if matching else {} + expected = test.build_expected(ctx) + if expected is None: + # Self-referential: verify the field exists + expected = {"representativeQuery": entry.get("representativeQuery")} + assertSuccessPartial(entry, expected, msg=test.msg) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_query_shapes.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_query_shapes.py new file mode 100644 index 000000000..9a975aef8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_query_shapes.py @@ -0,0 +1,601 @@ +"""Tests for setQuerySettings command query shape acceptance. + +Validates that the setQuerySettings command accepts valid query shapes for +find, distinct, and aggregate commands, including various shape variations, +field combinations, and $db field variations. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.query_planning.utils.settings_test_case import ( + SettingsTestCase, +) +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Command Shape Acceptance]: accepts find, distinct, and aggregate shapes. +# Property [Find Shape Variations]: setQuerySettings accepts find shapes with various field combos. +# Property [Distinct Shape Variations]: setQuerySettings accepts distinct shapes with query combos. +# Property [Aggregate Shape Variations]: setQuerySettings accepts aggregate pipeline shapes. +# Property [$db Field Variations]: setQuerySettings accepts non-existent and special-char db names. +SET_QUERY_SETTINGS_QUERY_SHAPE_TESTS: list[SettingsTestCase] = [ + # -- Command shape acceptance -- + SettingsTestCase( + "find_shape", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "sort": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "sort": {"x": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept valid find shape", + ), + SettingsTestCase( + "distinct_shape", + command=lambda ctx: { + "setQuerySettings": { + "distinct": ctx.collection, + "key": "x", + "query": {"x": {"$gt": 0}}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "distinct": ctx.collection, + "key": "x", + "query": {"x": {"$gt": 0}}, + "$db": ctx.database, + } + } + ], + msg="should accept valid distinct shape", + ), + SettingsTestCase( + "aggregate_shape", + command=lambda ctx: { + "setQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"x": 1}}], + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"x": 1}}], + "$db": ctx.database, + } + } + ], + msg="should accept valid aggregate shape", + ), + # -- Find shape variations -- + SettingsTestCase( + "find_filter_only", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept find with filter only", + ), + SettingsTestCase( + "find_filter_sort", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"b": 1}, + "sort": {"b": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"b": 1}, + "sort": {"b": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept find with filter+sort", + ), + SettingsTestCase( + "find_filter_projection", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"c": 1}, + "projection": {"c": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"c": 1}, + "projection": {"c": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept find with filter+projection", + ), + SettingsTestCase( + "find_filter_sort_projection", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"d": 1}, + "sort": {"d": 1}, + "projection": {"d": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"d": 1}, + "sort": {"d": 1}, + "projection": {"d": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept find with all fields", + ), + SettingsTestCase( + "find_with_collation", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"e": "abc"}, + "collation": {"locale": "en", "strength": 2}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"e": "abc"}, + "collation": {"locale": "en", "strength": 2}, + "$db": ctx.database, + } + } + ], + msg="should accept find with collation", + ), + SettingsTestCase( + "find_with_let", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"$expr": {"$eq": ["$f", "$$target"]}}, + "let": {"target": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"$expr": {"$eq": ["$f", "$$target"]}}, + "let": {"target": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept find with let", + ), + SettingsTestCase( + "find_with_limit", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"g": 1}, + "limit": 10, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"g": 1}, + "limit": 10, + "$db": ctx.database, + } + } + ], + msg="should accept find with limit", + ), + # -- Distinct shape variations -- + SettingsTestCase( + "distinct_key_only", + command=lambda ctx: { + "setQuerySettings": { + "distinct": ctx.collection, + "key": "j", + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "distinct": ctx.collection, + "key": "j", + "$db": ctx.database, + } + } + ], + msg="should accept distinct key only", + ), + SettingsTestCase( + "distinct_complex_query", + command=lambda ctx: { + "setQuerySettings": { + "distinct": ctx.collection, + "key": "k", + "query": {"$and": [{"k": {"$gt": 0}}, {"k": {"$lt": 100}}]}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "distinct": ctx.collection, + "key": "k", + "query": {"$and": [{"k": {"$gt": 0}}, {"k": {"$lt": 100}}]}, + "$db": ctx.database, + } + } + ], + msg="should accept distinct complex query", + ), + # -- Aggregate shape variations -- + SettingsTestCase( + "aggregate_match_only", + command=lambda ctx: { + "setQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"l": 1}}], + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"l": 1}}], + "$db": ctx.database, + } + } + ], + msg="should accept aggregate $match only", + ), + SettingsTestCase( + "aggregate_match_group", + command=lambda ctx: { + "setQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [ + {"$match": {"m": 1}}, + {"$group": {"_id": "$m", "count": {"$sum": 1}}}, + ], + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [ + {"$match": {"m": 1}}, + {"$group": {"_id": "$m", "count": {"$sum": 1}}}, + ], + "$db": ctx.database, + } + } + ], + msg="should accept aggregate $match+$group", + ), + SettingsTestCase( + "aggregate_match_sort_limit", + command=lambda ctx: { + "setQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"n": 1}}, {"$sort": {"n": 1}}, {"$limit": 5}], + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"n": 1}}, {"$sort": {"n": 1}}, {"$limit": 5}], + "$db": ctx.database, + } + } + ], + msg="should accept aggregate $match+$sort+$limit", + ), + SettingsTestCase( + "aggregate_empty_pipeline", + command=lambda ctx: { + "setQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [], + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [], + "$db": ctx.database, + } + } + ], + msg="should accept aggregate with empty pipeline", + ), + # -- $db field variations -- + SettingsTestCase( + "db_nonexistent", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"o": 1}, + "$db": "nonexistent_db_for_query_settings_test", + }, + "settings": { + "indexHints": [ + { + "ns": { + "db": "nonexistent_db_for_query_settings_test", + "coll": ctx.collection, + }, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"o": 1}, + "$db": "nonexistent_db_for_query_settings_test", + } + } + ], + msg="should accept non-existent $db", + ), + SettingsTestCase( + "db_special_characters", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"p": 1}, + "$db": "test-special-db", + }, + "settings": { + "indexHints": [ + { + "ns": {"db": "test-special-db", "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"p": 1}, + "$db": "test-special-db", + } + } + ], + msg="should accept $db with special chars", + ), +] + + +@pytest.mark.admin +@pytest.mark.replica_set +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_QUERY_SHAPE_TESTS)) +def test_setQuerySettings_query_shapes(collection, test): + """Test setQuerySettings accepts valid query shapes.""" + ctx = CommandContext.from_collection(collection) + try: + result = execute_admin_command(collection, test.build_command(ctx)) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_reject.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_reject.py new file mode 100644 index 000000000..5b4d2d16e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_reject.py @@ -0,0 +1,189 @@ +"""Tests for setQuerySettings reject field success behavior. + +Validates that rejection does not affect unrelated query shapes, +and that reject can be reversed via update or removal. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.query_planning.utils.settings_test_case import ( + SettingsTestCase, +) +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Reject Scope]: reject: true does not affect unrelated query shapes. +# Property [Reject Reversal via Update]: updating reject to false re-enables the query. +# Property [Reject Reversal via Remove]: removing the query setting re-enables the query. +# Property [Reject False Succeeds]: reject: false with indexHints allows the query. +SET_QUERY_SETTINGS_REJECT_SUCCESS_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "reject_does_not_affect_different_shape", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"rej_s1": 1}, + "$db": ctx.database, + }, + "settings": {"reject": True}, + } + ], + command=lambda ctx: { + "find": ctx.collection, + "filter": {"rej_s2": 1}, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"rej_s1": 1}, + "$db": ctx.database, + } + } + ], + msg="different query shape should not be rejected", + ), + SettingsTestCase( + "reject_reversed_by_update", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"rej_u1": 1}, + "$db": ctx.database, + }, + "settings": {"reject": True}, + }, + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"rej_u1": 1}, + "$db": ctx.database, + }, + "settings": { + "reject": False, + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + ], + command=lambda ctx: { + "find": ctx.collection, + "filter": {"rej_u1": 1}, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"rej_u1": 1}, + "$db": ctx.database, + } + } + ], + msg="query should succeed after reject updated to false", + ), + SettingsTestCase( + "reject_reversed_by_remove", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"rej_r1": 1}, + "$db": ctx.database, + }, + "settings": {"reject": True}, + }, + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"rej_r1": 1}, + "$db": ctx.database, + } + }, + ], + command=lambda ctx: { + "find": ctx.collection, + "filter": {"rej_r1": 1}, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"rej_r1": 1}, + "$db": ctx.database, + } + } + ], + msg="query should succeed after removeQuerySettings", + ), + SettingsTestCase( + "reject_false_allows_query", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"rej_f1": 1}, + "$db": ctx.database, + }, + "settings": { + "reject": False, + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "find": ctx.collection, + "filter": {"rej_f1": 1}, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"rej_f1": 1}, + "$db": ctx.database, + } + } + ], + msg="query with reject: false should succeed", + ), +] + + +@pytest.mark.admin +@pytest.mark.replica_set +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_REJECT_SUCCESS_TESTS)) +def test_setQuerySettings_reject_success(collection, test): + """Test that reject scope and reversal work correctly.""" + ctx = CommandContext.from_collection(collection) + try: + for cmd in test.build_setup(ctx): + r = execute_admin_command(collection, cmd) + ctx.setup_results.append(r) + result = execute_command(collection, test.build_command(ctx)) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_reject_errors.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_reject_errors.py new file mode 100644 index 000000000..ff0605fb4 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_reject_errors.py @@ -0,0 +1,134 @@ +"""Tests for setQuerySettings reject field error behavior. + +Validates that reject: true blocks matching queries for find, distinct, and +aggregate commands at execution time. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.query_planning.utils.settings_test_case import ( + SettingsTestCase, +) +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import QUERYSETTINGS_QUERY_REJECTED_ERROR +from documentdb_tests.framework.executor import execute_admin_command, execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Reject Blocks Find]: reject: true blocks matching find queries. +# Property [Reject Blocks Distinct]: reject: true blocks matching distinct queries. +# Property [Reject Blocks Aggregate]: reject: true blocks matching aggregate queries. +SET_QUERY_SETTINGS_REJECT_ERROR_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "reject_blocks_find", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"b8": 1}, + "$db": ctx.database, + }, + "settings": {"reject": True}, + } + ], + command=lambda ctx: { + "find": ctx.collection, + "filter": {"b8": 1}, + }, + error_code=QUERYSETTINGS_QUERY_REJECTED_ERROR, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"b8": 1}, + "$db": ctx.database, + } + } + ], + msg="query matching reject: true setting should be rejected", + ), + SettingsTestCase( + "reject_blocks_distinct", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "distinct": ctx.collection, + "key": "x", + "query": {"rej_d1": 1}, + "$db": ctx.database, + }, + "settings": {"reject": True}, + } + ], + command=lambda ctx: { + "distinct": ctx.collection, + "key": "x", + "query": {"rej_d1": 1}, + }, + error_code=QUERYSETTINGS_QUERY_REJECTED_ERROR, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "distinct": ctx.collection, + "key": "x", + "query": {"rej_d1": 1}, + "$db": ctx.database, + } + } + ], + msg="distinct query matching reject: true should be rejected", + ), + SettingsTestCase( + "reject_blocks_aggregate", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"rej_a1": 1}}], + "$db": ctx.database, + }, + "settings": {"reject": True}, + } + ], + command=lambda ctx: { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"rej_a1": 1}}], + "cursor": {}, + }, + error_code=QUERYSETTINGS_QUERY_REJECTED_ERROR, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"rej_a1": 1}}], + "$db": ctx.database, + } + } + ], + msg="aggregate query matching reject: true should be rejected", + ), +] + + +@pytest.mark.admin +@pytest.mark.replica_set +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_REJECT_ERROR_TESTS)) +def test_setQuerySettings_reject_errors(collection, test): + """Test that reject: true blocks matching queries.""" + ctx = CommandContext.from_collection(collection) + try: + for cmd in test.build_setup(ctx): + r = execute_admin_command(collection, cmd) + ctx.setup_results.append(r) + result = execute_command(collection, test.build_command(ctx)) + assertResult(result, error_code=test.error_code, msg=test.msg) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_settings.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_settings.py new file mode 100644 index 000000000..f5c46ff44 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_settings.py @@ -0,0 +1,785 @@ +"""Tests for setQuerySettings command settings configurations. + +Validates that the setQuerySettings command accepts valid settings +combinations including indexHints, reject, queryFramework, and comment +fields, as well as allowedIndexes variations and update behavior. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.query_planning.utils.settings_test_case import ( + SettingsTestCase, +) +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [indexHints Acceptance]: setQuerySettings accepts valid indexHints configurations. +# Property [reject Acceptance]: setQuerySettings accepts reject: true alone or with indexHints. +# Property [queryFramework Acceptance]: setQuerySettings accepts classic and sbe frameworks. +# Property [comment Acceptance]: setQuerySettings accepts comment as any BSON type. +# Property [Combined Settings]: setQuerySettings accepts all settings fields together. +# Property [$natural Hint]: setQuerySettings accepts $natural in allowedIndexes. +# Property [Multiple indexHints]: setQuerySettings accepts multiple indexHints documents. +# Property [Non-Existent Index]: setQuerySettings accepts non-existent index names. +# Property [Text Index Spec]: setQuerySettings accepts text index key pattern in allowedIndexes. +# Property [2dsphere Index Spec]: setQuerySettings accepts 2dsphere index key pattern. +# Property [2d Index Spec]: setQuerySettings accepts 2d index key pattern. +# Property [Hashed Index Spec]: setQuerySettings accepts hashed index key pattern. +SET_QUERY_SETTINGS_SETTINGS_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "indexHints_single_index", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a1": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept indexHints with single index", + ), + SettingsTestCase( + "indexHints_multiple_indexes", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a2": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_", {"a2": 1}], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a2": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept multiple indexes", + ), + SettingsTestCase( + "indexHints_key_pattern", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a3": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": [{"a3": 1}], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a3": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept indexHints with key pattern", + ), + SettingsTestCase( + "reject_true", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a5": 1}, + "$db": ctx.database, + }, + "settings": {"reject": True}, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a5": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept settings with reject: true", + ), + SettingsTestCase( + "reject_with_indexHints", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a6": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "reject": True, + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a6": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept reject with indexHints", + ), + SettingsTestCase( + "queryFramework_classic", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a7": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "queryFramework": "classic", + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a7": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept queryFramework: classic", + ), + SettingsTestCase( + "queryFramework_sbe", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a8": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "queryFramework": "sbe", + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a8": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept queryFramework: sbe", + ), + SettingsTestCase( + "with_comment_string", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a9": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "comment": "test comment for setQuerySettings", + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a9": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept settings with comment string", + ), + SettingsTestCase( + "all_settings_combined", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a12": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "queryFramework": "classic", + "reject": True, + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a12": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept all settings combined", + ), + SettingsTestCase( + "indexHints_natural", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a13": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["$natural"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a13": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept $natural in allowedIndexes", + ), + SettingsTestCase( + "indexHints_multiple_ns_documents", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a14": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + }, + { + "ns": {"db": ctx.database, "coll": "other_collection"}, + "allowedIndexes": ["_id_"], + }, + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a14": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept multiple indexHints documents", + ), + SettingsTestCase( + "indexHints_nonexistent_index", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a15": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["nonexistent_index"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a15": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept non-existent index name", + ), + SettingsTestCase( + "comment_object", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a16": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "comment": {"body": {"msg": "Updated"}}, + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a16": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept settings with comment as object", + ), + SettingsTestCase( + "comment_int", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a17": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "comment": 42, + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a17": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept settings with comment as int", + ), + SettingsTestCase( + "comment_bool", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a18": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "comment": True, + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a18": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept settings with comment as bool", + ), + SettingsTestCase( + "comment_array", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a19": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "comment": ["tag1", "tag2"], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a19": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept settings with comment as array", + ), + SettingsTestCase( + "comment_null", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a20": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "comment": None, + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a20": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept settings with comment as null", + ), + SettingsTestCase( + "indexHints_text_index_spec", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a21": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": [{"a21": "text"}], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a21": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept text index key pattern in allowedIndexes", + ), + SettingsTestCase( + "indexHints_2dsphere_index_spec", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a22": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": [{"geo": "2dsphere"}], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a22": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept 2dsphere index key pattern in allowedIndexes", + ), + SettingsTestCase( + "indexHints_2d_index_spec", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a23": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": [{"loc": "2d"}], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a23": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept 2d index key pattern in allowedIndexes", + ), + SettingsTestCase( + "indexHints_hashed_index_spec", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a24": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": [{"a24": "hashed"}], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a24": 1}, + "$db": ctx.database, + } + } + ], + msg="should accept hashed index key pattern in allowedIndexes", + ), +] + + +@pytest.mark.admin +@pytest.mark.replica_set +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_SETTINGS_TESTS)) +def test_setQuerySettings_settings(collection, test): + """Test setQuerySettings accepts valid settings configurations.""" + ctx = CommandContext.from_collection(collection) + try: + for cmd in test.build_setup(ctx): + r = execute_admin_command(collection, cmd) + ctx.setup_results.append(r) + result = execute_admin_command(collection, test.build_command(ctx)) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass + + +# Property [Update Behavior]: setQuerySettings can update existing settings by query or hash. +SET_QUERY_SETTINGS_UPDATE_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "update_existing_settings", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a10": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a10": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_", {"a10": 1}], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a10": 1}, + "$db": ctx.database, + } + } + ], + msg="update setQuerySettings should succeed", + ), + SettingsTestCase( + "update_via_hash", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a11": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "setQuerySettings": ctx.setup_results[0]["queryShapeHash"], + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_", {"a11": 1}], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a11": 1}, + "$db": ctx.database, + } + } + ], + msg="update via hash should succeed", + ), +] + + +@pytest.mark.admin +@pytest.mark.replica_set +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_UPDATE_TESTS)) +def test_setQuerySettings_update(collection, test): + """Test setQuerySettings can update existing settings by query or hash.""" + ctx = CommandContext.from_collection(collection) + try: + for cmd in test.build_setup(ctx): + r = execute_admin_command(collection, cmd) + ctx.setup_results.append(r) + result = execute_admin_command(collection, test.build_command(ctx)) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_type_errors.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_type_errors.py new file mode 100644 index 000000000..2c9d0304d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_type_errors.py @@ -0,0 +1,308 @@ +"""Tests for setQuerySettings command BSON type rejection. + +Validates that the setQuerySettings command rejects invalid BSON types for +the primary argument field, the queryFramework sub-field, the reject sub-field, +and the indexHints namespace and allowedIndexes sub-fields. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + FAILED_TO_PARSE_ERROR, + MISSING_FIELD_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Primary Argument Type Rejection]: the setQuerySettings field must +# be a document (query shape) or string (hash). All other BSON types are +# rejected with TYPE_MISMATCH_ERROR. +SET_QUERY_SETTINGS_PRIMARY_ARG_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"primary_arg_{tid}", + command=lambda ctx, v=value: { + "setQuerySettings": v, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"setQuerySettings should reject {tid} as the primary argument", + ) + for tid, value in [ + ("null", None), + ("int32", 42), + ("int64", Int64(42)), + ("double", 3.14), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("array", [1, 2, 3]), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(0, 0)), + ("binary", Binary(b"\x00")), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [queryFramework Type Rejection]: the queryFramework field must be a +# string. Non-string BSON types are rejected with TYPE_MISMATCH_ERROR. +SET_QUERY_SETTINGS_QUERY_FRAMEWORK_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"query_framework_{tid}", + command=lambda ctx, v=value: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "queryFramework": v, + }, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"setQuerySettings should reject {tid} as queryFramework", + ) + for tid, value in [ + ("int32", 42), + ("int64", Int64(42)), + ("double", 3.14), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("array", [1]), + ("object", {"k": "v"}), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(0, 0)), + ("binary", Binary(b"\x00")), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [reject Type Rejection]: the reject field must be a boolean. +# Non-boolean BSON types are rejected with TYPE_MISMATCH_ERROR. +SET_QUERY_SETTINGS_REJECT_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"reject_{tid}", + command=lambda ctx, v=value: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "reject": v, + }, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"setQuerySettings should reject {tid} as reject field", + ) + for tid, value in [ + ("null", None), + ("int32", 42), + ("int64", Int64(42)), + ("double", 3.14), + ("decimal128", Decimal128("1")), + ("string", "true"), + ("array", [True]), + ("object", {"k": "v"}), + ("objectid", ObjectId()), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(0, 0)), + ("binary", Binary(b"\x00")), + ("regex", Regex(".*")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [indexHints.ns.db Type Rejection]: the ns.db field must be a string. +SET_QUERY_SETTINGS_NS_DB_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"ns_db_{tid}", + command=lambda ctx, v=value: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": v, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"setQuerySettings should reject {tid} as indexHints.ns.db", + ) + for tid, value in [ + ("int32", 42), + ("bool", True), + ("array", ["test"]), + ("object", {"k": "v"}), + ] +] + +# Property [indexHints.ns.coll Type Rejection]: the ns.coll field must be a string. +SET_QUERY_SETTINGS_NS_COLL_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"ns_coll_{tid}", + command=lambda ctx, v=value: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": v}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"setQuerySettings should reject {tid} as indexHints.ns.coll", + ) + for tid, value in [ + ("int32", 42), + ("bool", True), + ] +] + +# Property [indexHints.allowedIndexes Type Rejection]: allowedIndexes must be an array. +SET_QUERY_SETTINGS_ALLOWED_INDEXES_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"allowed_indexes_{tid}", + command=lambda ctx, v=value: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": v, + } + ], + }, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"setQuerySettings should reject {tid} as indexHints.allowedIndexes", + ) + for tid, value in [ + ("string", "_id_"), + ("int32", 42), + ] +] + +# Property [allowedIndexes null]: null allowedIndexes treated as missing required field. +SET_QUERY_SETTINGS_ALLOWED_INDEXES_EDGE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "allowed_indexes_null_missing", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": None, + } + ], + }, + }, + error_code=MISSING_FIELD_ERROR, + msg="setQuerySettings should reject null allowedIndexes as missing field", + ), + CommandTestCase( + "allowed_indexes_non_string_element", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": [42], + } + ], + }, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="setQuerySettings should reject non-string elements in allowedIndexes", + ), +] + +SET_QUERY_SETTINGS_TYPE_ERROR_TESTS: list[CommandTestCase] = ( + SET_QUERY_SETTINGS_PRIMARY_ARG_TYPE_TESTS + + SET_QUERY_SETTINGS_QUERY_FRAMEWORK_TYPE_TESTS + + SET_QUERY_SETTINGS_REJECT_TYPE_TESTS + + SET_QUERY_SETTINGS_NS_DB_TYPE_TESTS + + SET_QUERY_SETTINGS_NS_COLL_TYPE_TESTS + + SET_QUERY_SETTINGS_ALLOWED_INDEXES_TYPE_TESTS + + SET_QUERY_SETTINGS_ALLOWED_INDEXES_EDGE_TESTS +) + + +@pytest.mark.admin +@pytest.mark.replica_set +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_TYPE_ERROR_TESTS)) +def test_setQuerySettings_type_errors(collection, test): + """Test setQuerySettings BSON type rejection.""" + ctx = CommandContext.from_collection(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertResult( + result, + error_code=test.error_code, + msg=test.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_validation_errors.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_validation_errors.py new file mode 100644 index 000000000..cead12f5f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_validation_errors.py @@ -0,0 +1,426 @@ +"""Tests for setQuerySettings command structural and validation errors. + +Validates that the setQuerySettings command rejects malformed query shapes, +invalid hash strings, missing or empty settings, unrecognized fields, invalid +queryFramework values, system collection restrictions, and that reject: true +blocks matching queries at execution time. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + INVALID_LENGTH_ERROR, + INVALID_NAMESPACE_ERROR, + MISSING_FIELD_ERROR, + QUERYSETTINGS_EMPTY_SETTINGS_ERROR, + QUERYSETTINGS_IDHACK_QUERY_ERROR, + QUERYSETTINGS_INTERNAL_DB_ERROR, + QUERYSETTINGS_NS_COLL_MISSING_ERROR, + QUERYSETTINGS_NS_DB_MISSING_ERROR, + QUERYSETTINGS_REJECT_ONLY_ERROR, + QUERYSETTINGS_UNKNOWN_COMMAND_SHAPE_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Query Shape Validation]: rejects malformed or unknown query shape documents. +# Property [Hash String Validation]: rejects invalid hash string formats. +# Property [indexHints Structure Validation]: rejects indexHints missing required sub-fields. +# Property [Settings Value Validation]: rejects invalid field values in settings document. +# Property [Settings Presence]: rejects missing or empty settings document. +# Property [Unrecognized Fields]: rejects unknown top-level command fields. +# Property [Database Restrictions]: rejects query shapes targeting internal databases. +# Property [indexHints Value Validation]: rejects empty allowedIndexes and IDHACK queries. +# Property [Reject Blocks Query]: a rejected query returns an error when executed. +SET_QUERY_SETTINGS_VALIDATION_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "query_shape_missing_db", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=MISSING_FIELD_ERROR, + msg="setQuerySettings should reject query shape missing $db field", + ), + CommandTestCase( + "query_shape_empty_db", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": "", + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="setQuerySettings should reject query shape with empty $db", + ), + CommandTestCase( + "query_shape_unknown_command", + command=lambda ctx: { + "setQuerySettings": { + "unknownCommand": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=QUERYSETTINGS_UNKNOWN_COMMAND_SHAPE_ERROR, + msg="setQuerySettings should reject unknown command type in query shape", + ), + CommandTestCase( + "empty_hash_string", + command=lambda ctx: { + "setQuerySettings": "", + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=INVALID_LENGTH_ERROR, + msg="setQuerySettings should reject empty hash string", + ), + CommandTestCase( + "short_hash_string", + command=lambda ctx: { + "setQuerySettings": "tooshort", + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=BAD_VALUE_ERROR, + msg="setQuerySettings should reject hash string with wrong length", + ), + CommandTestCase( + "nonhex_hash_string", + command=lambda ctx: { + "setQuerySettings": "ZZZZZZZZ34567890ABCDEF1234567890" + "ABCDEF1234567890ABCDEF1234567890", + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=BAD_VALUE_ERROR, + msg="setQuerySettings should reject hash string with non-hex chars", + ), + CommandTestCase( + "indexHints_missing_ns", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=MISSING_FIELD_ERROR, + msg="setQuerySettings should reject indexHints missing ns field", + ), + CommandTestCase( + "indexHints_ns_missing_db", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=QUERYSETTINGS_NS_DB_MISSING_ERROR, + msg="setQuerySettings should reject indexHints.ns missing db field", + ), + CommandTestCase( + "indexHints_ns_null_db", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": None, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=QUERYSETTINGS_NS_DB_MISSING_ERROR, + msg="setQuerySettings should reject indexHints.ns with null db", + ), + CommandTestCase( + "indexHints_ns_missing_coll", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=QUERYSETTINGS_NS_COLL_MISSING_ERROR, + msg="setQuerySettings should reject indexHints.ns missing coll field", + ), + CommandTestCase( + "indexHints_ns_null_coll", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": None}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=QUERYSETTINGS_NS_COLL_MISSING_ERROR, + msg="setQuerySettings should reject indexHints.ns with null coll", + ), + CommandTestCase( + "invalid_query_framework_value", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "queryFramework": "invalidFramework", + }, + }, + error_code=BAD_VALUE_ERROR, + msg="setQuerySettings should reject invalid queryFramework string", + ), + CommandTestCase( + "reject_false_only", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": {"reject": False}, + }, + error_code=QUERYSETTINGS_REJECT_ONLY_ERROR, + msg="setQuerySettings should reject settings with only reject: false", + ), + CommandTestCase( + "missing_settings", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + }, + error_code=MISSING_FIELD_ERROR, + msg="setQuerySettings should reject missing settings field", + ), + CommandTestCase( + "empty_settings", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": {}, + }, + error_code=QUERYSETTINGS_EMPTY_SETTINGS_ERROR, + msg="setQuerySettings should reject empty settings document", + ), + CommandTestCase( + "unrecognized_top_level_field", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + "unknownField": 1, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="setQuerySettings should reject unrecognized top-level field", + ), + CommandTestCase( + "system_collection", + command=lambda ctx: { + "setQuerySettings": { + "find": "system.users", + "filter": {}, + "$db": "admin", + }, + "settings": { + "indexHints": [ + { + "ns": {"db": "admin", "coll": "system.users"}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=QUERYSETTINGS_INTERNAL_DB_ERROR, + msg="setQuerySettings should reject query shapes on internal databases", + ), + CommandTestCase( + "local_database", + command=lambda ctx: { + "setQuerySettings": { + "find": "oplog.rs", + "filter": {}, + "$db": "local", + }, + "settings": { + "indexHints": [ + { + "ns": {"db": "local", "coll": "oplog.rs"}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=QUERYSETTINGS_INTERNAL_DB_ERROR, + msg="setQuerySettings should reject query shapes on local database", + ), + CommandTestCase( + "indexHints_empty_allowed_rejected", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"a4": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": [], + } + ], + }, + }, + error_code=QUERYSETTINGS_REJECT_ONLY_ERROR, + msg="setQuerySettings should reject indexHints with empty allowedIndexes", + ), + CommandTestCase( + "idhack_query_rejected", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"_id": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + error_code=QUERYSETTINGS_IDHACK_QUERY_ERROR, + msg="setQuerySettings should reject IDHACK-eligible queries", + ), +] + + +@pytest.mark.admin +@pytest.mark.replica_set +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_VALIDATION_ERROR_TESTS)) +def test_setQuerySettings_validation_errors(collection, test): + """Test setQuerySettings structural and validation error rejection.""" + ctx = CommandContext.from_collection(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertResult( + result, + error_code=test.error_code, + msg=test.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_verification.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_verification.py new file mode 100644 index 000000000..113da8840 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_setQuerySettings_verification.py @@ -0,0 +1,970 @@ +"""Tests for setQuerySettings observable effects and verification. + +Validates query shape hash properties, $querySettings stage output for +distinct and aggregate shapes, showDebugQueryShape, multiple settings +management, comment visibility, settings replacement semantics, and +indexHints namespace mismatch acceptance. +""" + +from __future__ import annotations + +import re + +import pytest + +from documentdb_tests.compatibility.tests.core.query_planning.utils.settings_test_case import ( + SettingsTestCase, +) +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +from .utils.setQuerySettings_common import get_query_settings + +# Property [Hash Format]: queryShapeHash is a 64-character hexadecimal string. +# Property [Hash Consistency]: same query shape produces the same hash. +# Property [Hash Uniqueness]: different query shapes produce different hashes. +# Property [Shape Matching]: filter values do not affect shape identity. +# Property [Sort Direction Matters]: different sort directions produce different hashes. +# Property [$querySettings Distinct]: $querySettings returns correct data for distinct. +# Property [$querySettings Aggregate]: $querySettings returns correct data for aggregate. +# Property [showDebugQueryShape True]: debugQueryShape present when requested. +# Property [showDebugQueryShape False]: debugQueryShape absent when not requested. +# Property [Multiple Settings Visible]: all query settings appear in $querySettings. +# Property [Multiple Settings Remove]: removing one leaves others intact. +# Property [Comment Visibility]: settings.comment appears in $querySettings output. +# Property [Comment Update]: updating settings.comment replaces the old value. +# Property [Settings Replacement]: updating settings preserves unmodified fields. +# Property [No Duplicate On Update]: updating same shape does not duplicate entries. +# Property [ns Mismatch]: indexHints ns.coll can differ from query shape collection. + + +# --------------------------------------------------------------------------- +# Group 1: ns.coll mismatch acceptance test +# --------------------------------------------------------------------------- + +SET_QUERY_SETTINGS_NS_MISMATCH_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "ns_coll_mismatch_accepted", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"mis1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": { + "db": ctx.database, + "coll": "completely_different_collection", + }, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"mis1": 1}, + "$db": ctx.database, + } + } + ], + msg="ns.coll mismatch should be accepted", + ), +] + + +@pytest.mark.admin +@pytest.mark.replica_set +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_NS_MISMATCH_TESTS)) +def test_setQuerySettings_ns_coll_mismatch_accepted(collection, test): + """Test that indexHints ns.coll can differ from query shape collection.""" + ctx = CommandContext.from_collection(collection) + try: + result = execute_admin_command(collection, test.build_command(ctx)) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Group 2: Hash property tests +# --------------------------------------------------------------------------- + +SET_QUERY_SETTINGS_HASH_SAME_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "same_shape_produces_same_hash", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"h2": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"h2": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"h2": 1}, + "$db": ctx.database, + } + } + ], + msg="same query shape should produce identical hashes", + ), + SettingsTestCase( + "filter_values_do_not_affect_shape", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + } + }, + ], + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"x": 999}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"x": 999}, + "$db": ctx.database, + } + } + ], + msg="filter values should not affect query shape hash", + ), +] + +SET_QUERY_SETTINGS_HASH_DIFFERENT_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "different_shapes_different_hashes", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"h3a": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"h3b": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"h3a": 1}, + "$db": ctx.database, + } + }, + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"h3b": 1}, + "$db": ctx.database, + } + }, + ], + msg="different query shapes should produce different hashes", + ), + SettingsTestCase( + "sort_direction_affects_shape", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"sd": 1}, + "sort": {"a": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"sd": 1}, + "sort": {"a": -1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sd": 1}, + "sort": {"a": 1}, + "$db": ctx.database, + } + }, + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sd": 1}, + "sort": {"a": -1}, + "$db": ctx.database, + } + }, + ], + msg="sort direction should produce different query shape hashes", + ), +] + + +@pytest.mark.admin +@pytest.mark.replica_set +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_HASH_SAME_TESTS)) +def test_setQuerySettings_hash_same(collection, test): + """Test that equivalent query shapes produce the same hash.""" + ctx = CommandContext.from_collection(collection) + try: + setup_hash = None + for cmd in test.build_setup(ctx): + r = execute_admin_command(collection, cmd) + ctx.setup_results.append(r) + if "queryShapeHash" in r: + setup_hash = r["queryShapeHash"] + result = execute_admin_command(collection, test.build_command(ctx)) + assertSuccessPartial( + result, + {"queryShapeHash": setup_hash}, + msg=test.msg, + ) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass + + +@pytest.mark.admin +@pytest.mark.replica_set +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_HASH_DIFFERENT_TESTS)) +def test_setQuerySettings_hash_different(collection, test): + """Test that distinct query shapes produce different hashes.""" + ctx = CommandContext.from_collection(collection) + try: + setup_hash = None + for cmd in test.build_setup(ctx): + r = execute_admin_command(collection, cmd) + ctx.setup_results.append(r) + if "queryShapeHash" in r: + setup_hash = r["queryShapeHash"] + result = execute_admin_command(collection, test.build_command(ctx)) + hashes_differ = result["queryShapeHash"] != setup_hash + assertSuccessPartial( + {"differ": hashes_differ}, + {"differ": True}, + msg=test.msg, + ) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Group 3: Hash format test +# --------------------------------------------------------------------------- + +SET_QUERY_SETTINGS_HASH_FORMAT_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "hash_is_64_char_hex", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"h1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"h1": 1}, + "$db": ctx.database, + } + } + ], + msg="queryShapeHash should be 64-char hex", + ), +] + + +@pytest.mark.admin +@pytest.mark.replica_set +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_HASH_FORMAT_TESTS)) +def test_setQuerySettings_hash_format(collection, test): + """Test that queryShapeHash matches expected format.""" + ctx = CommandContext.from_collection(collection) + try: + result = execute_admin_command(collection, test.build_command(ctx)) + h = result.get("queryShapeHash", "") + is_valid = bool(re.fullmatch(r"[0-9A-Fa-f]{64}", h)) + assertSuccessPartial( + {"valid": is_valid}, + {"valid": True}, + msg=f"{test.msg}, got: {h!r}", + ) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Group 4: $querySettings inspection tests +# --------------------------------------------------------------------------- + +SET_QUERY_SETTINGS_QS_STAGE_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "querySettings_returns_distinct_shape", + command=lambda ctx: { + "setQuerySettings": { + "distinct": ctx.collection, + "key": "x", + "query": {"qs_d1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected=lambda ctx: {"distinct": ctx.collection}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "distinct": ctx.collection, + "key": "x", + "query": {"qs_d1": 1}, + "$db": ctx.database, + } + } + ], + msg="representativeQuery should be a distinct shape", + ), + SettingsTestCase( + "querySettings_returns_aggregate_shape", + command=lambda ctx: { + "setQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"qs_a1": 1}}], + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected=lambda ctx: {"aggregate": ctx.collection}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"qs_a1": 1}}], + "$db": ctx.database, + } + } + ], + msg="representativeQuery should be an aggregate shape", + ), +] + + +@pytest.mark.admin +@pytest.mark.replica_set +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_QS_STAGE_TESTS)) +def test_setQuerySettings_qs_stage(collection, test): + """Test $querySettings returns correct representativeQuery.""" + ctx = CommandContext.from_collection(collection) + try: + r = execute_admin_command(collection, test.build_command(ctx)) + settings = get_query_settings(collection) + matching = [s for s in settings if s.get("queryShapeHash") == r["queryShapeHash"]] + entry = matching[0] if matching else {} + assertSuccessPartial( + entry.get("representativeQuery", {}), + test.build_expected(ctx), + msg=test.msg, + ) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Group 5: showDebugQueryShape tests +# --------------------------------------------------------------------------- + +SET_QUERY_SETTINGS_DEBUG_SHAPE_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "debug_query_shape_present_when_enabled", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"dbg1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"has_debug": True}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"dbg1": 1}, + "$db": ctx.database, + } + } + ], + msg="debugQueryShape should be present with showDebugQueryShape: true", + ), + SettingsTestCase( + "debug_query_shape_absent_when_disabled", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"dbg2": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"has_debug": False}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"dbg2": 1}, + "$db": ctx.database, + } + } + ], + msg="debugQueryShape should be absent with showDebugQueryShape: false", + ), +] + + +@pytest.mark.admin +@pytest.mark.replica_set +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_DEBUG_SHAPE_TESTS)) +def test_setQuerySettings_debug_shape(collection, test): + """Test showDebugQueryShape controls debugQueryShape presence.""" + ctx = CommandContext.from_collection(collection) + expected = test.build_expected(ctx) + show_debug = expected["has_debug"] + try: + execute_admin_command(collection, test.build_command(ctx)) + settings = list( + collection.database.client.admin.aggregate( + [{"$querySettings": {"showDebugQueryShape": show_debug}}] + ) + ) + filter_key = "dbg1" if show_debug else "dbg2" + entry = [ + s + for s in settings + if s.get("representativeQuery", {}).get("filter", {}).get(filter_key) + ] + has_debug = "debugQueryShape" in (entry[0] if entry else {}) + assertSuccessPartial( + {"has_debug": has_debug}, + expected, + msg=test.msg, + ) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Group 6: Settings field verification via $querySettings +# (comment visibility, comment update, settings replacement) +# --------------------------------------------------------------------------- + +SET_QUERY_SETTINGS_FIELD_VERIFICATION_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "comment_visible_in_querySettings", + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"comvis1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "comment": "my-test-comment", + }, + }, + expected={"comment": "my-test-comment"}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"comvis1": 1}, + "$db": ctx.database, + } + } + ], + msg="comment should be visible in $querySettings output", + ), + SettingsTestCase( + "comment_replaced_on_update", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"comup1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "comment": "original", + }, + } + ], + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"comup1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "comment": "updated", + }, + }, + expected={"comment": "updated"}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"comup1": 1}, + "$db": ctx.database, + } + } + ], + msg="comment should be replaced by the updated value", + ), + SettingsTestCase( + "update_preserves_unmodified_fields", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"rep1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "queryFramework": "classic", + }, + } + ], + command=lambda ctx: { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"rep1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + expected={"queryFramework": "classic"}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"rep1": 1}, + "$db": ctx.database, + } + } + ], + msg="queryFramework should be preserved after update with only indexHints", + ), +] + + +@pytest.mark.admin +@pytest.mark.replica_set +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_FIELD_VERIFICATION_TESTS)) +def test_setQuerySettings_field_verification(collection, test): + """Test settings fields are visible and correctly updated in $querySettings.""" + ctx = CommandContext.from_collection(collection) + try: + for cmd in test.build_setup(ctx): + r = execute_admin_command(collection, cmd) + ctx.setup_results.append(r) + r = execute_admin_command(collection, test.build_command(ctx)) + settings = get_query_settings(collection) + matching = [s for s in settings if s.get("queryShapeHash") == r["queryShapeHash"]] + entry = matching[0] if matching else {} + assertSuccessPartial( + entry.get("settings", {}), + test.build_expected(ctx), + msg=test.msg, + ) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Group 7: Multi-setup settings management tests +# --------------------------------------------------------------------------- + + +SET_QUERY_SETTINGS_MULTI_SETUP_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "no_duplicate_on_update", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"dup1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"dup1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + "queryFramework": "classic", + }, + }, + ], + expected=lambda ctx: { + "ok": sum( + 1 + for h in ctx.setup_results[-1]["_live_hashes"] + if h + == [r["queryShapeHash"] for r in ctx.setup_results if "queryShapeHash" in r][-1] + ) + == 1 + }, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"dup1": 1}, + "$db": ctx.database, + } + } + ], + msg="updating same shape should not create duplicate entries", + ), + SettingsTestCase( + "multiple_settings_all_visible", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {f"multi{i}": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + for i in range(1, 4) + ], + expected=lambda ctx: { + "ok": all( + h in ctx.setup_results[-1]["_live_hashes"] + for h in [r["queryShapeHash"] for r in ctx.setup_results if "queryShapeHash" in r] + ) + }, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {f"multi{i}": 1}, + "$db": ctx.database, + } + } + for i in range(1, 4) + ], + msg="all 3 query settings should be visible in $querySettings", + ), + SettingsTestCase( + "remove_one_leaves_others", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"rem1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"rem2": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"rem1": 1}, + "$db": ctx.database, + } + }, + ], + expected=lambda ctx: { + "ok": [r["queryShapeHash"] for r in ctx.setup_results if "queryShapeHash" in r][0] + not in ctx.setup_results[-1]["_live_hashes"] + and [r["queryShapeHash"] for r in ctx.setup_results if "queryShapeHash" in r][1] + in ctx.setup_results[-1]["_live_hashes"] + }, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {f"rem{i}": 1}, + "$db": ctx.database, + } + } + for i in range(1, 3) + ], + msg="q1 removed, q2 should remain in $querySettings", + ), +] + + +@pytest.mark.admin +@pytest.mark.replica_set +@pytest.mark.parametrize("test", pytest_params(SET_QUERY_SETTINGS_MULTI_SETUP_TESTS)) +def test_setQuerySettings_multi_setup(collection, test): + """Test multi-setup settings management via $querySettings inspection.""" + ctx = CommandContext.from_collection(collection) + try: + for cmd in test.build_setup(ctx): + r = execute_admin_command(collection, cmd) + ctx.setup_results.append(r) + all_hashes = {s.get("queryShapeHash") for s in get_query_settings(collection)} + # Stash live hashes so expected-lambdas can reference them. + ctx.setup_results.append({"_live_hashes": all_hashes}) + assertSuccessPartial( + test.build_expected(ctx), + {"ok": True}, + msg=test.msg, + ) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass diff --git a/documentdb_tests/compatibility/tests/core/query-planning/commands/setQuerySettings/test_smoke_setQuerySettings.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_smoke_setQuerySettings.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/query-planning/commands/setQuerySettings/test_smoke_setQuerySettings.py rename to documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/test_smoke_setQuerySettings.py diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/utils/__init__.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/utils/setQuerySettings_common.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/utils/setQuerySettings_common.py new file mode 100644 index 000000000..9d5da0037 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_planning/commands/setQuerySettings/utils/setQuerySettings_common.py @@ -0,0 +1,15 @@ +"""Shared utilities for setQuerySettings tests.""" + +from __future__ import annotations + +from typing import Any + +from pymongo.collection import Collection + + +def get_query_settings(collection: Collection) -> list[dict[str, Any]]: + """Retrieve all current query settings via $querySettings stage.""" + admin = collection.database.client.admin + result = admin.command({"aggregate": 1, "pipeline": [{"$querySettings": {}}], "cursor": {}}) + batch: list[dict[str, Any]] = result.get("cursor", {}).get("firstBatch", []) + return batch diff --git a/documentdb_tests/compatibility/tests/core/query_planning/utils/__init__.py b/documentdb_tests/compatibility/tests/core/query_planning/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/query_planning/utils/settings_test_case.py b/documentdb_tests/compatibility/tests/core/query_planning/utils/settings_test_case.py new file mode 100644 index 000000000..9e8d565bc --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_planning/utils/settings_test_case.py @@ -0,0 +1,53 @@ +"""Test case with setup/cleanup lifecycle for settings-based commands. + +``SettingsTestCase`` extends ``CommandTestCase`` with ``setup_commands`` +and ``cleanup`` hooks for commands that require prerequisite operations +(e.g. creating a query setting before testing removal) and post-test +teardown (e.g. removing cluster-wide query settings). + +Results returned by each setup command are appended to +``setup_results`` so that later lambdas (``command``, ``expected``, +etc.) can reference values produced during setup. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) + + +@dataclass(frozen=True) +class SettingsTestCase(CommandTestCase): + """CommandTestCase with setup-command and cleanup lifecycle. + + Attributes: + setup_commands: Optional callable ``(CommandContext) -> list[dict]`` + returning commands to execute **before** the main command. + Each command's result is appended to + ``CommandContext.setup_results`` by the runner. + cleanup: Optional callable ``(CommandContext) -> list[dict]`` + returning commands to run after the test. Each dict is + passed to the executor inside a try/except so cleanup + failures are silently ignored. + """ + + setup_commands: Callable[[CommandContext], list[dict[str, Any]]] | None = None + cleanup: Callable[[CommandContext], list[dict[str, Any]]] | None = None + + def build_setup(self, ctx: CommandContext) -> list[dict[str, Any]]: + """Resolve setup commands from the callable, or return empty list.""" + if self.setup_commands is None: + return [] + return self.setup_commands(ctx) + + def build_cleanup(self, ctx: CommandContext) -> list[dict[str, Any]]: + """Resolve cleanup commands from the callable, or return empty list.""" + if self.cleanup is None: + return [] + return self.cleanup(ctx) diff --git a/documentdb_tests/compatibility/tests/core/utils/command_test_case.py b/documentdb_tests/compatibility/tests/core/utils/command_test_case.py index 8399464a6..27a686422 100644 --- a/documentdb_tests/compatibility/tests/core/utils/command_test_case.py +++ b/documentdb_tests/compatibility/tests/core/utils/command_test_case.py @@ -1,4 +1,4 @@ -"""Shared test case for collection command tests.""" +"""Shared test case for collection and admin command tests.""" from __future__ import annotations @@ -26,12 +26,16 @@ class CommandContext: database: The resolved database name. namespace: The full namespace string (``database.collection``). uuids: Mapping of collection names to their server-assigned UUIDs. + setup_results: Results from setup commands, populated by the runner. + Mutable even in a frozen dataclass so runners can append after + construction. """ collection: str database: str namespace: str uuids: dict[str, Any] = field(default_factory=dict) + setup_results: list[dict[str, Any]] = field(default_factory=list) @classmethod def from_collection(cls, collection: Collection) -> CommandContext: diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index fd892adbc..7c137d582 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -53,6 +53,7 @@ API_VERSION_ERROR = 322 API_STRICT_ERROR = 323 COLLECTION_UUID_MISMATCH_ERROR = 361 +QUERYSETTINGS_QUERY_REJECTED_ERROR = 411 EXPRESSION_NOT_OBJECT_ERROR = 10065 BSON_OBJECT_TOO_LARGE_ERROR = 10334 DUPLICATE_KEY_ERROR = 11000 @@ -500,11 +501,18 @@ N_ACCUMULATOR_INVALID_N_ERROR = 7548606 GEO_NEAR_MIN_DISTANCE_NOT_CONSTANT_ERROR = 7555701 GEO_NEAR_MAX_DISTANCE_NOT_CONSTANT_ERROR = 7555702 +QUERYSETTINGS_UNKNOWN_COMMAND_SHAPE_ERROR = 7746402 +QUERYSETTINGS_REJECT_ONLY_ERROR = 7746604 +QUERYSETTINGS_IDHACK_QUERY_ERROR = 7746606 QUERYSETTINGS_NON_DOCUMENT_ARG_ERROR = 7746800 PIPELINE_LENGTH_LIMIT_ERROR = 7749501 PERCENTILE_INVALID_P_FIELD_ERROR = 7750301 PERCENTILE_INVALID_P_VALUE_ERROR = 7750303 ENCRYPTED_FIELD_TRIM_FACTOR_OUT_OF_RANGE_ERROR = 8574000 +QUERYSETTINGS_INTERNAL_DB_ERROR = 8584900 +QUERYSETTINGS_NS_DB_MISSING_ERROR = 8727500 +QUERYSETTINGS_NS_COLL_MISSING_ERROR = 8727501 +QUERYSETTINGS_EMPTY_SETTINGS_ERROR = 8727502 COUNT_FIELD_ID_RESERVED_ERROR = 9039800 CONVERT_BYTE_ORDER_TYPE_ERROR = 9130001 CONVERT_BYTE_ORDER_VALUE_ERROR = 9130002