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/__init__.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_behavior.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_behavior.py new file mode 100644 index 000000000..18cc6ddd2 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_behavior.py @@ -0,0 +1,588 @@ +"""Tests for removeQuerySettings command behavioral verification. + +Verifies that removeQuerySettings actually removes query settings from the +cluster, not just that it returns ok: 1.0. Uses $querySettings to observe +settings state before and after removal. +""" + +from __future__ import annotations + +from typing import Any + +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 + +pytestmark = [pytest.mark.no_parallel] + +# Property [Remove By Query Shape]: removeQuerySettings removes settings +# when given the original query shape, verified via $querySettings. +# Property [Remove By Hash]: removeQuerySettings removes settings when given +# the query shape hash string, verified via $querySettings. +# Property [Remove Distinct Shape]: removeQuerySettings removes settings for +# distinct query shapes, verified via $querySettings. +# Property [Remove Aggregate Shape]: removeQuerySettings removes settings for +# aggregate query shapes, verified via $querySettings. +# Property [Shape Matching Ignores Filter Values]: query shape matching uses +# field structure, not values. Removing with different filter values removes +# the original setting. +REMOVEQUERYSETTINGS_SETTING_REMOVED_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "removes_by_query_shape", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"r1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"r1": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"r1": 1}, + "$db": ctx.database, + } + } + ], + msg="removeQuerySettings should remove the setting by query shape", + ), + SettingsTestCase( + "removes_by_hash", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"r2": 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": {"r2": 1}, + "$db": ctx.database, + } + } + ], + msg="removeQuerySettings should remove the setting by hash", + ), + SettingsTestCase( + "removes_distinct_shape", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "distinct": ctx.collection, + "key": "x", + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "removeQuerySettings": { + "distinct": ctx.collection, + "key": "x", + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "distinct": ctx.collection, + "key": "x", + "$db": ctx.database, + } + } + ], + msg="removeQuerySettings should remove the distinct setting", + ), + SettingsTestCase( + "removes_aggregate_shape", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"x": 1}}], + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "removeQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"x": 1}}], + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"x": 1}}], + "$db": ctx.database, + } + } + ], + msg="removeQuerySettings should remove the aggregate setting", + ), + SettingsTestCase( + "shape_ignores_filter_values", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"sm1": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sm1": 999}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sm1": 1}, + "$db": ctx.database, + } + } + ], + msg="shape matching should ignore filter values and remove the setting", + ), +] + + +@pytest.mark.admin +@pytest.mark.replica_set +@pytest.mark.parametrize("test", pytest_params(REMOVEQUERYSETTINGS_SETTING_REMOVED_TESTS)) +def test_removeQuerySettings_setting_removed(collection, test): + """Test that removeQuerySettings actually removes settings, verified via $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) + expected_hash = ctx.setup_results[0]["queryShapeHash"] + + execute_admin_command(collection, test.build_command(ctx)) + + admin = collection.database.client.admin + qs_result = admin.command( + {"aggregate": 1, "pipeline": [{"$querySettings": {}}], "cursor": {}} + ) + batch: list[dict[str, Any]] = qs_result.get("cursor", {}).get("firstBatch", []) + count = sum(1 for s in batch if s.get("queryShapeHash") == expected_hash) + assertSuccessPartial( + {"count": count}, + {"count": 0}, + msg=test.msg, + ) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass + + +# Property [Idempotent Removal]: calling removeQuerySettings a second time +# for the same query shape succeeds silently without error. +REMOVEQUERYSETTINGS_IDEMPOTENT_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "idempotent_removal", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"r3": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + }, + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"r3": 1}, + "$db": ctx.database, + } + }, + ], + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"r3": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"r3": 1}, + "$db": ctx.database, + } + } + ], + msg="removeQuerySettings should succeed silently on second removal", + ), +] + + +@pytest.mark.admin +@pytest.mark.replica_set +@pytest.mark.parametrize("test", pytest_params(REMOVEQUERYSETTINGS_IDEMPOTENT_TESTS)) +def test_removeQuerySettings_idempotent(collection, test): + """Test removeQuerySettings is idempotent on second call.""" + 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 [Shape Matching Includes Collection]: collection name is part of +# the query shape. Removing with a different collection does not affect the +# original setting. +# Property [Shape Matching Includes $db]: $db is part of the query shape. +# Removing with a different $db does not affect the original setting. +# Property [Shape Matching Includes Sort Direction]: sort direction is part +# of the query shape. Removing with a different sort direction does not +# affect the original setting. +# Property [Shape Matching Includes Extra Fields]: adding extra fields +# changes the query shape. Removing with extra fields does not affect the +# original filter-only setting. +REMOVEQUERYSETTINGS_SHAPE_PERSISTS_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "shape_collection_name_matters", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"sm2": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "removeQuerySettings": { + "find": "other_collection", + "filter": {"sm2": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sm2": 1}, + "$db": ctx.database, + } + } + ], + msg="removing with different collection should not affect original setting", + ), + SettingsTestCase( + "shape_db_matters", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"sm3": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sm3": 1}, + "$db": "other_database", + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sm3": 1}, + "$db": ctx.database, + } + } + ], + msg="removing with different $db should not affect original setting", + ), + SettingsTestCase( + "shape_sort_direction_matters", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"sm4": 1}, + "sort": {"sm4": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sm4": 1}, + "sort": {"sm4": -1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sm4": 1}, + "sort": {"sm4": 1}, + "$db": ctx.database, + } + } + ], + msg="removing with different sort direction should not affect original setting", + ), + SettingsTestCase( + "shape_extra_fields_change_shape", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"sm5": 1}, + "$db": ctx.database, + }, + "settings": { + "indexHints": [ + { + "ns": {"db": ctx.database, "coll": ctx.collection}, + "allowedIndexes": ["_id_"], + } + ], + }, + } + ], + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sm5": 1}, + "sort": {"sm5": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"sm5": 1}, + "$db": ctx.database, + } + } + ], + msg="removing with extra fields should not affect filter-only setting", + ), +] + + +@pytest.mark.admin +@pytest.mark.replica_set +@pytest.mark.parametrize("test", pytest_params(REMOVEQUERYSETTINGS_SHAPE_PERSISTS_TESTS)) +def test_removeQuerySettings_shape_persists(collection, test): + """Test that mismatched shapes do not remove original 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) + expected_hash = ctx.setup_results[0]["queryShapeHash"] + + execute_admin_command(collection, test.build_command(ctx)) + + admin = collection.database.client.admin + qs_result = admin.command( + {"aggregate": 1, "pipeline": [{"$querySettings": {}}], "cursor": {}} + ) + batch: list[dict[str, Any]] = qs_result.get("cursor", {}).get("firstBatch", []) + count = sum(1 for s in batch if s.get("queryShapeHash") == expected_hash) + assertSuccessPartial({"count": count}, {"count": 1}, msg=test.msg) + finally: + for cmd in test.build_cleanup(ctx): + try: + execute_admin_command(collection, cmd) + except Exception: + pass + + +# Property [Reject Removal Restores Query]: removing a reject: true setting +# allows the previously-rejected query to succeed again. +REMOVEQUERYSETTINGS_REJECT_REMOVAL_TESTS: list[SettingsTestCase] = [ + SettingsTestCase( + "reject_removal_restores_query", + setup_commands=lambda ctx: [ + { + "setQuerySettings": { + "find": ctx.collection, + "filter": {"rj1": 1}, + "$db": ctx.database, + }, + "settings": {"reject": True}, + } + ], + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"rj1": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + cleanup=lambda ctx: [ + { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"rj1": 1}, + "$db": ctx.database, + } + } + ], + msg="query should succeed after removing reject: true setting", + ), +] + + +@pytest.mark.admin +@pytest.mark.replica_set +@pytest.mark.parametrize("test", pytest_params(REMOVEQUERYSETTINGS_REJECT_REMOVAL_TESTS)) +def test_removeQuerySettings_reject_removal(collection, test): + """Test that removing reject: true setting restores the query.""" + ctx = CommandContext.from_collection(collection) + try: + for cmd in test.build_setup(ctx): + r = execute_admin_command(collection, cmd) + ctx.setup_results.append(r) + + # Remove the reject setting + execute_admin_command(collection, test.build_command(ctx)) + + # Verify query succeeds after removal + restored = execute_command(collection, {"find": ctx.collection, "filter": {"rj1": 1}}) + assertSuccessPartial(restored, {"ok": 1.0}, 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/removeQuerySettings/test_removeQuerySettings_core.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_core.py new file mode 100644 index 000000000..9dc0b6c6e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_core.py @@ -0,0 +1,312 @@ +"""Tests for removeQuerySettings command core acceptance behavior. + +Validates that the removeQuerySettings command accepts valid query shapes +for find, distinct, and aggregate commands, various shape variations, +$db field variations, hash-based removal, and idempotent behavior. +""" + +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 assertSuccessPartial +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.no_parallel] + +# Property [Find Shape Acceptance]: removeQuerySettings accepts find shapes +# with various field combinations without error. +REMOVEQUERYSETTINGS_FIND_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "accepts_find_filter_only", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"a": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept find with filter only", + ), + CommandTestCase( + "accepts_find_filter_sort", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"b": 1}, + "sort": {"b": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept find with filter and sort", + ), + CommandTestCase( + "accepts_find_filter_projection", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"c": 1}, + "projection": {"c": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept find with filter and projection", + ), + CommandTestCase( + "accepts_find_filter_sort_projection", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"d": 1}, + "sort": {"d": 1}, + "projection": {"d": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept find with all shape fields", + ), + CommandTestCase( + "accepts_find_with_collation", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"e": "abc"}, + "collation": {"locale": "en", "strength": 2}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept find with collation", + ), + CommandTestCase( + "accepts_find_with_let", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"$expr": {"$eq": ["$f", "$$target"]}}, + "let": {"target": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept find with let", + ), + CommandTestCase( + "accepts_find_without_filter", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept find without filter", + ), +] + +# Property [Distinct Shape Acceptance]: removeQuerySettings accepts distinct +# shapes with various field combinations without error. +REMOVEQUERYSETTINGS_DISTINCT_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "accepts_distinct_key_only", + command=lambda ctx: { + "removeQuerySettings": { + "distinct": ctx.collection, + "key": "j", + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept distinct with key only", + ), + CommandTestCase( + "accepts_distinct_key_with_query", + command=lambda ctx: { + "removeQuerySettings": { + "distinct": ctx.collection, + "key": "k", + "query": {"$and": [{"k": {"$gt": 0}}, {"k": {"$lt": 100}}]}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept distinct with query filter", + ), +] + +# Property [Aggregate Shape Acceptance]: removeQuerySettings accepts aggregate +# pipeline shapes with various stage combinations without error. +REMOVEQUERYSETTINGS_AGGREGATE_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "accepts_aggregate_single_stage", + command=lambda ctx: { + "removeQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"l": 1}}], + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept aggregate with single stage", + ), + CommandTestCase( + "accepts_aggregate_multi_stage", + command=lambda ctx: { + "removeQuerySettings": { + "aggregate": ctx.collection, + "pipeline": [ + {"$match": {"m": 1}}, + {"$group": {"_id": "$m", "count": {"$sum": 1}}}, + ], + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept aggregate with multiple stages", + ), +] + +# Property [Nonexistent $db Acceptance]: removeQuerySettings accepts +# non-existent database names in the $db field without error. +REMOVEQUERYSETTINGS_DB_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "accepts_nonexistent_db", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"o": 1}, + "$db": f"{ctx.database}_nonexistent", + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept non-existent $db", + ), +] + +# Property [Silent No-Op]: removeQuerySettings succeeds silently when +# no matching settings exist. +REMOVEQUERYSETTINGS_NOOP_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "noop_nonexistent_query_shape", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"nonexistent_field": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should succeed when no matching settings exist", + ), + CommandTestCase( + "noop_nonexistent_hash", + command=lambda ctx: { + "removeQuerySettings": "00000000000000000000000000000000" + "00000000000000000000000000000000" + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should succeed with a non-existent hash", + ), + CommandTestCase( + "noop_lowercase_hash", + command=lambda ctx: { + "removeQuerySettings": "abcdef0123456789abcdef0123456789" + "abcdef0123456789abcdef0123456789" + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept lowercase hex hash", + ), +] + +# Property [IDHACK Query Acceptance]: unlike setQuerySettings which rejects +# IDHACK-eligible queries, removeQuerySettings accepts them. +REMOVEQUERYSETTINGS_IDHACK_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "accepts_idhack_query", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"_id": 1}, + "$db": ctx.database, + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept IDHACK-eligible queries", + ), +] + +# Property [Internal Database Acceptance]: unlike setQuerySettings which +# rejects internal databases, removeQuerySettings accepts them. +REMOVEQUERYSETTINGS_INTERNAL_DB_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "accepts_admin_db", + command=lambda ctx: { + "removeQuerySettings": { + "find": "system.users", + "filter": {}, + "$db": "admin", + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept admin database query shapes", + ), + CommandTestCase( + "accepts_local_db", + command=lambda ctx: { + "removeQuerySettings": { + "find": "oplog.rs", + "filter": {}, + "$db": "local", + } + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept local database query shapes", + ), +] + +# Property [Comment Field Acceptance]: removeQuerySettings accepts the +# comment top-level field without error. +REMOVEQUERYSETTINGS_COMMENT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "accepts_comment_field", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"cmt1": 1}, + "$db": ctx.database, + }, + "comment": "test comment", + }, + expected={"ok": 1.0}, + msg="removeQuerySettings should accept comment field", + ), +] + +REMOVEQUERYSETTINGS_CORE_TESTS: list[CommandTestCase] = ( + REMOVEQUERYSETTINGS_FIND_ACCEPTANCE_TESTS + + REMOVEQUERYSETTINGS_DISTINCT_ACCEPTANCE_TESTS + + REMOVEQUERYSETTINGS_AGGREGATE_ACCEPTANCE_TESTS + + REMOVEQUERYSETTINGS_DB_ACCEPTANCE_TESTS + + REMOVEQUERYSETTINGS_NOOP_TESTS + + REMOVEQUERYSETTINGS_IDHACK_TESTS + + REMOVEQUERYSETTINGS_INTERNAL_DB_TESTS + + REMOVEQUERYSETTINGS_COMMENT_TESTS +) + + +@pytest.mark.replica_set +@pytest.mark.parametrize("test", pytest_params(REMOVEQUERYSETTINGS_CORE_TESTS)) +def test_removeQuerySettings_core(collection, test): + """Test removeQuerySettings command core acceptance behavior.""" + ctx = CommandContext.from_collection(collection) + result = execute_admin_command(collection, test.build_command(ctx)) + assertSuccessPartial(result, test.build_expected(ctx), msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_error.py b/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_error.py new file mode 100644 index 000000000..d9de6abdf --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/query_planning/commands/removeQuerySettings/test_removeQuerySettings_error.py @@ -0,0 +1,213 @@ +"""Tests for removeQuerySettings command error cases. + +Validates that the removeQuerySettings command rejects invalid BSON types for +the primary argument, malformed query shapes, invalid hash strings, and +unrecognized top-level 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 ( + BAD_VALUE_ERROR, + INVALID_LENGTH_ERROR, + INVALID_NAMESPACE_ERROR, + MISSING_FIELD_ERROR, + QUERYSETTINGS_UNKNOWN_COMMAND_SHAPE_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = [pytest.mark.no_parallel] + +# Property [Primary Argument Type Rejection]: the removeQuerySettings field +# must be a document or string. All other BSON types are rejected. +REMOVEQUERYSETTINGS_PRIMARY_ARG_TYPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"primary_arg_{tid}", + command=lambda ctx, v=value: {"removeQuerySettings": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"removeQuerySettings 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 [Query Shape Validation]: rejects malformed query shape documents +# including empty documents, missing/empty/null $db, and unknown command types. +REMOVEQUERYSETTINGS_QUERY_SHAPE_VALIDATION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "query_shape_empty_document", + command=lambda ctx: {"removeQuerySettings": {}}, + error_code=QUERYSETTINGS_UNKNOWN_COMMAND_SHAPE_ERROR, + msg="removeQuerySettings should reject empty query shape document", + ), + CommandTestCase( + "query_shape_missing_db", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + } + }, + error_code=MISSING_FIELD_ERROR, + msg="removeQuerySettings should reject query shape missing $db field", + ), + CommandTestCase( + "query_shape_empty_db", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": "", + } + }, + error_code=INVALID_NAMESPACE_ERROR, + msg="removeQuerySettings should reject query shape with empty $db", + ), + CommandTestCase( + "query_shape_null_db", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": None, + } + }, + error_code=MISSING_FIELD_ERROR, + msg="removeQuerySettings should reject query shape with null $db", + ), + CommandTestCase( + "query_shape_unknown_command", + command=lambda ctx: { + "removeQuerySettings": { + "unknownCommand": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + } + }, + error_code=QUERYSETTINGS_UNKNOWN_COMMAND_SHAPE_ERROR, + msg="removeQuerySettings should reject unknown command type in query shape", + ), + CommandTestCase( + "query_shape_no_command_type", + command=lambda ctx: { + "removeQuerySettings": { + "filter": {"x": 1}, + "$db": ctx.database, + } + }, + error_code=QUERYSETTINGS_UNKNOWN_COMMAND_SHAPE_ERROR, + msg="removeQuerySettings should reject query shape without a command type", + ), +] + +# Property [Hash String Validation]: rejects invalid hash string formats +# including empty, too short, too long, and non-hexadecimal strings. +REMOVEQUERYSETTINGS_HASH_VALIDATION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_hash_string", + command=lambda ctx: {"removeQuerySettings": ""}, + error_code=INVALID_LENGTH_ERROR, + msg="removeQuerySettings should reject empty hash string", + ), + CommandTestCase( + "short_hash_string", + command=lambda ctx: {"removeQuerySettings": "ABCD"}, + error_code=INVALID_LENGTH_ERROR, + msg="removeQuerySettings should reject short hash string", + ), + CommandTestCase( + "long_hash_string", + command=lambda ctx: {"removeQuerySettings": "AA" * 33}, + error_code=INVALID_LENGTH_ERROR, + msg="removeQuerySettings should reject hash string longer than 64 chars", + ), + CommandTestCase( + "non_hex_hash_string", + command=lambda ctx: { + "removeQuerySettings": "GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG" + "GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG" + }, + error_code=BAD_VALUE_ERROR, + msg="removeQuerySettings should reject non-hex hash string", + ), +] + +# Property [Unrecognized Fields]: rejects unknown top-level command fields +# and fields valid for setQuerySettings but not removeQuerySettings. +REMOVEQUERYSETTINGS_UNRECOGNIZED_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unrecognized_top_level_field", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "unknownField": 1, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="removeQuerySettings should reject unrecognized top-level field", + ), + CommandTestCase( + "settings_field_rejected", + command=lambda ctx: { + "removeQuerySettings": { + "find": ctx.collection, + "filter": {"x": 1}, + "$db": ctx.database, + }, + "settings": {"reject": True}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="removeQuerySettings should reject settings field", + ), +] + +REMOVEQUERYSETTINGS_ERROR_TESTS: list[CommandTestCase] = ( + REMOVEQUERYSETTINGS_PRIMARY_ARG_TYPE_TESTS + + REMOVEQUERYSETTINGS_QUERY_SHAPE_VALIDATION_TESTS + + REMOVEQUERYSETTINGS_HASH_VALIDATION_TESTS + + REMOVEQUERYSETTINGS_UNRECOGNIZED_FIELD_TESTS +) + + +@pytest.mark.replica_set +@pytest.mark.parametrize("test", pytest_params(REMOVEQUERYSETTINGS_ERROR_TESTS)) +def test_removeQuerySettings_error(collection, test): + """Test removeQuerySettings error cases.""" + 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/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/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/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..cf6d4e8ad 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -500,6 +500,7 @@ 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_NON_DOCUMENT_ARG_ERROR = 7746800 PIPELINE_LENGTH_LIMIT_ERROR = 7749501 PERCENTILE_INVALID_P_FIELD_ERROR = 7750301