From 0989bf9605c9b8563a9ae886cfa9320651eb0121 Mon Sep 17 00:00:00 2001 From: PatersonProjects Date: Fri, 19 Jun 2026 13:49:12 -0700 Subject: [PATCH 1/7] Parametrized tests using CommandTestCase, removed out of scope tests and duplicates, edited docStrings Signed-off-by: PatersonProjects --- .../diagnostic/commands/explain/__init__.py | 0 .../test_explain_argument_validation.py | 104 ++++++++ .../explain/test_explain_core_behavior.py | 251 ++++++++++++++++++ .../commands/explain/test_explain_errors.py | 93 +++++++ .../explain/test_explain_geo_queries.py | 98 +++++++ .../explain/test_explain_query_plans.py | 161 +++++++++++ .../test_explain_verbosity_and_response.py | 209 +++++++++++++++ .../explain/test_explain_write_operations.py | 145 ++++++++++ 8 files changed, 1061 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/__init__.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_argument_validation.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_core_behavior.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_errors.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_geo_queries.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_query_plans.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_verbosity_and_response.py create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_write_operations.py diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/__init__.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_argument_validation.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_argument_validation.py new file mode 100644 index 000000000..f5d965bcb --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_argument_validation.py @@ -0,0 +1,104 @@ +"""Tests for explain command argument validation. + +Covers the verbosity parameter (valid string modes and null, rejection of other +BSON types), the explain field (rejection of non-document types), and the +comment parameter (acceptance of any BSON type). +""" + +import pytest + +from documentdb_tests.framework.assertions import ( + assertFailureCode, + assertSuccessPartial, +) +from documentdb_tests.framework.bson_type_validator import ( + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.test_constants import BsonType + +pytestmark = pytest.mark.admin + + +VERBOSITY_SPEC = [ + BsonTypeTestCase( + id="verbosity", + msg=( + "verbosity should accept string modes and null, " + "and reject other types with TypeMismatch" + ), + keyword="verbosity", + valid_types=[BsonType.STRING, BsonType.NULL], + valid_inputs={BsonType.STRING: "queryPlanner"}, + default_error_code=TYPE_MISMATCH_ERROR, + ) +] +VERBOSITY_ACCEPTANCE_CASES = generate_bson_acceptance_test_cases(VERBOSITY_SPEC) +VERBOSITY_REJECTION_CASES = generate_bson_rejection_test_cases(VERBOSITY_SPEC) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", VERBOSITY_ACCEPTANCE_CASES) +def test_explain_accepts_valid_verbosity(collection, bson_type, sample_value, spec): + """Test explain accepts valid verbosity values (string mode and null default).""" + collection.insert_one({"_id": 1, "a": 1}) + result = execute_command( + collection, + {"explain": {"find": collection.name, "filter": {"a": 1}}, "verbosity": sample_value}, + ) + assertSuccessPartial(result, {"ok": 1.0}, msg=spec.msg) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", VERBOSITY_REJECTION_CASES) +def test_explain_rejects_non_string_verbosity(collection, bson_type, sample_value, spec): + """Test explain rejects non-string verbosity for every invalid BSON type.""" + collection.insert_one({"_id": 1, "a": 1}) + result = execute_command( + collection, + {"explain": {"find": collection.name, "filter": {"a": 1}}, "verbosity": sample_value}, + ) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) + + +EXPLAIN_ARGUMENT_TYPE_SPEC = [ + BsonTypeTestCase( + id="explain", + msg="explain field should reject non-document types with TypeMismatch", + keyword="explain", + valid_types=[BsonType.OBJECT], + default_error_code=TYPE_MISMATCH_ERROR, + ) +] +EXPLAIN_ARGUMENT_REJECTION_CASES = generate_bson_rejection_test_cases(EXPLAIN_ARGUMENT_TYPE_SPEC) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", EXPLAIN_ARGUMENT_REJECTION_CASES) +def test_explain_rejects_non_document_explain_field(collection, bson_type, sample_value, spec): + """Test explain rejects non-document values for the explain field.""" + result = execute_command( + collection, + {"explain": sample_value, "verbosity": "queryPlanner"}, + ) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) + + +COMMENT_TYPE_SPEC = [ + BsonTypeTestCase( + id="comment", + msg="comment should accept any BSON type", + keyword="comment", + valid_types=list(BsonType), + ) +] +COMMENT_ACCEPTANCE_CASES = generate_bson_acceptance_test_cases(COMMENT_TYPE_SPEC) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", COMMENT_ACCEPTANCE_CASES) +def test_explain_accepts_comment_type(collection, bson_type, sample_value, spec): + """Test explain accepts a comment of any BSON type at the explain level.""" + collection.insert_one({"_id": 1, "a": 1}) + cmd = {"explain": {"find": collection.name, "filter": {"a": 1}}, "comment": sample_value} + result = execute_command(collection, cmd) + assertSuccessPartial(result, {"ok": 1.0}, msg=spec.msg) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_core_behavior.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_core_behavior.py new file mode 100644 index 000000000..14528171d --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_core_behavior.py @@ -0,0 +1,251 @@ +"""Tests for explain command core behavior. + +Covers the explainable command surface (find, aggregate, count, distinct, +update, delete, findAndModify), edge cases (empty / non-existent collection, +complex and $expr filters, $lookup sub-pipeline), and plan-cache interaction +(explain does not create plan cache entries). +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult, assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Exists + +pytestmark = pytest.mark.admin + + +SUPPORTED_COMMAND_TESTS: list[CommandTestCase] = [ + CommandTestCase( + id="find", + docs=[{"_id": i, "a": i, "b": i} for i in range(5)], + command=lambda ctx: {"explain": {"find": ctx.collection, "filter": {"a": 1}}}, + expected={"ok": Eq(1.0)}, + msg="explain should plan the find command", + ), + CommandTestCase( + id="aggregate_single", + docs=[{"_id": i, "a": i, "b": i} for i in range(5)], + command=lambda ctx: { + "explain": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"a": 1}}], + "cursor": {}, + } + }, + expected={"ok": Eq(1.0)}, + msg="explain should plan the aggregate_single command", + ), + CommandTestCase( + id="aggregate_multi", + docs=[{"_id": i, "a": i, "b": i} for i in range(5)], + command=lambda ctx: { + "explain": { + "aggregate": ctx.collection, + "pipeline": [ + {"$match": {"a": {"$gt": 0}}}, + {"$group": {"_id": None, "c": {"$sum": 1}}}, + ], + "cursor": {}, + } + }, + expected={"ok": Eq(1.0)}, + msg="explain should plan the aggregate_multi command", + ), + CommandTestCase( + id="count", + docs=[{"_id": i, "a": i, "b": i} for i in range(5)], + command=lambda ctx: {"explain": {"count": ctx.collection, "query": {"a": 1}}}, + expected={"ok": Eq(1.0)}, + msg="explain should plan the count command", + ), + CommandTestCase( + id="distinct", + docs=[{"_id": i, "a": i, "b": i} for i in range(5)], + command=lambda ctx: {"explain": {"distinct": ctx.collection, "key": "a"}}, + expected={"ok": Eq(1.0)}, + msg="explain should plan the distinct command", + ), + CommandTestCase( + id="update_single", + docs=[{"_id": i, "a": i, "b": i} for i in range(5)], + command=lambda ctx: { + "explain": { + "update": ctx.collection, + "updates": [{"q": {"a": 1}, "u": {"$set": {"b": 9}}}], + } + }, + expected={"ok": Eq(1.0)}, + msg="explain should plan the update_single command", + ), + CommandTestCase( + id="update_multi", + docs=[{"_id": i, "a": i, "b": i} for i in range(5)], + command=lambda ctx: { + "explain": { + "update": ctx.collection, + "updates": [{"q": {"a": {"$gt": 0}}, "u": {"$set": {"b": 9}}, "multi": True}], + } + }, + expected={"ok": Eq(1.0)}, + msg="explain should plan the update_multi command", + ), + CommandTestCase( + id="delete_single", + docs=[{"_id": i, "a": i, "b": i} for i in range(5)], + command=lambda ctx: { + "explain": {"delete": ctx.collection, "deletes": [{"q": {"a": 1}, "limit": 1}]} + }, + expected={"ok": Eq(1.0)}, + msg="explain should plan the delete_single command", + ), + CommandTestCase( + id="delete_multi", + docs=[{"_id": i, "a": i, "b": i} for i in range(5)], + command=lambda ctx: { + "explain": { + "delete": ctx.collection, + "deletes": [{"q": {"a": {"$gt": 0}}, "limit": 0}], + } + }, + expected={"ok": Eq(1.0)}, + msg="explain should plan the delete_multi command", + ), + CommandTestCase( + id="findAndModify", + docs=[{"_id": i, "a": i, "b": i} for i in range(5)], + command=lambda ctx: { + "explain": { + "findAndModify": ctx.collection, + "query": {"a": 1}, + "update": {"$set": {"b": 9}}, + } + }, + expected={"ok": Eq(1.0)}, + msg="explain should plan the findAndModify command", + ), + CommandTestCase( + id="find_returns_query_planner", + docs=[{"_id": i, "a": i, "b": i} for i in range(5)], + command=lambda ctx: {"explain": {"find": ctx.collection, "filter": {"a": 1}}}, + expected={"queryPlanner": Exists()}, + msg="find explain has queryPlanner", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SUPPORTED_COMMAND_TESTS)) +def test_explain_supported_commands(collection, test): + """Test explain plans each supported command and exposes planner output.""" + collection = test.prepare(collection.database, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +EDGE_CASE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + id="empty_collection", + docs=[], + command=lambda ctx: {"explain": {"find": ctx.collection, "filter": {"a": 1}}}, + expected={"ok": Eq(1.0)}, + msg="explain on empty collection should succeed", + ), + CommandTestCase( + id="non_existent_collection", + command=lambda ctx: { + "explain": {"find": f"{ctx.collection}_nonexistent", "filter": {"a": 1}} + }, + expected={"ok": Eq(1.0)}, + msg="explain on non-existent collection should succeed", + ), + CommandTestCase( + id="complex_nested_query", + docs=[{"_id": i, "a": i, "b": i % 2} for i in range(10)], + command=lambda ctx: { + "explain": { + "find": ctx.collection, + "filter": { + "$and": [ + {"$or": [{"a": {"$lt": 3}}, {"a": {"$gt": 7}}]}, + {"$or": [{"b": 0}, {"b": 1}]}, + ] + }, + } + }, + expected={"ok": Eq(1.0)}, + msg="explain should plan a complex nested query", + ), + CommandTestCase( + id="expr_in_filter", + docs=[{"_id": i, "a": i, "b": i + 1} for i in range(5)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"$expr": {"$gt": ["$b", "$a"]}}} + }, + expected={"ok": Eq(1.0)}, + msg="explain should plan an $expr filter", + ), + CommandTestCase( + id="aggregate_lookup_subpipeline", + docs=[{"_id": i, "a": i} for i in range(5)], + command=lambda ctx: { + "explain": { + "aggregate": ctx.collection, + "pipeline": [ + { + "$lookup": { + "from": ctx.collection, + "let": {"av": "$a"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$a", "$$av"]}}}], + "as": "matches", + } + } + ], + "cursor": {}, + } + }, + expected={"ok": Eq(1.0)}, + msg="explain should plan a $lookup sub-pipeline", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(EDGE_CASE_TESTS)) +def test_explain_edge_cases(collection, test): + """Test explain succeeds across collection-state and filter edge cases.""" + collection = test.prepare(collection.database, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +def test_explain_does_not_create_plan_cache_entry(collection): + """Test explain does not create a plan cache entry for the planned query.""" + collection.insert_many([{"_id": i, "a": i % 5, "b": i % 3} for i in range(50)]) + collection.create_index([("a", 1)]) + collection.create_index([("a", 1), ("b", 1)]) + collection.database.command({"planCacheClear": collection.name}) + execute_command(collection, {"explain": {"find": collection.name, "filter": {"a": 2, "b": 1}}}) + + cache = execute_command( + collection, + {"aggregate": collection.name, "pipeline": [{"$planCacheStats": {}}], "cursor": {}}, + ) + assertSuccess(cache, [], msg="explain should not create plan cache entries") diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_errors.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_errors.py new file mode 100644 index 000000000..2474517c3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_errors.py @@ -0,0 +1,93 @@ +"""Tests for explain command error conditions. + +Covers invalid verbosity strings, invalid and non-explainable explain field +values, and geospatial explain errors (hinting a non-geo index with +$nearSphere). +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode, assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + COMMAND_NOT_FOUND_ERROR, + ILLEGAL_OPERATION_ERROR, + NO_QUERY_EXECUTION_PLANS_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.admin + +EXPLAIN_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + id="invalid_verbosity", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "notAMode", + }, + error_code=BAD_VALUE_ERROR, + msg="invalid verbosity string should be BadValue", + ), + CommandTestCase( + id="non_explainable_command", + command=lambda ctx: {"explain": {"insert": ctx.collection, "documents": [{"_id": 1}]}}, + error_code=ILLEGAL_OPERATION_ERROR, + msg="non-explainable command should error", + ), + CommandTestCase( + id="empty_explain_document", + command={"explain": {}}, + error_code=COMMAND_NOT_FOUND_ERROR, + msg="empty explain document should error", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(EXPLAIN_ERROR_TESTS)) +def test_explain_error_cases(collection, test): + """Test explain rejects invalid verbosity, non-explainable commands, + and malformed explain fields. + """ + collection = test.prepare(collection.database, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +@pytest.mark.geospatial +def test_explain_find_nearSphere_hint_non_geo_index_fails(collection): + """Test explain of a $nearSphere find hinting a non-geo index returns an error.""" + collection.create_index([("loc", "2dsphere")]) + collection.insert_many( + [ + {"_id": 0, "loc": {"type": "Point", "coordinates": [0, 0]}, "a": 1}, + {"_id": 1, "loc": {"type": "Point", "coordinates": [1, 1]}, "a": 2}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [2, 2]}, "a": 3}, + ] + ) + collection.create_index([("a", 1)]) + near = {"loc": {"$nearSphere": {"$geometry": {"type": "Point", "coordinates": [0, 0]}}}} + result = execute_command( + collection, + { + "explain": {"find": collection.name, "filter": near, "hint": {"a": 1}}, + "verbosity": "queryPlanner", + }, + ) + assertFailureCode( + result, + NO_QUERY_EXECUTION_PLANS_ERROR, + msg="$nearSphere with a non-geo index hint should fail (no viable plan)", + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_geo_queries.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_geo_queries.py new file mode 100644 index 000000000..0c8a70513 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_geo_queries.py @@ -0,0 +1,98 @@ +"""Tests for explain wiring with geospatial $nearSphere queries. + +These are wiring tests: they verify that explain plans $nearSphere queries +across command types (find, count, distinct, findAndModify). Comprehensive +geospatial parsing and semantics live under the geospatial feature directory; +here we only confirm explain delegates to the geo query path. +""" + +import pytest +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +pytestmark = [pytest.mark.admin, pytest.mark.geospatial] + + +NEAR = {"loc": {"$nearSphere": {"$geometry": {"type": "Point", "coordinates": [0, 0]}}}} + +GEO_DOCS = [ + {"_id": 0, "loc": {"type": "Point", "coordinates": [0, 0]}, "a": 1}, + {"_id": 1, "loc": {"type": "Point", "coordinates": [1, 1]}, "a": 2}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [2, 2]}, "a": 3}, +] +GEO_INDEXES = [IndexModel([("loc", "2dsphere")])] + + +NEARSPHERE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + id="find_basic", + docs=GEO_DOCS, + indexes=GEO_INDEXES, + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": NEAR}, + "verbosity": "queryPlanner", + }, + expected={"ok": Eq(1.0)}, + msg="explain find $nearSphere (basic) should plan", + ), + CommandTestCase( + id="count", + docs=GEO_DOCS, + indexes=GEO_INDEXES, + command=lambda ctx: { + "explain": {"count": ctx.collection, "query": NEAR}, + "verbosity": "queryPlanner", + }, + expected={"ok": Eq(1.0)}, + msg="explain count $nearSphere should plan", + ), + CommandTestCase( + id="distinct", + docs=GEO_DOCS, + indexes=GEO_INDEXES, + command=lambda ctx: { + "explain": {"distinct": ctx.collection, "key": "a", "query": NEAR}, + "verbosity": "queryPlanner", + }, + expected={"ok": Eq(1.0)}, + msg="explain distinct $nearSphere should plan", + ), + CommandTestCase( + id="findAndModify", + docs=GEO_DOCS, + indexes=GEO_INDEXES, + command=lambda ctx: { + "explain": { + "findAndModify": ctx.collection, + "query": NEAR, + "update": {"$set": {"visited": True}}, + }, + "verbosity": "queryPlanner", + }, + expected={"ok": Eq(1.0)}, + msg="explain findAndModify $nearSphere should plan", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(NEARSPHERE_TESTS)) +def test_explain_nearSphere_plans(collection, test): + """Test explain plans $nearSphere queries across command types.""" + collection = test.prepare(collection.database, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_query_plans.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_query_plans.py new file mode 100644 index 000000000..78869538f --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_query_plans.py @@ -0,0 +1,161 @@ +"""Tests for explain query-plan and index-usage output. + +Covers collection-scan vs index-scan plan selection, the plan change after an +index is created, aggregate query-planner output, and executionStats counts for +hinted queries and $limit pipelines (including after additional inserts). + +Assertions favor portable signals (nReturned, plan stage presence) over +engine-specific internal plan-node details. +""" + +import pytest +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import ( + assertProperties, + assertResult, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Exists + +pytestmark = pytest.mark.admin + + +def test_explain_uses_collscan_without_index(collection): + """Test explain reports COLLSCAN when no usable index exists.""" + collection.insert_many([{"_id": i, "a": i} for i in range(20)]) + result = execute_command(collection, {"explain": {"find": collection.name, "filter": {"a": 5}}}) + assertProperties( + result, + {"queryPlanner.winningPlan.stage": Eq("COLLSCAN")}, + raw_res=True, + msg="plan should be COLLSCAN before index creation", + ) + + +def test_explain_uses_ixscan_after_index_created(collection): + """Test explain switches to IXSCAN after a matching index is created.""" + collection.insert_many([{"_id": i, "a": i} for i in range(20)]) + collection.create_index([("a", 1)]) + result = execute_command(collection, {"explain": {"find": collection.name, "filter": {"a": 5}}}) + assertProperties( + result, + {"queryPlanner.winningPlan.stage": Eq("IXSCAN")}, + raw_res=True, + msg="plan should switch to IXSCAN after index creation", + ) + + +def test_explain_aggregate_contains_query_planner(collection): + """Test explain for an aggregate returns a queryPlanner section.""" + collection.insert_many([{"_id": i, "a": i % 3} for i in range(20)]) + result = execute_command( + collection, + { + "explain": { + "aggregate": collection.name, + "pipeline": [{"$match": {"a": 1}}], + "cursor": {}, + } + }, + ) + assertProperties( + result, + {"queryPlanner": Exists()}, + raw_res=True, + msg="aggregate explain should contain a queryPlanner section", + ) + + +EXEC_STATS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + id="hint_nReturned", + docs=[{"_id": i, "a": i, "b": i % 2} for i in range(10)], + indexes=[IndexModel([("a", 1)])], + command=lambda ctx: { + "explain": { + "find": ctx.collection, + "filter": {"a": {"$gte": 0}}, + "sort": {"a": 1}, + "hint": {"a": 1}, + }, + "verbosity": "executionStats", + }, + expected={"executionStats.nReturned": Eq(10)}, + msg="explain with hint should report correct nReturned", + ), + CommandTestCase( + id="limit_executionStats_nReturned", + docs=[{"_id": i, "a": i} for i in range(20)], + command=lambda ctx: { + "explain": {"aggregate": ctx.collection, "pipeline": [{"$limit": 5}], "cursor": {}}, + "verbosity": "executionStats", + }, + expected={"executionStats.nReturned": Eq(5)}, + msg="executionStats nReturned should match the limit", + ), + CommandTestCase( + id="limit_allPlansExecution_nReturned", + docs=[{"_id": i, "a": i} for i in range(20)], + command=lambda ctx: { + "explain": {"aggregate": ctx.collection, "pipeline": [{"$limit": 5}], "cursor": {}}, + "verbosity": "allPlansExecution", + }, + expected={"executionStats.nReturned": Eq(5)}, + msg="allPlansExecution should honor the limit", + ), + CommandTestCase( + id="limit_multiple_indexes_nReturned", + docs=[{"_id": i, "a": i % 5, "b": i % 3} for i in range(60)], + indexes=[IndexModel([("a", 1)]), IndexModel([("a", 1), ("b", 1)])], + command=lambda ctx: { + "explain": { + "aggregate": ctx.collection, + "pipeline": [{"$match": {"a": 2, "b": 1}}, {"$limit": 3}], + "cursor": {}, + }, + "verbosity": "executionStats", + }, + expected={"executionStats.nReturned": Eq(3)}, + msg="limit should be honored with multiple candidate indexes", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(EXEC_STATS_TESTS)) +def test_explain_execution_stats(collection, test): + """Test explain executionStats reports counts matching hints and limits.""" + collection = test.prepare(collection.database, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +def test_explain_results_update_after_inserts(collection): + """Test explain executionStats reflects newly inserted documents.""" + collection.insert_many([{"_id": i, "a": i} for i in range(10)]) + collection.create_index([("a", 1)]) + query = { + "explain": {"find": collection.name, "filter": {"a": {"$gte": 0}}, "hint": {"a": 1}}, + "verbosity": "executionStats", + } + execute_command(collection, query) + collection.insert_many([{"_id": i, "a": i} for i in range(10, 15)]) + after = execute_command(collection, query) + assertProperties( + after, + {"executionStats.nReturned": Eq(15)}, + raw_res=True, + msg="explain should reflect additional inserted documents", + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_verbosity_and_response.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_verbosity_and_response.py new file mode 100644 index 000000000..859a23a19 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_verbosity_and_response.py @@ -0,0 +1,209 @@ +"""Tests for explain verbosity modes and response structure. + +Covers the response sections produced by each verbosity mode (queryPlanner, +executionStats, allPlansExecution) and the behavioral differences between modes +(query execution and rejected-plan statistics). +""" + +import pytest +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Exists, IsType, NotExists + +pytestmark = pytest.mark.admin + + +RESPONSE_FIELD_TESTS: list[CommandTestCase] = [ + CommandTestCase( + id="queryPlanner_root", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "queryPlanner", + }, + expected={"queryPlanner": Exists()}, + msg="queryPlanner should contain queryPlanner", + ), + CommandTestCase( + id="queryPlanner_parsedQuery", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "queryPlanner", + }, + expected={"queryPlanner.parsedQuery": Exists()}, + msg="queryPlanner should contain queryPlanner.parsedQuery", + ), + CommandTestCase( + id="queryPlanner_winningPlan", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "queryPlanner", + }, + expected={"queryPlanner.winningPlan": Exists()}, + msg="queryPlanner should contain queryPlanner.winningPlan", + ), + CommandTestCase( + id="queryPlanner_namespace", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "queryPlanner", + }, + expected={"queryPlanner.namespace": IsType("string")}, + msg="queryPlanner should contain queryPlanner.namespace", + ), + CommandTestCase( + id="executionStats_root", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "executionStats", + }, + expected={"executionStats": Exists()}, + msg="executionStats should contain executionStats", + ), + CommandTestCase( + id="executionStats_executionSuccess", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "executionStats", + }, + expected={"executionStats.executionSuccess": IsType("bool")}, + msg="executionStats should contain executionStats.executionSuccess", + ), + CommandTestCase( + id="executionStats_nReturned", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "executionStats", + }, + expected={"executionStats.nReturned": IsType("int")}, + msg="executionStats should contain executionStats.nReturned", + ), + CommandTestCase( + id="executionStats_executionTimeMillis", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "executionStats", + }, + expected={"executionStats.executionTimeMillis": Exists()}, + msg="executionStats should contain executionStats.executionTimeMillis", + ), + CommandTestCase( + id="executionStats_totalKeysExamined", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "executionStats", + }, + expected={"executionStats.totalKeysExamined": IsType("int")}, + msg="executionStats should contain executionStats.totalKeysExamined", + ), + CommandTestCase( + id="executionStats_totalDocsExamined", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "executionStats", + }, + expected={"executionStats.totalDocsExamined": IsType("int")}, + msg="executionStats should contain executionStats.totalDocsExamined", + ), + CommandTestCase( + id="allPlans_queryPlanner", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "allPlansExecution", + }, + expected={"queryPlanner": Exists()}, + msg="allPlansExecution should contain queryPlanner", + ), + CommandTestCase( + id="allPlans_executionStats", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "allPlansExecution", + }, + expected={"executionStats": Exists()}, + msg="allPlansExecution should contain executionStats", + ), + CommandTestCase( + id="allPlans_rejectedPlans", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "allPlansExecution", + }, + expected={"queryPlanner.rejectedPlans": IsType("array")}, + msg="allPlansExecution should contain queryPlanner.rejectedPlans", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(RESPONSE_FIELD_TESTS)) +def test_explain_response_contains_field(collection, test): + """Test each verbosity-mode response contains the expected response field.""" + collection = test.prepare(collection.database, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) + + +MODE_BEHAVIOR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + id="queryPlanner_omits_executionStats", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + "verbosity": "queryPlanner", + }, + expected={"executionStats": NotExists()}, + msg="queryPlanner must not execute", + ), + CommandTestCase( + id="executionStats_excludes_rejected_plan_stats", + docs=[{"_id": i, "a": i % 5, "b": i % 3} for i in range(60)], + indexes=[IndexModel([("a", 1)]), IndexModel([("a", 1), ("b", 1)])], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 2, "b": 1}}, + "verbosity": "executionStats", + }, + expected={"executionStats.allPlansExecution": NotExists()}, + msg="executionStats mode should not include rejected-plan execution", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(MODE_BEHAVIOR_TESTS)) +def test_explain_verbosity_mode_behavior(collection, test): + """Test verbosity modes include/exclude execution sections as documented.""" + collection = test.prepare(collection.database, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_write_operations.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_write_operations.py new file mode 100644 index 000000000..2a44e7b8b --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_write_operations.py @@ -0,0 +1,145 @@ +"""Tests for explain on write operations. + +Covers that explain does not modify data for update / delete / findAndModify, +and that it reports "would" statistics (nWouldModify, nWouldDelete) and the +DELETE execution stage. +""" + +import pytest +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult, assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +pytestmark = pytest.mark.admin + + +def test_explain_update_does_not_modify_documents(collection): + """Test explain on an update does not apply the modification.""" + collection.insert_one({"_id": 1, "a": 1, "b": 1}) + execute_command( + collection, + { + "explain": { + "update": collection.name, + "updates": [{"q": {"a": 1}, "u": {"$set": {"b": 999}}}], + } + }, + ) + after = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess( + after, [{"_id": 1, "a": 1, "b": 1}], msg="explain update must not modify documents" + ) + + +def test_explain_delete_does_not_remove_documents(collection): + """Test explain on a delete does not remove any documents.""" + collection.insert_many([{"_id": i} for i in range(5)]) + execute_command( + collection, {"explain": {"delete": collection.name, "deletes": [{"q": {}, "limit": 0}]}} + ) + after = execute_command(collection, {"find": collection.name, "filter": {}}) + assertSuccess( + after, + [{"_id": i} for i in range(5)], + msg="explain delete must not remove documents", + ignore_doc_order=True, + ) + + +def test_explain_findAndModify_does_not_modify_documents(collection): + """Test explain on a findAndModify does not apply the modification.""" + collection.insert_one({"_id": 1, "a": 1}) + execute_command( + collection, + { + "explain": { + "findAndModify": collection.name, + "query": {"a": 1}, + "update": {"$set": {"a": 2}}, + } + }, + ) + after = execute_command(collection, {"find": collection.name, "filter": {"_id": 1}}) + assertSuccess( + after, [{"_id": 1, "a": 1}], msg="explain findAndModify must not modify documents" + ) + + +WOULD_STATS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + id="update_would_modify_count", + docs=[{"_id": i, "a": i % 2} for i in range(6)], + command=lambda ctx: { + "explain": { + "update": ctx.collection, + "updates": [{"q": {"a": 1}, "u": {"$set": {"b": 9}}, "multi": True}], + }, + "verbosity": "executionStats", + }, + expected={"executionStats.executionStages.nWouldModify": Eq(3)}, + msg="explain update should report nWouldModify", + ), + CommandTestCase( + id="delete_empty_collection_reports_zero", + docs=[], + command=lambda ctx: { + "explain": {"delete": ctx.collection, "deletes": [{"q": {}, "limit": 0}]}, + "verbosity": "executionStats", + }, + expected={"executionStats.executionStages.nWouldDelete": Eq(0)}, + msg="empty collection should report nWouldDelete 0", + ), + CommandTestCase( + id="delete_empty_indexed_collection_reports_zero", + docs=[], + indexes=[IndexModel([("a", 1)], name="a_1")], + command=lambda ctx: { + "explain": {"delete": ctx.collection, "deletes": [{"q": {"a": 1}, "limit": 0}]}, + "verbosity": "executionStats", + }, + expected={"executionStats.executionStages.nWouldDelete": Eq(0)}, + msg="empty indexed collection should report nWouldDelete 0", + ), + CommandTestCase( + id="delete_reports_matching_count", + docs=[{"_id": i, "a": 1 if i < 3 else 2} for i in range(5)], + command=lambda ctx: { + "explain": {"delete": ctx.collection, "deletes": [{"q": {"a": 1}, "limit": 0}]}, + "verbosity": "executionStats", + }, + expected={"executionStats.executionStages.nWouldDelete": Eq(3)}, + msg="should report nWouldDelete matching the query", + ), + CommandTestCase( + id="delete_shows_delete_stage", + docs=[{"_id": i, "a": 1} for i in range(3)], + command=lambda ctx: { + "explain": {"delete": ctx.collection, "deletes": [{"q": {"a": 1}, "limit": 1}]}, + "verbosity": "executionStats", + }, + expected={"executionStats.executionStages.stage": Eq("DELETE")}, + msg="explain single delete should show a DELETE stage", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(WOULD_STATS_TESTS)) +def test_explain_write_would_stats(collection, test): + """Test explain on writes reports would-modify/would-delete stats and stages.""" + collection = test.prepare(collection.database, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) From e91fba23fcf4beabcb646616edabbd126ae9b442 Mon Sep 17 00:00:00 2001 From: PatersonProjects Date: Fri, 19 Jun 2026 15:25:11 -0700 Subject: [PATCH 2/7] Added error override to bson test, Changed test to avoid conflicting case return Signed-off-by: PatersonProjects --- .../explain/test_explain_argument_validation.py | 3 ++- .../commands/explain/test_explain_query_plans.py | 10 +++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_argument_validation.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_argument_validation.py index f5d965bcb..a4c638efb 100644 --- a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_argument_validation.py +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_argument_validation.py @@ -16,7 +16,7 @@ generate_bson_acceptance_test_cases, generate_bson_rejection_test_cases, ) -from documentdb_tests.framework.error_codes import TYPE_MISMATCH_ERROR +from documentdb_tests.framework.error_codes import MISSING_FIELD_ERROR, TYPE_MISMATCH_ERROR from documentdb_tests.framework.executor import execute_command from documentdb_tests.framework.test_constants import BsonType @@ -69,6 +69,7 @@ def test_explain_rejects_non_string_verbosity(collection, bson_type, sample_valu keyword="explain", valid_types=[BsonType.OBJECT], default_error_code=TYPE_MISMATCH_ERROR, + error_code_overrides={BsonType.NULL: MISSING_FIELD_ERROR}, ) ] EXPLAIN_ARGUMENT_REJECTION_CASES = generate_bson_rejection_test_cases(EXPLAIN_ARGUMENT_TYPE_SPEC) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_query_plans.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_query_plans.py index 78869538f..a1c338acc 100644 --- a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_query_plans.py +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_query_plans.py @@ -21,7 +21,7 @@ ) from documentdb_tests.framework.executor import execute_command from documentdb_tests.framework.parametrize import pytest_params -from documentdb_tests.framework.property_checks import Eq, Exists +from documentdb_tests.framework.property_checks import Eq, Exists, Ne pytestmark = pytest.mark.admin @@ -38,16 +38,16 @@ def test_explain_uses_collscan_without_index(collection): ) -def test_explain_uses_ixscan_after_index_created(collection): - """Test explain switches to IXSCAN after a matching index is created.""" +def test_explain_avoids_collscan_after_index_created(collection): + """Test explain stops using COLLSCAN after a matching index is created.""" collection.insert_many([{"_id": i, "a": i} for i in range(20)]) collection.create_index([("a", 1)]) result = execute_command(collection, {"explain": {"find": collection.name, "filter": {"a": 5}}}) assertProperties( result, - {"queryPlanner.winningPlan.stage": Eq("IXSCAN")}, + {"queryPlanner.winningPlan.stage": Ne("COLLSCAN")}, raw_res=True, - msg="plan should switch to IXSCAN after index creation", + msg="plan should use an index (not COLLSCAN) after index creation", ) From a7a8afcae5cc133d08256b72f16d9cdaa88e93a2 Mon Sep 17 00:00:00 2001 From: PatersonProjects Date: Thu, 25 Jun 2026 11:39:49 -0700 Subject: [PATCH 3/7] Addressed PR Comments Signed-off-by: PatersonProjects --- .../commands/explain/test_explain_errors.py | 16 +++++++++++++++- .../test_explain_verbosity_and_response.py | 9 +++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_errors.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_errors.py index 2474517c3..e2ac9e3f5 100644 --- a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_errors.py +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_errors.py @@ -1,7 +1,8 @@ """Tests for explain command error conditions. Covers invalid verbosity strings, invalid and non-explainable explain field -values, and geospatial explain errors (hinting a non-geo index with +values, explain of an aggregate with a $out write stage at executionStats +verbosity, and geospatial explain errors (hinting a non-geo index with $nearSphere). """ @@ -46,6 +47,19 @@ error_code=COMMAND_NOT_FOUND_ERROR, msg="empty explain document should error", ), + CommandTestCase( + id="aggregate_out_execution_stats", + command=lambda ctx: { + "explain": { + "aggregate": ctx.collection, + "pipeline": [{"$out": f"{ctx.collection}_out"}], + "cursor": {}, + }, + "verbosity": "executionStats", + }, + error_code=ILLEGAL_OPERATION_ERROR, + msg="explain with $out stage at executionStats verbosity should error", + ), ] diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_verbosity_and_response.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_verbosity_and_response.py index 859a23a19..5f7f5de0c 100644 --- a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_verbosity_and_response.py +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_verbosity_and_response.py @@ -191,6 +191,15 @@ def test_explain_response_contains_field(collection, test): expected={"executionStats.allPlansExecution": NotExists()}, msg="executionStats mode should not include rejected-plan execution", ), + CommandTestCase( + id="default_verbosity_matches_allPlansExecution", + docs=[{"_id": i, "a": i % 5} for i in range(20)], + command=lambda ctx: { + "explain": {"find": ctx.collection, "filter": {"a": 1}}, + }, + expected={"queryPlanner": Exists(), "executionStats": Exists()}, + msg="default verbosity should include both queryPlanner and executionStats", + ), ] From 45f08a12cbcab2f2d0e80d3bfa246ab38622b403 Mon Sep 17 00:00:00 2001 From: PatersonProjects Date: Thu, 25 Jun 2026 12:57:37 -0700 Subject: [PATCH 4/7] Dropped aggregation test due to misleading docs Signed-off-by: PatersonProjects --- .../commands/explain/test_explain_errors.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_errors.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_errors.py index e2ac9e3f5..2474517c3 100644 --- a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_errors.py +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_errors.py @@ -1,8 +1,7 @@ """Tests for explain command error conditions. Covers invalid verbosity strings, invalid and non-explainable explain field -values, explain of an aggregate with a $out write stage at executionStats -verbosity, and geospatial explain errors (hinting a non-geo index with +values, and geospatial explain errors (hinting a non-geo index with $nearSphere). """ @@ -47,19 +46,6 @@ error_code=COMMAND_NOT_FOUND_ERROR, msg="empty explain document should error", ), - CommandTestCase( - id="aggregate_out_execution_stats", - command=lambda ctx: { - "explain": { - "aggregate": ctx.collection, - "pipeline": [{"$out": f"{ctx.collection}_out"}], - "cursor": {}, - }, - "verbosity": "executionStats", - }, - error_code=ILLEGAL_OPERATION_ERROR, - msg="explain with $out stage at executionStats verbosity should error", - ), ] From 5cdf624eed3513d3b34d9d914f058a31e8283244 Mon Sep 17 00:00:00 2001 From: PatersonProjects Date: Thu, 25 Jun 2026 16:45:35 -0700 Subject: [PATCH 5/7] Added positive out test case Signed-off-by: PatersonProjects --- .../test_explain_verbosity_and_response.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_verbosity_and_response.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_verbosity_and_response.py index 5f7f5de0c..fc12d9f84 100644 --- a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_verbosity_and_response.py +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_verbosity_and_response.py @@ -151,6 +151,31 @@ expected={"queryPlanner.rejectedPlans": IsType("array")}, msg="allPlansExecution should contain queryPlanner.rejectedPlans", ), + # Documentation states explain cannot be run with $out at + # executionStats verbosity, but observed behaviour is that the aggregate + # explain succeeds: planner/execution stats are nested under the $cursor + # stage and the $out stage is reported in the plan, while its write is + # silently dropped (not executed), so the output collection is never created. + CommandTestCase( + id="aggregate_out_execution_stats_silently_drops_out", + command=lambda ctx: { + "explain": { + "aggregate": ctx.collection, + "pipeline": [{"$out": f"{ctx.collection}_out"}], + "cursor": {}, + }, + "verbosity": "executionStats", + }, + expected={ + "stages.0.$cursor.queryPlanner": Exists(), + "stages.0.$cursor.executionStats": Exists(), + "stages.1.$out": Exists(), + }, + msg=( + "explain with $out at executionStats verbosity should succeed;" + " $out is silently dropped" + ), + ), ] From 1edcae27bc02d14061d163a715d299c85d97c741 Mon Sep 17 00:00:00 2001 From: PatersonProjects Date: Thu, 2 Jul 2026 16:50:38 -0700 Subject: [PATCH 6/7] Added queryHash shape test file Signed-off-by: PatersonProjects --- .../explain/test_explain_queryhash_shape.py | 596 ++++++++++++++++++ 1 file changed, 596 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_queryhash_shape.py diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_queryhash_shape.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_queryhash_shape.py new file mode 100644 index 000000000..c4890dcc8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_queryhash_shape.py @@ -0,0 +1,596 @@ +"""Tests for explain queryPlanner.queryHash shape equivalence. + +Parametrized over (queryA, queryB, same_shape) triples. For each pair the test +runs explain on both queries and asserts that their queryPlanner.queryHash +values either match (same_shape=True) or differ (same_shape=False). +""" + +from dataclasses import dataclass +from typing import Any, Optional + +import pytest + +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_case import BaseTestCase + +pytestmark = pytest.mark.admin + + +def get_query_hash( + collection, + find_filter: dict, + sort: Optional[dict] = None, + projection: Optional[dict] = None, + collation: Optional[dict] = None, +) -> str: + """Return the queryPlanner.queryHash from explain for a find filter.""" + cmd: dict[str, Any] = {"find": collection.name, "filter": find_filter} + if sort is not None: + cmd["sort"] = sort + if projection is not None: + cmd["projection"] = projection + if collation is not None: + cmd["collation"] = collation + result = execute_command(collection, {"explain": cmd, "verbosity": "queryPlanner"}) + if isinstance(result, Exception): + raise RuntimeError(f"explain command failed: {result}") + query_planner = result.get("queryPlanner", {}) + if "queryHash" not in query_planner: + raise RuntimeError(f"explain did not return queryPlanner.queryHash; got: {result}") + return str(query_planner["queryHash"]) + + +@dataclass(frozen=True) +class QueryHashCase(BaseTestCase): + """Test case comparing the queryHash of two find queries.""" + + filter_a: Optional[dict] = None + filter_b: Optional[dict] = None + sort_a: Optional[dict] = None + sort_b: Optional[dict] = None + projection_a: Optional[dict] = None + projection_b: Optional[dict] = None + collation_a: Optional[dict] = None + collation_b: Optional[dict] = None + + +SAME_SHAPE_TESTS: list[QueryHashCase] = [ + QueryHashCase( + id="literal_value_scalar", + filter_a={"a": {"$eq": 1}}, + filter_b={"a": {"$eq": 3}}, + msg="different scalar values with same operator should share a query shape", + ), + QueryHashCase( + id="literal_value_array", + filter_a={"a": {"$in": [1, 2, 3]}}, + filter_b={"a": {"$in": [4, 5, 6]}}, + msg="$in with same array length but different values should share a query shape", + ), + QueryHashCase( + id="literal_value_document", + filter_a={"a": {"$gt": 1}}, + filter_b={"a": {"$gt": 4}}, + msg="$gt with different operands should share a query shape", + ), + QueryHashCase( + id="implicit_explicit_eq", + filter_a={"a": 1}, + filter_b={"a": {"$eq": 1}}, + msg="implicit equality and explicit $eq should share a query shape", + ), + QueryHashCase( + id="implicit_explicit_and", + filter_a={"a": 1, "b": 2}, + filter_b={"$and": [{"a": 1}, {"b": 2}]}, + msg="implicit multi-field filter and explicit $and should share a query shape", + ), + QueryHashCase( + id="field_clause_order", + filter_a={"a": 1, "b": 2}, + filter_b={"b": 2, "a": 1}, + msg="reversed top-level field order should share a query shape", + ), + QueryHashCase( + id="and_clause_order", + filter_a={"$and": [{"a": 1}, {"b": 2}]}, + filter_b={"$and": [{"b": 2}, {"a": 1}]}, + msg="reversed $and clause order should share a query shape", + ), + QueryHashCase( + id="comparison_operand_variation_gt", + filter_a={"a": {"$gt": 0}}, + filter_b={"a": {"$gt": 10}}, + msg="$gt with different operands should share a query shape", + ), + QueryHashCase( + id="comparison_operand_variation_lte", + filter_a={"a": {"$lte": 2}}, + filter_b={"a": {"$lte": 4}}, + msg="$lte with different operands should share a query shape", + ), + QueryHashCase( + id="in_value_variation", + filter_a={"a": {"$in": [1, 2]}}, + filter_b={"a": {"$in": [3, 4]}}, + msg="$in with same length but different values should share a query shape", + ), + QueryHashCase( + id="nin_value_variation", + filter_a={"a": {"$nin": [1, 2]}}, + filter_b={"a": {"$nin": [3, 4]}}, + msg="$nin with same length but different values should share a query shape", + ), + QueryHashCase( + id="all_value_variation", + filter_a={"arr": {"$all": [1, 2]}}, + filter_b={"arr": {"$all": [3, 4]}}, + msg="$all with same length but different values should share a query shape", + ), + QueryHashCase( + id="projection_field_order", + filter_a={"a": 1}, + filter_b={"a": 1}, + projection_a={"a": 1, "b": 1}, + projection_b={"b": 1, "a": 1}, + msg="same projection fields in different order should share a query shape", + ), + QueryHashCase( + id="not_operand_variation", + filter_a={"a": {"$not": {"$gt": 1}}}, + filter_b={"a": {"$not": {"$gt": 3}}}, + msg="$not with different inner operands should share a query shape", + ), + QueryHashCase( + id="mod_argument_variation", + filter_a={"a": {"$mod": [2, 0]}}, + filter_b={"a": {"$mod": [3, 1]}}, + msg="$mod with different divisor/remainder should share a query shape", + ), + QueryHashCase( + id="size_argument_variation", + filter_a={"arr": {"$size": 2}}, + filter_b={"arr": {"$size": 3}}, + msg="$size with different values should share a query shape", + ), + QueryHashCase( + id="elemMatch_inner_operand_variation", + filter_a={"arr": {"$elemMatch": {"$gt": 0}}}, + filter_b={"arr": {"$elemMatch": {"$gt": 5}}}, + msg="$elemMatch with different inner operands should share a query shape", + ), + QueryHashCase( + id="expr_arithmetic_operand_variation", + filter_a={"$expr": {"$gt": ["$a", 1]}}, + filter_b={"$expr": {"$gt": ["$a", 3]}}, + msg="$expr with different literal operands should share a query shape", + ), + QueryHashCase( + id="or_value_variation", + filter_a={"$or": [{"a": 1}, {"b": 2}]}, + filter_b={"$or": [{"a": 3}, {"b": 4}]}, + msg="$or with different operand values should share a query shape", + ), + QueryHashCase( + id="nor_value_variation", + filter_a={"$nor": [{"a": 1}, {"b": 2}]}, + filter_b={"$nor": [{"a": 3}, {"b": 4}]}, + msg="$nor with different operand values should share a query shape", + ), + QueryHashCase( + id="empty_filter", + filter_a={}, + filter_b={}, + msg="two empty filters should share a query shape", + ), + QueryHashCase( + id="dot_notation_value_variation", + filter_a={"a.b": 1}, + filter_b={"a.b": 2}, + msg="dot-notation field path with different values should share a query shape", + ), + QueryHashCase( + id="comparison_operand_variation_gte", + filter_a={"a": {"$gte": 1}}, + filter_b={"a": {"$gte": 4}}, + msg="$gte with different operands should share a query shape", + ), + QueryHashCase( + id="comparison_operand_variation_lt", + filter_a={"a": {"$lt": 3}}, + filter_b={"a": {"$lt": 7}}, + msg="$lt with different operands should share a query shape", + ), + QueryHashCase( + id="comparison_operand_variation_ne", + filter_a={"a": {"$ne": 1}}, + filter_b={"a": {"$ne": 4}}, + msg="$ne with different operands should share a query shape", + ), + QueryHashCase( + id="in_element_order_variation", + filter_a={"a": {"$in": [1, 2, 3]}}, + filter_b={"a": {"$in": [3, 1, 2]}}, + msg="$in with same elements in different order should share a query shape", + ), + QueryHashCase( + id="sort_absent_vs_no_sort", + filter_a={"a": 1}, + filter_b={"a": 2}, + msg="same filter structure with no sort and different values should share a query shape", + ), + QueryHashCase( + id="collation_strength_variation", + filter_a={"s": "hello"}, + filter_b={"s": "world"}, + collation_a={"locale": "en", "strength": 1}, + collation_b={"locale": "en", "strength": 1}, + msg="same collation locale and strength with different values should share a query shape", + ), + QueryHashCase( + id="regex_pattern_variation", + filter_a={"s": {"$regex": "^1"}}, + filter_b={"s": {"$regex": "^2"}}, + msg="different $regex patterns are treated as literals and share a query shape", + ), + QueryHashCase( + id="expr_operator_variation", + filter_a={"$expr": {"$gt": ["$a", "$b"]}}, + filter_b={"$expr": {"$lt": ["$a", "$b"]}}, + msg="$expr operators are not part of the shape; different operators share a query shape", + ), + QueryHashCase( + id="type_argument_variation", + filter_a={"a": {"$type": "int"}}, + filter_b={"a": {"$type": "string"}}, + msg="$type argument is treated as a literal and shares a query shape", + ), + QueryHashCase( + id="type_array_vs_string", + filter_a={"a": {"$type": ["int"]}}, + filter_b={"a": {"$type": "int"}}, + msg="$type array form vs string form shares a query shape", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SAME_SHAPE_TESTS)) +def test_explain_queryhash_same_shape(collection, test): + """Test that structurally equivalent queries produce the same queryPlanner.queryHash.""" + collection.insert_many( + [{"_id": i, "a": i % 5, "b": i % 3, "arr": [i, i + 1], "s": str(i)} for i in range(20)] + ) + hash_a = get_query_hash( + collection, test.filter_a, test.sort_a, test.projection_a, test.collation_a + ) + hash_b = get_query_hash( + collection, test.filter_b, test.sort_b, test.projection_b, test.collation_b + ) + assertSuccess( + {"queryHash": hash_a}, + {"queryHash": hash_b}, + msg=test.msg, + raw_res=True, + ) + + +DIFF_SHAPE_TESTS: list[QueryHashCase] = [ + QueryHashCase( + id="different_field_names", + filter_a={"a": 1}, + filter_b={"b": 1}, + msg="queries on different fields should have different query shapes", + ), + QueryHashCase( + id="comparison_operator_variation", + filter_a={"a": {"$gt": 1}}, + filter_b={"a": {"$lt": 1}}, + msg="different comparison operators on the same field should have different query shapes", + ), + QueryHashCase( + id="in_vs_nin", + filter_a={"a": {"$in": [1, 2]}}, + filter_b={"a": {"$nin": [1, 2]}}, + msg="$in vs $nin should have different query shapes", + ), + QueryHashCase( + id="and_vs_or", + filter_a={"$and": [{"a": 1}, {"b": 2}]}, + filter_b={"$or": [{"a": 1}, {"b": 2}]}, + msg="$and vs $or should have different query shapes", + ), + QueryHashCase( + id="or_vs_nor", + filter_a={"$or": [{"a": 1}, {"b": 2}]}, + filter_b={"$nor": [{"a": 1}, {"b": 2}]}, + msg="$or vs $nor should have different query shapes", + ), + QueryHashCase( + id="and_clause_count", + filter_a={"$and": [{"a": 1}]}, + filter_b={"$and": [{"a": 1}, {"b": 2}]}, + msg="$and with different clause counts should have different query shapes", + ), + QueryHashCase( + id="exists_true_vs_false", + filter_a={"a": {"$exists": True}}, + filter_b={"a": {"$exists": False}}, + msg="$exists true vs false should have different query shapes", + ), + QueryHashCase( + id="in_length_variation", + filter_a={"a": {"$in": [1]}}, + filter_b={"a": {"$in": [1, 2]}}, + msg="$in with different array lengths should have different query shapes", + ), + QueryHashCase( + id="not_inner_operator_variation", + filter_a={"a": {"$not": {"$gt": 1}}}, + filter_b={"a": {"$not": {"$lt": 1}}}, + msg="$not with different inner operators should have different query shapes", + ), + QueryHashCase( + id="sort_field_variation", + filter_a={"a": {"$gt": 0}}, + filter_b={"a": {"$gt": 0}}, + sort_a={"a": 1}, + sort_b={"b": 1}, + msg="same filter sorted on different fields should have different query shapes", + ), + QueryHashCase( + id="projection_field_set", + filter_a={"a": 1}, + filter_b={"a": 1}, + projection_a={"a": 1}, + projection_b={"b": 1}, + msg="different projection field sets should have different query shapes", + ), + QueryHashCase( + id="collation_locale_variation", + filter_a={"s": "hello"}, + filter_b={"s": "hello"}, + collation_a={"locale": "en"}, + collation_b={"locale": "fr"}, + msg="different collation locales should have different query shapes", + ), + QueryHashCase( + id="elemMatch_inner_operator_variation", + filter_a={"arr": {"$elemMatch": {"$gt": 0}}}, + filter_b={"arr": {"$elemMatch": {"$lt": 0}}}, + msg="$elemMatch with different inner operators should have different query shapes", + ), + QueryHashCase( + id="jsonSchema_field_variation", + filter_a={"$jsonSchema": {"required": ["a"]}}, + filter_b={"$jsonSchema": {"required": ["b"]}}, + msg="$jsonSchema requiring different fields should have different query shapes", + ), + QueryHashCase( + id="sort_direction_variation", + filter_a={"a": {"$gt": 0}}, + filter_b={"a": {"$gt": 0}}, + sort_a={"a": 1}, + sort_b={"a": -1}, + msg="same filter with different sort directions should have different query shapes", + ), + QueryHashCase( + id="compound_sort_field_order", + filter_a={"a": {"$gt": 0}}, + filter_b={"a": {"$gt": 0}}, + sort_a={"a": 1, "b": 1}, + sort_b={"b": 1, "a": 1}, + msg="compound sort with different field order should have different query shapes", + ), + QueryHashCase( + id="ne_vs_eq", + filter_a={"a": {"$ne": 1}}, + filter_b={"a": {"$eq": 1}}, + msg="$ne vs $eq on the same field should have different query shapes", + ), + QueryHashCase( + id="exists_vs_equality", + filter_a={"a": {"$exists": True}}, + filter_b={"a": 1}, + msg="$exists:true vs equality predicate should have different query shapes", + ), + QueryHashCase( + id="dot_notation_vs_top_level", + filter_a={"a.b": 1}, + filter_b={"a": 1}, + msg="dot-notation path vs top-level field should have different query shapes", + ), + QueryHashCase( + id="or_clause_count", + filter_a={"$or": [{"a": 1}]}, + filter_b={"$or": [{"a": 1}, {"b": 2}]}, + msg="$or with different clause counts should have different query shapes", + ), + QueryHashCase( + id="nor_clause_count", + filter_a={"$nor": [{"a": 1}]}, + filter_b={"$nor": [{"a": 1}, {"b": 2}]}, + msg="$nor with different clause counts should have different query shapes", + ), + QueryHashCase( + id="all_vs_in", + filter_a={"arr": {"$all": [1, 2]}}, + filter_b={"arr": {"$in": [1, 2]}}, + msg="$all vs $in on the same field should have different query shapes", + ), + QueryHashCase( + id="collation_present_vs_absent", + filter_a={"s": "hello"}, + filter_b={"s": "hello"}, + collation_a={"locale": "en"}, + collation_b=None, + msg="collation present vs absent should produce different query shapes", + ), + QueryHashCase( + id="sort_present_vs_absent", + filter_a={"a": 1}, + filter_b={"a": 1}, + sort_a={"a": 1}, + sort_b=None, + msg="query with sort vs same query without sort should have different query shapes", + ), + QueryHashCase( + id="projection_present_vs_absent", + filter_a={"a": 1}, + filter_b={"a": 1}, + projection_a={"a": 1}, + projection_b=None, + msg="projection present vs absent should produce different query shapes", + ), + QueryHashCase( + id="projection_inclusion_vs_exclusion", + filter_a={"a": 1}, + filter_b={"a": 1}, + projection_a={"b": 1}, + projection_b={"b": 0}, + msg="projection inclusion vs exclusion on the same field should differ in query shape", + ), + QueryHashCase( + id="collation_strength_variation", + filter_a={"s": "hello"}, + filter_b={"s": "hello"}, + collation_a={"locale": "en", "strength": 1}, + collation_b={"locale": "en", "strength": 2}, + msg="same locale with different collation strength should have different query shapes", + ), + QueryHashCase( + id="in_length_all", + filter_a={"arr": {"$all": [1]}}, + filter_b={"arr": {"$all": [1, 2]}}, + msg="$all with different array lengths should have different query shapes", + ), + QueryHashCase( + id="jsonSchema_keyword_variation", + filter_a={"$jsonSchema": {"required": ["a"]}}, + filter_b={"$jsonSchema": {"properties": {"a": {"bsonType": "int"}}}}, + msg="$jsonSchema with different keywords should have different query shapes", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(DIFF_SHAPE_TESTS)) +def test_explain_queryhash_different_shape(collection, test): + """Test that structurally distinct queries produce different queryPlanner.queryHash values.""" + collection.insert_many( + [{"_id": i, "a": i % 5, "b": i % 3, "arr": [i, i + 1], "s": str(i)} for i in range(20)] + ) + hash_a = get_query_hash( + collection, test.filter_a, test.sort_a, test.projection_a, test.collation_a + ) + hash_b = get_query_hash( + collection, test.filter_b, test.sort_b, test.projection_b, test.collation_b + ) + assertSuccess( + {"hashes_differ": hash_a != hash_b}, + {"hashes_differ": True}, + msg=test.msg, + raw_res=True, + ) + + +def test_explain_queryhash_plan_cache_filter_collapses_same_shape(collection): + """Test that same-shape queries map to a single planCacheSetFilter entry. + + Pins the shape function across subsystems: planner -> plan-cache-filter -> + explain. A divergence between them would cause planCacheListFilters to + return two separate filter entries for queries that share a queryHash. + """ + collection.insert_many( + [{"_id": i, "a": i % 5, "b": i % 3, "arr": [i, i + 1], "s": str(i)} for i in range(20)] + ) + collection.create_index([("a", 1)]) + collection.database.command({"planCacheClear": collection.name}) + + filter_a = {"a": {"$eq": 1}} + filter_b = {"a": {"$eq": 3}} + + hash_a = get_query_hash(collection, filter_a) + hash_b = get_query_hash(collection, filter_b) + if hash_a != hash_b: + raise RuntimeError( + f"pre-condition failed: filter_a and filter_b must share a query shape " + f"(hash_a={hash_a!r}, hash_b={hash_b!r})" + ) + + set_result = execute_command( + collection, + {"planCacheSetFilter": collection.name, "query": filter_a, "indexes": [{"a": 1}]}, + ) + if isinstance(set_result, Exception) or set_result.get("ok") != 1.0: + raise RuntimeError(f"planCacheSetFilter failed: {set_result}") + + list_result = execute_command(collection, {"planCacheListFilters": collection.name}) + if isinstance(list_result, Exception) or "filters" not in list_result: + raise RuntimeError(f"planCacheListFilters failed: {list_result}") + + assertSuccess( + {"filter_count": len(list_result["filters"])}, + {"filter_count": 1}, + msg="same-shape queries should map to exactly one plan cache filter entry", + raw_res=True, + ) + + +def _explain_before_and_after_index(collection, find_filter: dict) -> tuple[dict, dict]: + """Return (queryPlanner before index, queryPlanner after index) for find_filter.""" + result_before = execute_command( + collection, + {"explain": {"find": collection.name, "filter": find_filter}, "verbosity": "queryPlanner"}, + ) + if isinstance(result_before, Exception) or "queryPlanner" not in result_before: + raise RuntimeError(f"explain before index failed: {result_before}") + qp_before = result_before["queryPlanner"] + if qp_before.get("queryHash") is None or qp_before.get("planCacheKey") is None: + raise RuntimeError(f"explain did not return queryHash/planCacheKey: {result_before}") + + collection.create_index([("a", 1)]) + + result_after = execute_command( + collection, + {"explain": {"find": collection.name, "filter": find_filter}, "verbosity": "queryPlanner"}, + ) + if isinstance(result_after, Exception) or "queryPlanner" not in result_after: + raise RuntimeError(f"explain after index failed: {result_after}") + return qp_before, result_after["queryPlanner"] + + +def test_explain_queryhash_stable_after_index_add(collection): + """Test that queryHash is unchanged after an index is added. + + queryHash (planCacheShapeHash) depends only on the query shape, not on + available indexes, so it must remain the same before and after index creation. + """ + collection.insert_many( + [{"_id": i, "a": i % 5, "b": i % 3, "arr": [i, i + 1], "s": str(i)} for i in range(20)] + ) + qp_before, qp_after = _explain_before_and_after_index(collection, {"a": {"$eq": 1}}) + assertSuccess( + {"queryHash": qp_after.get("queryHash")}, + {"queryHash": qp_before["queryHash"]}, + msg="queryHash must be stable across index creation", + raw_res=True, + ) + + +def test_explain_planCacheKey_changes_after_index_add(collection): + """Test that planCacheKey changes when an index is added. + + planCacheKey depends on both the query shape and the available indexes, + so it must differ before and after index creation. + """ + collection.insert_many( + [{"_id": i, "a": i % 5, "b": i % 3, "arr": [i, i + 1], "s": str(i)} for i in range(20)] + ) + qp_before, qp_after = _explain_before_and_after_index(collection, {"a": {"$eq": 1}}) + assertSuccess( + {"planCacheKey_changed": qp_before["planCacheKey"] != qp_after.get("planCacheKey")}, + {"planCacheKey_changed": True}, + msg="planCacheKey must change after an index is added", + raw_res=True, + ) From 13a397b8b66b5e78facafbf7607406834e3f54fd Mon Sep 17 00:00:00 2001 From: PatersonProjects Date: Thu, 2 Jul 2026 17:36:20 -0700 Subject: [PATCH 7/7] Added tests for different hash shape types Signed-off-by: PatersonProjects --- .../explain/test_explain_queryhash_shape.py | 169 ++++++++---------- 1 file changed, 75 insertions(+), 94 deletions(-) diff --git a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_queryhash_shape.py b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_queryhash_shape.py index c4890dcc8..0de460033 100644 --- a/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_queryhash_shape.py +++ b/documentdb_tests/compatibility/tests/system/diagnostic/commands/explain/test_explain_queryhash_shape.py @@ -10,9 +10,10 @@ import pytest -from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.assertions import assertProperties from documentdb_tests.framework.executor import execute_command from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Ne from documentdb_tests.framework.test_case import BaseTestCase pytestmark = pytest.mark.admin @@ -25,7 +26,7 @@ def get_query_hash( projection: Optional[dict] = None, collation: Optional[dict] = None, ) -> str: - """Return the queryPlanner.queryHash from explain for a find filter.""" + """Return queryPlanner.planCacheShapeHash from explain for a find filter.""" cmd: dict[str, Any] = {"find": collection.name, "filter": find_filter} if sort is not None: cmd["sort"] = sort @@ -37,14 +38,42 @@ def get_query_hash( if isinstance(result, Exception): raise RuntimeError(f"explain command failed: {result}") query_planner = result.get("queryPlanner", {}) - if "queryHash" not in query_planner: - raise RuntimeError(f"explain did not return queryPlanner.queryHash; got: {result}") - return str(query_planner["queryHash"]) + if "planCacheShapeHash" not in query_planner: + raise RuntimeError(f"explain did not return queryPlanner.planCacheShapeHash; got: {result}") + return str(query_planner["planCacheShapeHash"]) + + +def get_query_shape_hash( + collection, + find_filter: dict, + sort: Optional[dict] = None, + projection: Optional[dict] = None, + collation: Optional[dict] = None, +) -> str: + """Return the top-level queryShapeHash from explain for a find filter.""" + cmd: dict[str, Any] = {"find": collection.name, "filter": find_filter} + if sort is not None: + cmd["sort"] = sort + if projection is not None: + cmd["projection"] = projection + if collation is not None: + cmd["collation"] = collation + result = execute_command(collection, {"explain": cmd, "verbosity": "queryPlanner"}) + if isinstance(result, Exception): + raise RuntimeError(f"explain command failed: {result}") + if "queryShapeHash" not in result: + raise RuntimeError(f"explain did not return queryShapeHash; got: {result}") + return str(result["queryShapeHash"]) @dataclass(frozen=True) class QueryHashCase(BaseTestCase): - """Test case comparing the queryHash of two find queries.""" + """Test case comparing the queryHash of two find queries. + + query_shape_same overrides the expected same/different verdict specifically + for queryShapeHash when it differs from planCacheShapeHash behaviour. + Leave as None to inherit the planCacheShapeHash expectation. + """ filter_a: Optional[dict] = None filter_b: Optional[dict] = None @@ -54,6 +83,7 @@ class QueryHashCase(BaseTestCase): projection_b: Optional[dict] = None collation_a: Optional[dict] = None collation_b: Optional[dict] = None + query_shape_same: Optional[bool] = None SAME_SHAPE_TESTS: list[QueryHashCase] = [ @@ -91,12 +121,14 @@ class QueryHashCase(BaseTestCase): id="field_clause_order", filter_a={"a": 1, "b": 2}, filter_b={"b": 2, "a": 1}, + query_shape_same=False, msg="reversed top-level field order should share a query shape", ), QueryHashCase( id="and_clause_order", filter_a={"$and": [{"a": 1}, {"b": 2}]}, filter_b={"$and": [{"b": 2}, {"a": 1}]}, + query_shape_same=False, msg="reversed $and clause order should share a query shape", ), QueryHashCase( @@ -133,6 +165,7 @@ class QueryHashCase(BaseTestCase): id="projection_field_order", filter_a={"a": 1}, filter_b={"a": 1}, + query_shape_same=False, projection_a={"a": 1, "b": 1}, projection_b={"b": 1, "a": 1}, msg="same projection fields in different order should share a query shape", @@ -239,12 +272,14 @@ class QueryHashCase(BaseTestCase): id="expr_operator_variation", filter_a={"$expr": {"$gt": ["$a", "$b"]}}, filter_b={"$expr": {"$lt": ["$a", "$b"]}}, + query_shape_same=False, msg="$expr operators are not part of the shape; different operators share a query shape", ), QueryHashCase( id="type_argument_variation", filter_a={"a": {"$type": "int"}}, filter_b={"a": {"$type": "string"}}, + query_shape_same=False, msg="$type argument is treated as a literal and shares a query shape", ), QueryHashCase( @@ -268,9 +303,9 @@ def test_explain_queryhash_same_shape(collection, test): hash_b = get_query_hash( collection, test.filter_b, test.sort_b, test.projection_b, test.collation_b ) - assertSuccess( - {"queryHash": hash_a}, - {"queryHash": hash_b}, + assertProperties( + {"planCacheShapeHash": hash_a}, + {"planCacheShapeHash": Eq(hash_b)}, msg=test.msg, raw_res=True, ) @@ -323,6 +358,7 @@ def test_explain_queryhash_same_shape(collection, test): id="in_length_variation", filter_a={"a": {"$in": [1]}}, filter_b={"a": {"$in": [1, 2]}}, + query_shape_same=True, msg="$in with different array lengths should have different query shapes", ), QueryHashCase( @@ -486,111 +522,56 @@ def test_explain_queryhash_different_shape(collection, test): hash_b = get_query_hash( collection, test.filter_b, test.sort_b, test.projection_b, test.collation_b ) - assertSuccess( - {"hashes_differ": hash_a != hash_b}, - {"hashes_differ": True}, + assertProperties( + {"planCacheShapeHash": hash_a}, + {"planCacheShapeHash": Ne(hash_b)}, msg=test.msg, raw_res=True, ) -def test_explain_queryhash_plan_cache_filter_collapses_same_shape(collection): - """Test that same-shape queries map to a single planCacheSetFilter entry. +@pytest.mark.parametrize("test", pytest_params(SAME_SHAPE_TESTS)) +def test_explain_queryshapehash_same_shape(collection, test): + """Test that structurally equivalent queries produce the same top-level queryShapeHash. - Pins the shape function across subsystems: planner -> plan-cache-filter -> - explain. A divergence between them would cause planCacheListFilters to - return two separate filter entries for queries that share a queryHash. + Cases annotated with query_shape_same=False diverge from planCacheShapeHash + behaviour: queryShapeHash preserves field order, $and clause order, + projection order, $expr operators, and $type arguments. """ + expected_same = test.query_shape_same if test.query_shape_same is not None else True collection.insert_many( [{"_id": i, "a": i % 5, "b": i % 3, "arr": [i, i + 1], "s": str(i)} for i in range(20)] ) - collection.create_index([("a", 1)]) - collection.database.command({"planCacheClear": collection.name}) - - filter_a = {"a": {"$eq": 1}} - filter_b = {"a": {"$eq": 3}} - - hash_a = get_query_hash(collection, filter_a) - hash_b = get_query_hash(collection, filter_b) - if hash_a != hash_b: - raise RuntimeError( - f"pre-condition failed: filter_a and filter_b must share a query shape " - f"(hash_a={hash_a!r}, hash_b={hash_b!r})" - ) - - set_result = execute_command( - collection, - {"planCacheSetFilter": collection.name, "query": filter_a, "indexes": [{"a": 1}]}, - ) - if isinstance(set_result, Exception) or set_result.get("ok") != 1.0: - raise RuntimeError(f"planCacheSetFilter failed: {set_result}") - - list_result = execute_command(collection, {"planCacheListFilters": collection.name}) - if isinstance(list_result, Exception) or "filters" not in list_result: - raise RuntimeError(f"planCacheListFilters failed: {list_result}") - - assertSuccess( - {"filter_count": len(list_result["filters"])}, - {"filter_count": 1}, - msg="same-shape queries should map to exactly one plan cache filter entry", - raw_res=True, + hash_a = get_query_shape_hash( + collection, test.filter_a, test.sort_a, test.projection_a, test.collation_a ) - - -def _explain_before_and_after_index(collection, find_filter: dict) -> tuple[dict, dict]: - """Return (queryPlanner before index, queryPlanner after index) for find_filter.""" - result_before = execute_command( - collection, - {"explain": {"find": collection.name, "filter": find_filter}, "verbosity": "queryPlanner"}, + hash_b = get_query_shape_hash( + collection, test.filter_b, test.sort_b, test.projection_b, test.collation_b ) - if isinstance(result_before, Exception) or "queryPlanner" not in result_before: - raise RuntimeError(f"explain before index failed: {result_before}") - qp_before = result_before["queryPlanner"] - if qp_before.get("queryHash") is None or qp_before.get("planCacheKey") is None: - raise RuntimeError(f"explain did not return queryHash/planCacheKey: {result_before}") - - collection.create_index([("a", 1)]) - - result_after = execute_command( - collection, - {"explain": {"find": collection.name, "filter": find_filter}, "verbosity": "queryPlanner"}, + check = Eq(hash_b) if expected_same else Ne(hash_b) + assertProperties( + {"queryShapeHash": hash_a}, {"queryShapeHash": check}, msg=test.msg, raw_res=True ) - if isinstance(result_after, Exception) or "queryPlanner" not in result_after: - raise RuntimeError(f"explain after index failed: {result_after}") - return qp_before, result_after["queryPlanner"] -def test_explain_queryhash_stable_after_index_add(collection): - """Test that queryHash is unchanged after an index is added. +@pytest.mark.parametrize("test", pytest_params(DIFF_SHAPE_TESTS)) +def test_explain_queryshapehash_different_shape(collection, test): + """Test that structurally distinct queries produce different top-level queryShapeHash values. - queryHash (planCacheShapeHash) depends only on the query shape, not on - available indexes, so it must remain the same before and after index creation. + Cases annotated with query_shape_same=True diverge from planCacheShapeHash + behaviour: queryShapeHash treats $in array length as irrelevant. """ + expected_same = test.query_shape_same if test.query_shape_same is not None else False collection.insert_many( [{"_id": i, "a": i % 5, "b": i % 3, "arr": [i, i + 1], "s": str(i)} for i in range(20)] ) - qp_before, qp_after = _explain_before_and_after_index(collection, {"a": {"$eq": 1}}) - assertSuccess( - {"queryHash": qp_after.get("queryHash")}, - {"queryHash": qp_before["queryHash"]}, - msg="queryHash must be stable across index creation", - raw_res=True, + hash_a = get_query_shape_hash( + collection, test.filter_a, test.sort_a, test.projection_a, test.collation_a ) - - -def test_explain_planCacheKey_changes_after_index_add(collection): - """Test that planCacheKey changes when an index is added. - - planCacheKey depends on both the query shape and the available indexes, - so it must differ before and after index creation. - """ - collection.insert_many( - [{"_id": i, "a": i % 5, "b": i % 3, "arr": [i, i + 1], "s": str(i)} for i in range(20)] + hash_b = get_query_shape_hash( + collection, test.filter_b, test.sort_b, test.projection_b, test.collation_b ) - qp_before, qp_after = _explain_before_and_after_index(collection, {"a": {"$eq": 1}}) - assertSuccess( - {"planCacheKey_changed": qp_before["planCacheKey"] != qp_after.get("planCacheKey")}, - {"planCacheKey_changed": True}, - msg="planCacheKey must change after an index is added", - raw_res=True, + check = Eq(hash_b) if expected_same else Ne(hash_b) + assertProperties( + {"queryShapeHash": hash_a}, {"queryShapeHash": check}, msg=test.msg, raw_res=True )