diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/indexStats/__init__.py b/documentdb_tests/compatibility/tests/core/operator/stages/indexStats/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/indexStats/test_indexStats_collection_types.py b/documentdb_tests/compatibility/tests/core/operator/stages/indexStats/test_indexStats_collection_types.py new file mode 100644 index 00000000..9d8d7faa --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/indexStats/test_indexStats_collection_types.py @@ -0,0 +1,83 @@ +"""Tests for $indexStats collection type behavior.""" + +from __future__ import annotations + +import pytest +from pymongo.collection import Collection + +from documentdb_tests.compatibility.tests.core.operator.stages.indexStats.utils.indexStats_test_case import ( # noqa: E501 + IndexStatsTestCase, + prepare_collection, +) +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.target_collection import ( + CappedCollection, + TimeseriesCollection, + ViewCollection, +) + +# Property [Collection Existence]: $indexStats handles non-existent, empty, +# and special collection types. +COLLECTION_TYPE_TESTS: list[IndexStatsTestCase] = [ + IndexStatsTestCase( + id="nonexistent_collection", + docs=None, + pipeline=[{"$indexStats": {}}, {"$count": "n"}], + expected=[], + msg="Non-existent collection should return 0 documents", + ), + IndexStatsTestCase( + id="empty_collection", + docs=[], + pipeline=[{"$indexStats": {}}, {"$count": "n"}], + expected=[{"n": 1}], + msg="Empty collection should return 1 document for the _id index", + ), + IndexStatsTestCase( + id="capped_collection", + target_collection=CappedCollection(), + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "_id_"}}, {"$count": "n"}], + expected=[{"n": 1}], + msg="Capped collection should report the _id index", + ), + IndexStatsTestCase( + id="timeseries_collection", + target_collection=TimeseriesCollection(), + docs=[], + pipeline=[{"$indexStats": {}}, {"$count": "n"}], + expected=[{"n": 1}], + msg="Time series collection should report at least one index", + ), + IndexStatsTestCase( + id="view_collection", + target_collection=ViewCollection(), + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "_id_"}}, {"$count": "n"}], + expected=[{"n": 1}], + msg="View should report the underlying collection's _id index", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(COLLECTION_TYPE_TESTS)) +def test_indexStats_collection_types(collection: Collection, test_case: IndexStatsTestCase): + """Test $indexStats on various collection types.""" + coll = prepare_collection(collection, test_case) + result = execute_command( + coll, + { + "aggregate": coll.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/indexStats/test_indexStats_output_structure.py b/documentdb_tests/compatibility/tests/core/operator/stages/indexStats/test_indexStats_output_structure.py new file mode 100644 index 00000000..13b25154 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/indexStats/test_indexStats_output_structure.py @@ -0,0 +1,446 @@ +"""Tests for $indexStats output document structure.""" + +from __future__ import annotations + +import pytest +from pymongo.collection import Collection +from pymongo.operations import IndexModel + +from documentdb_tests.compatibility.tests.core.operator.stages.indexStats.utils.indexStats_test_case import ( # noqa: E501 + IndexStatsTestCase, + prepare_collection, +) +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, Exists, IsType, NotExists +from documentdb_tests.framework.test_constants import INT64_ZERO + +# Property [Top-Level Fields]: each output document contains the documented +# top-level fields with correct types and no _id. +OUTPUT_TOP_LEVEL_FIELDS_TESTS: list[IndexStatsTestCase] = [ + IndexStatsTestCase( + id="default_index_field_types", + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "_id_"}}], + expected={ + "name": Eq("_id_"), + "key": Eq({"_id": 1}), + "host": IsType("string"), + "accesses": {"ops": Eq(INT64_ZERO), "since": IsType("date")}, + "spec": IsType("object"), + "_id": NotExists(), + }, + msg="Default index output should have correct top-level field types and no _id", + ), + IndexStatsTestCase( + id="user_index_field_types", + indexes=[IndexModel([("a", 1)])], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "a_1"}}], + expected={ + "name": Eq("a_1"), + "key": Eq({"a": 1}), + "host": IsType("string"), + "accesses": {"ops": Eq(INT64_ZERO), "since": IsType("date")}, + "spec": {"key": Eq({"a": 1}), "name": Eq("a_1"), "v": IsType("int")}, + "_id": NotExists(), + }, + msg="User index output should have correct top-level field types and no _id", + ), +] + +# Property [Key Direction Type]: index key direction values are int32, not +# int64. +OUTPUT_KEY_DIRECTION_TYPE_TESTS: list[IndexStatsTestCase] = [ + IndexStatsTestCase( + id="single_key_direction_is_int", + indexes=[IndexModel([("a", 1)])], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "a_1"}}], + expected={"key.a": Eq(1)}, + msg="Single index key direction should be int, not long", + ), + IndexStatsTestCase( + id="compound_key_directions_are_int", + indexes=[IndexModel([("a", 1), ("b", -1)])], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "a_1_b_-1"}}], + expected={"key.a": Eq(1), "key.b": Eq(-1)}, + msg="Compound index key directions should be int, not long", + ), + IndexStatsTestCase( + id="id_key_direction_is_int", + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "_id_"}}], + expected={"key._id": Eq(1)}, + msg="Default _id index key direction should be int, not long", + ), +] + +# Property [Absent Fields]: conditional fields are absent when their +# conditions are not met and present when they are. +OUTPUT_ABSENT_FIELDS_TESTS: list[IndexStatsTestCase] = [ + IndexStatsTestCase( + id="shard_absent_non_sharded", + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "_id_"}}], + expected={"shard": NotExists()}, + msg="shard field should be absent on non-sharded topology", + ), + IndexStatsTestCase( + id="building_absent_completed_index", + indexes=[IndexModel([("a", 1)])], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "a_1"}}], + expected={"building": NotExists()}, + msg="building field should be absent for completed indexes", + ), + IndexStatsTestCase( + id="hidden_absent_when_not_hidden", + indexes=[IndexModel([("a", 1)])], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "a_1"}}], + expected={"spec.hidden": NotExists()}, + msg="spec.hidden should be absent when index is not hidden", + ), +] + +# Property [Default Index Names]: each index type produces the expected +# auto-generated name. +DEFAULT_INDEX_NAME_TESTS: list[IndexStatsTestCase] = [ + IndexStatsTestCase( + id="single_field_default_name", + indexes=[IndexModel([("a", 1)])], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "a_1"}}], + expected={"name": Eq("a_1")}, + msg="Single-field index should have default name", + ), + IndexStatsTestCase( + id="compound_default_name", + indexes=[IndexModel([("a", 1), ("b", -1)])], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "a_1_b_-1"}}], + expected={"name": Eq("a_1_b_-1")}, + msg="Compound index should have default name", + ), + IndexStatsTestCase( + id="text_default_name", + indexes=[IndexModel([("a", "text")])], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "a_text"}}], + expected={"name": Eq("a_text")}, + msg="Text index should have default name", + ), + IndexStatsTestCase( + id="2d_default_name", + indexes=[IndexModel([("loc", "2d")])], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "loc_2d"}}], + expected={"name": Eq("loc_2d")}, + msg="2d index should have default name", + ), + IndexStatsTestCase( + id="2dsphere_default_name", + indexes=[IndexModel([("geo", "2dsphere")])], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "geo_2dsphere"}}], + expected={"name": Eq("geo_2dsphere")}, + msg="2dsphere index should have default name", + ), + IndexStatsTestCase( + id="wildcard_default_name", + indexes=[IndexModel([("$**", 1)])], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "$**_1"}}], + expected={"name": Eq("$**_1")}, + msg="Wildcard index should have default name", + ), + IndexStatsTestCase( + id="hashed_default_name", + indexes=[IndexModel([("a", "hashed")])], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "a_hashed"}}], + expected={"name": Eq("a_hashed")}, + msg="Hashed index should have default name", + ), + IndexStatsTestCase( + id="unique_default_name", + indexes=[IndexModel([("a", 1)], unique=True)], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "a_1"}}], + expected={"name": Eq("a_1")}, + msg="Unique index should have default name", + ), + IndexStatsTestCase( + id="sparse_default_name", + indexes=[IndexModel([("a", 1)], sparse=True)], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "a_1"}}], + expected={"name": Eq("a_1")}, + msg="Sparse index should have default name", + ), + IndexStatsTestCase( + id="ttl_default_name", + indexes=[IndexModel([("ts", 1)], expireAfterSeconds=3600)], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "ts_1"}}], + expected={"name": Eq("ts_1")}, + msg="TTL index should have default name", + ), + IndexStatsTestCase( + id="partial_filter_default_name", + indexes=[IndexModel([("a", 1)], partialFilterExpression={"a": {"$gt": 0}})], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "a_1"}}], + expected={"name": Eq("a_1")}, + msg="Partial filter index should have default name", + ), + IndexStatsTestCase( + id="collation_default_name", + indexes=[IndexModel([("a", 1)], collation={"locale": "en", "strength": 2})], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "a_1"}}], + expected={"name": Eq("a_1")}, + msg="Collation index should have default name", + ), + IndexStatsTestCase( + id="hidden_default_name", + indexes=[IndexModel([("a", 1)], hidden=True)], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "a_1"}}], + expected={"name": Eq("a_1")}, + msg="Hidden index should have default name", + ), +] + +# Property [Custom Index Names]: indexes with custom names including +# special characters are reported correctly. +CUSTOM_INDEX_NAME_TESTS: list[IndexStatsTestCase] = [ + IndexStatsTestCase( + id="custom_name", + indexes=[IndexModel([("a", 1)], name="my_custom_name")], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "my_custom_name"}}], + expected={"name": Eq("my_custom_name")}, + msg="Custom-named index should be reported", + ), + IndexStatsTestCase( + id="unicode_name", + indexes=[IndexModel([("a", 1)], name="\u00edndice_\u65e5\u672c\u8a9e")], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "\u00edndice_\u65e5\u672c\u8a9e"}}], + expected={"name": Eq("\u00edndice_\u65e5\u672c\u8a9e")}, + msg="Unicode-named index should be reported", + ), + IndexStatsTestCase( + id="long_name", + indexes=[IndexModel([("a", 1)], name="x" * 100)], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "x" * 100}}], + expected={"name": Eq("x" * 100)}, + msg="Long-named index should be reported", + ), + IndexStatsTestCase( + id="name_with_spaces", + indexes=[IndexModel([("a", 1)], name="my index name")], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "my index name"}}], + expected={"name": Eq("my index name")}, + msg="Index name with spaces should be reported", + ), + IndexStatsTestCase( + id="name_with_dots", + indexes=[IndexModel([("a", 1)], name="a.b.c")], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "a.b.c"}}], + expected={"name": Eq("a.b.c")}, + msg="Index name with dots should be reported", + ), + IndexStatsTestCase( + id="name_with_dollar", + indexes=[IndexModel([("a", 1)], name="$special")], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "$special"}}], + expected={"name": Eq("$special")}, + msg="Index name with dollar sign should be reported", + ), + IndexStatsTestCase( + id="name_with_emoji", + indexes=[IndexModel([("a", 1)], name="\U0001f600\U0001f680")], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "\U0001f600\U0001f680"}}], + expected={"name": Eq("\U0001f600\U0001f680")}, + msg="Index name with emoji should be reported", + ), +] + +# Property [Key Representation]: the key field correctly represents the +# index key specification for each index type. +KEY_REPRESENTATION_TESTS: list[IndexStatsTestCase] = [ + IndexStatsTestCase( + id="compound_key", + indexes=[IndexModel([("a", 1), ("b", -1)])], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "a_1_b_-1"}}], + expected={"key": Eq({"a": 1, "b": -1})}, + msg="Compound index key should reflect all fields and directions", + ), + IndexStatsTestCase( + id="text_key", + indexes=[IndexModel([("a", "text")])], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "a_text"}}], + expected={"key": Eq({"_fts": "text", "_ftsx": 1})}, + msg="Text index key should use _fts/_ftsx representation", + ), + IndexStatsTestCase( + id="2d_key", + indexes=[IndexModel([("loc", "2d")])], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "loc_2d"}}], + expected={"key": Eq({"loc": "2d"})}, + msg="2d index key should use string value", + ), + IndexStatsTestCase( + id="2dsphere_key", + indexes=[IndexModel([("geo", "2dsphere")])], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "geo_2dsphere"}}], + expected={"key": Eq({"geo": "2dsphere"})}, + msg="2dsphere index key should use string value", + ), + IndexStatsTestCase( + id="hashed_key", + indexes=[IndexModel([("a", "hashed")])], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "a_hashed"}}], + expected={"key": Eq({"a": "hashed"})}, + msg="Hashed index key should use string value", + ), + IndexStatsTestCase( + id="wildcard_key", + indexes=[IndexModel([("$**", 1)])], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "$**_1"}}], + expected={"key": Eq({"$**": 1})}, + msg="Wildcard index key should use $** field path", + ), +] + +# Property [Index Options in Spec]: index options are reflected in the +# spec document. +INDEX_OPTIONS_IN_SPEC_TESTS: list[IndexStatsTestCase] = [ + IndexStatsTestCase( + id="2d_options_in_spec", + indexes=[IndexModel([("loc", "2d")], bits=20, min=-100, max=100, name="loc_2d_opts")], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "loc_2d_opts"}}], + expected={ + "spec.bits": Eq(20), + "spec.min": Eq(-100), + "spec.max": Eq(100), + }, + msg="2d explicit options (bits, min, max) should appear in spec", + ), + IndexStatsTestCase( + id="wildcard_projection_in_spec", + indexes=[IndexModel([("$**", 1)], wildcardProjection={"a": 1}, name="wc_proj")], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "wc_proj"}}], + expected={"spec.wildcardProjection": Eq({"a": 1})}, + msg="Wildcard index with wildcardProjection should include it in spec", + ), + IndexStatsTestCase( + id="field_path_wildcard_no_projection", + indexes=[IndexModel([("a.$**", 1)], name="fp_wc")], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "fp_wc"}}], + expected={"spec.wildcardProjection": NotExists()}, + msg="Field-path wildcard should not include wildcardProjection in spec", + ), + IndexStatsTestCase( + id="unique_option_in_spec", + indexes=[IndexModel([("a", 1)], unique=True, name="a_unique")], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "a_unique"}}], + expected={"spec.unique": Eq(True)}, + msg="Unique option should be in spec", + ), + IndexStatsTestCase( + id="sparse_option_in_spec", + indexes=[IndexModel([("a", 1)], sparse=True, name="a_sparse")], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "a_sparse"}}], + expected={"spec.sparse": Eq(True)}, + msg="Sparse option should be in spec", + ), + IndexStatsTestCase( + id="ttl_expire_in_spec", + indexes=[IndexModel([("ts", 1)], expireAfterSeconds=3600, name="ts_ttl")], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "ts_ttl"}}], + expected={"spec.expireAfterSeconds": Eq(3600)}, + msg="TTL expireAfterSeconds should be in spec", + ), + IndexStatsTestCase( + id="collation_in_spec", + indexes=[ + IndexModel([("a", 1)], collation={"locale": "en", "strength": 2}, name="a_collation") + ], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "a_collation"}}], + expected={"spec.collation": Exists()}, + msg="Collation option should be in spec", + ), + IndexStatsTestCase( + id="partial_filter_in_spec", + indexes=[ + IndexModel([("a", 1)], partialFilterExpression={"a": {"$gt": 0}}, name="a_partial") + ], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "a_partial"}}], + expected={"spec.partialFilterExpression": Eq({"a": {"$gt": 0}})}, + msg="partialFilterExpression should be in spec", + ), + IndexStatsTestCase( + id="hidden_option_in_spec", + indexes=[IndexModel([("a", 1)], hidden=True)], + docs=[], + pipeline=[{"$indexStats": {}}, {"$match": {"name": "a_1"}}], + expected={"spec.hidden": Eq(True)}, + msg="Hidden option should be in spec", + ), +] + +OUTPUT_STRUCTURE_TESTS = ( + OUTPUT_TOP_LEVEL_FIELDS_TESTS + + OUTPUT_KEY_DIRECTION_TYPE_TESTS + + OUTPUT_ABSENT_FIELDS_TESTS + + DEFAULT_INDEX_NAME_TESTS + + CUSTOM_INDEX_NAME_TESTS + + KEY_REPRESENTATION_TESTS + + INDEX_OPTIONS_IN_SPEC_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(OUTPUT_STRUCTURE_TESTS)) +def test_indexStats_output_structure(collection: Collection, test_case: IndexStatsTestCase): + """Test $indexStats output document structure.""" + coll = prepare_collection(collection, test_case) + result = execute_command( + coll, + { + "aggregate": coll.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/indexStats/test_indexStats_syntax.py b/documentdb_tests/compatibility/tests/core/operator/stages/indexStats/test_indexStats_syntax.py new file mode 100644 index 00000000..e8c42150 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/indexStats/test_indexStats_syntax.py @@ -0,0 +1,338 @@ +"""Tests for $indexStats syntax validation.""" + +from __future__ import annotations + +from datetime import datetime + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp +from pymongo.collection import Collection + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INDEX_STATS_ARG_ERROR, + PIPELINE_STAGE_EXTRA_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF + +# Property [Non-Empty Document]: any non-empty document argument to +# $indexStats produces INDEX_STATS_ARG_ERROR, regardless of key names or +# values. +NON_EMPTY_DOC_TESTS: list[StageTestCase] = [ + StageTestCase( + docs=[], + pipeline=[{"$indexStats": {"unknown": 1}}], + error_code=INDEX_STATS_ARG_ERROR, + id="unknown_field", + msg="Non-empty document with unknown field should be rejected", + ), + StageTestCase( + docs=[], + pipeline=[{"$indexStats": {"a": None}}], + error_code=INDEX_STATS_ARG_ERROR, + id="null_value", + msg="Non-empty document with null value should be rejected", + ), + StageTestCase( + docs=[], + pipeline=[{"$indexStats": {"a": {}}}], + error_code=INDEX_STATS_ARG_ERROR, + id="nested_empty_doc", + msg="Non-empty document with nested empty doc should be rejected", + ), + StageTestCase( + docs=[], + pipeline=[{"$indexStats": {"$foo": 1}}], + error_code=INDEX_STATS_ARG_ERROR, + id="dollar_prefixed_key", + msg="Non-empty document with $-prefixed key should be rejected", + ), + StageTestCase( + docs=[], + pipeline=[{"$indexStats": {"a.b": 1}}], + error_code=INDEX_STATS_ARG_ERROR, + id="dot_notation_key", + msg="Non-empty document with dot-notation key should be rejected", + ), + StageTestCase( + docs=[], + pipeline=[{"$indexStats": {"_id": 1}}], + error_code=INDEX_STATS_ARG_ERROR, + id="id_key", + msg="Non-empty document with _id key should be rejected", + ), + StageTestCase( + docs=[], + pipeline=[{"$indexStats": {"": 1}}], + error_code=INDEX_STATS_ARG_ERROR, + id="empty_string_key", + msg="Non-empty document with empty string key should be rejected", + ), + StageTestCase( + docs=[], + pipeline=[{"$indexStats": {"a": 1, "b": 2}}], + error_code=INDEX_STATS_ARG_ERROR, + id="multiple_keys", + msg="Non-empty document with multiple keys should be rejected", + ), +] + +# Property [Non-Document Types]: all non-document BSON types produce +# INDEX_STATS_ARG_ERROR. +NON_DOCUMENT_TYPE_TESTS: list[StageTestCase] = [ + StageTestCase( + docs=[], + pipeline=[{"$indexStats": "hello"}], + error_code=INDEX_STATS_ARG_ERROR, + id="string", + msg="String argument should be rejected", + ), + StageTestCase( + docs=[], + pipeline=[{"$indexStats": 42}], + error_code=INDEX_STATS_ARG_ERROR, + id="int32", + msg="Int32 argument should be rejected", + ), + StageTestCase( + docs=[], + pipeline=[{"$indexStats": Int64(42)}], + error_code=INDEX_STATS_ARG_ERROR, + id="int64", + msg="Int64 argument should be rejected", + ), + StageTestCase( + docs=[], + pipeline=[{"$indexStats": 3.14}], + error_code=INDEX_STATS_ARG_ERROR, + id="double", + msg="Double argument should be rejected", + ), + StageTestCase( + docs=[], + pipeline=[{"$indexStats": DECIMAL128_ONE_AND_HALF}], + error_code=INDEX_STATS_ARG_ERROR, + id="decimal128", + msg="Decimal128 argument should be rejected", + ), + StageTestCase( + docs=[], + pipeline=[{"$indexStats": True}], + error_code=INDEX_STATS_ARG_ERROR, + id="bool", + msg="Bool argument should be rejected", + ), + StageTestCase( + docs=[], + pipeline=[{"$indexStats": None}], + error_code=INDEX_STATS_ARG_ERROR, + id="null", + msg="Null argument should be rejected", + ), + StageTestCase( + docs=[], + pipeline=[{"$indexStats": [1, 2]}], + error_code=INDEX_STATS_ARG_ERROR, + id="array", + msg="Array argument should be rejected", + ), + StageTestCase( + docs=[], + pipeline=[{"$indexStats": ObjectId()}], + error_code=INDEX_STATS_ARG_ERROR, + id="objectid", + msg="ObjectId argument should be rejected", + ), + StageTestCase( + docs=[], + pipeline=[{"$indexStats": datetime(2024, 1, 1)}], + error_code=INDEX_STATS_ARG_ERROR, + id="datetime", + msg="Datetime argument should be rejected", + ), + StageTestCase( + docs=[], + pipeline=[{"$indexStats": Timestamp(1, 1)}], + error_code=INDEX_STATS_ARG_ERROR, + id="timestamp", + msg="Timestamp argument should be rejected", + ), + StageTestCase( + docs=[], + pipeline=[{"$indexStats": Binary(b"\x00")}], + error_code=INDEX_STATS_ARG_ERROR, + id="binary", + msg="Binary argument should be rejected", + ), + StageTestCase( + docs=[], + pipeline=[{"$indexStats": Regex(".*")}], + error_code=INDEX_STATS_ARG_ERROR, + id="regex", + msg="Regex argument should be rejected", + ), + StageTestCase( + docs=[], + pipeline=[{"$indexStats": Code("function(){}")}], + error_code=INDEX_STATS_ARG_ERROR, + id="code", + msg="Code argument should be rejected", + ), + StageTestCase( + docs=[], + pipeline=[{"$indexStats": Code("function(){}", {})}], + error_code=INDEX_STATS_ARG_ERROR, + id="code_with_scope", + msg="Code with scope argument should be rejected", + ), + StageTestCase( + docs=[], + pipeline=[{"$indexStats": MinKey()}], + error_code=INDEX_STATS_ARG_ERROR, + id="minkey", + msg="MinKey argument should be rejected", + ), + StageTestCase( + docs=[], + pipeline=[{"$indexStats": MaxKey()}], + error_code=INDEX_STATS_ARG_ERROR, + id="maxkey", + msg="MaxKey argument should be rejected", + ), +] + +# Property [Expression-Like Objects]: expression-like documents are not +# evaluated and produce INDEX_STATS_ARG_ERROR. +EXPRESSION_LIKE_TESTS: list[StageTestCase] = [ + StageTestCase( + docs=[], + pipeline=[{"$indexStats": {"$literal": {}}}], + error_code=INDEX_STATS_ARG_ERROR, + id="literal_expression", + msg="$literal expression should not be evaluated", + ), + StageTestCase( + docs=[], + pipeline=[{"$indexStats": {"$add": [1, 2]}}], + error_code=INDEX_STATS_ARG_ERROR, + id="add_expression", + msg="$add expression should not be evaluated", + ), +] + +# Property [Array Variants]: array arguments of any shape are rejected +# with INDEX_STATS_ARG_ERROR; the stage parser does not unwrap +# single-element arrays. +ARRAY_VARIANT_TESTS: list[StageTestCase] = [ + StageTestCase( + docs=[], + pipeline=[{"$indexStats": [{}]}], + error_code=INDEX_STATS_ARG_ERROR, + id="array_with_empty_doc", + msg="Array containing empty doc should be rejected", + ), + StageTestCase( + docs=[], + pipeline=[{"$indexStats": [None]}], + error_code=INDEX_STATS_ARG_ERROR, + id="array_with_null", + msg="Array containing null should be rejected", + ), + StageTestCase( + docs=[], + pipeline=[{"$indexStats": [[]]}], + error_code=INDEX_STATS_ARG_ERROR, + id="array_with_empty_array", + msg="Array containing empty array should be rejected", + ), + StageTestCase( + docs=[], + pipeline=[{"$indexStats": []}], + error_code=INDEX_STATS_ARG_ERROR, + id="empty_array", + msg="Empty array should be rejected", + ), +] + +# Property [Extra Keys in Stage Document]: any extra key alongside +# $indexStats in the stage document produces +# PIPELINE_STAGE_EXTRA_FIELD_ERROR, regardless of key order or whether +# the extra key is another stage name. +EXTRA_KEY_TESTS: list[StageTestCase] = [ + StageTestCase( + docs=[], + pipeline=[{"$indexStats": {}, "extra": 1}], + error_code=PIPELINE_STAGE_EXTRA_FIELD_ERROR, + id="extra_key", + msg="Extra key in stage document should be rejected", + ), + StageTestCase( + docs=[], + pipeline=[{"$indexStats": {}, "$match": {}}], + error_code=PIPELINE_STAGE_EXTRA_FIELD_ERROR, + id="extra_key_is_stage_name", + msg="Extra key that is another stage name should be rejected", + ), +] + +# Property [Error Precedence - Extra Keys Over Argument]: extra keys in +# the stage document (PIPELINE_STAGE_EXTRA_FIELD_ERROR) take precedence +# over argument validation (INDEX_STATS_ARG_ERROR). +EXTRA_KEY_OVER_ARG_TESTS: list[StageTestCase] = [ + StageTestCase( + docs=[], + pipeline=[{"$indexStats": "bad", "extra": 1}], + error_code=PIPELINE_STAGE_EXTRA_FIELD_ERROR, + id="extra_key_over_bad_arg", + msg="Extra key should take precedence over invalid argument", + ), +] + +# Property [Validation on Nonexistent Collection]: argument validation +# fires even when the target collection does not exist. +VALIDATION_WITHOUT_DOCS_TESTS: list[StageTestCase] = [ + StageTestCase( + docs=None, + pipeline=[{"$indexStats": {"bad": 1}}], + error_code=INDEX_STATS_ARG_ERROR, + id="bad_arg_nonexistent_collection", + msg="Argument error should fire on non-existent collection", + ), +] + +SYNTAX_ERROR_TESTS = ( + NON_EMPTY_DOC_TESTS + + NON_DOCUMENT_TYPE_TESTS + + EXPRESSION_LIKE_TESTS + + ARRAY_VARIANT_TESTS + + EXTRA_KEY_TESTS + + EXTRA_KEY_OVER_ARG_TESTS + + VALIDATION_WITHOUT_DOCS_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(SYNTAX_ERROR_TESTS)) +def test_indexStats_syntax_error(collection: Collection, test_case: StageTestCase): + """Test $indexStats rejects invalid syntax.""" + populate_collection(collection, test_case) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/indexStats/utils/__init__.py b/documentdb_tests/compatibility/tests/core/operator/stages/indexStats/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/indexStats/utils/indexStats_test_case.py b/documentdb_tests/compatibility/tests/core/operator/stages/indexStats/utils/indexStats_test_case.py new file mode 100644 index 00000000..feb11745 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/indexStats/utils/indexStats_test_case.py @@ -0,0 +1,38 @@ +"""Extended test case for $indexStats tests with target_collection.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from pymongo.collection import Collection + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, +) +from documentdb_tests.framework.target_collection import TargetCollection + + +@dataclass(frozen=True) +class IndexStatsTestCase(StageTestCase): + """StageTestCase with declarative target_collection support.""" + + target_collection: TargetCollection = field(default_factory=TargetCollection) + + +def prepare_collection(collection: Collection, test_case: IndexStatsTestCase) -> Collection: + """Resolve target collection and set up docs/indexes. + + Returns the resolved collection to run the aggregate against. + """ + if test_case.docs is None: + return collection + + db = collection.database + db.create_collection(collection.name) + coll = test_case.target_collection.resolve(db, collection) + + if test_case.docs: + coll.insert_many(test_case.docs) + if test_case.indexes: + coll.create_indexes(test_case.indexes) + return coll diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_indexStats.py b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_indexStats.py new file mode 100644 index 00000000..2064220f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_indexStats.py @@ -0,0 +1,471 @@ +"""Tests for $indexStats pipeline position requirements.""" + +from __future__ import annotations + +import pytest +from pymongo.collection import Collection +from pymongo.operations import IndexModel + +from documentdb_tests.compatibility.tests.core.operator.stages.indexStats.utils.indexStats_test_case import ( # noqa: E501 + IndexStatsTestCase, + prepare_collection, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + FACET_PIPELINE_INVALID_STAGE_ERROR, + NOT_FIRST_STAGE_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [First Stage]: $indexStats succeeds when it is the first stage +# in a pipeline. +FIRST_STAGE_TESTS: list[IndexStatsTestCase] = [ + IndexStatsTestCase( + id="first_stage_succeeds", + docs=[], + pipeline=[ + {"$indexStats": {}}, + {"$match": {"name": "_id_"}}, + {"$project": {"_id": 0, "name": 1}}, + ], + expected=[{"name": "_id_"}], + msg="$indexStats as the first stage should succeed", + ), + IndexStatsTestCase( + id="all_indexes_returned", + indexes=[IndexModel([("a", 1)]), IndexModel([("b", -1)])], + docs=[], + pipeline=[ + {"$indexStats": {}}, + {"$project": {"_id": 0, "name": 1, "key": 1}}, + {"$sort": {"name": 1}}, + ], + expected=[ + {"name": "_id_", "key": {"_id": 1}}, + {"name": "a_1", "key": {"a": 1}}, + {"name": "b_-1", "key": {"b": -1}}, + ], + msg="All indexes should be returned with correct names and keys", + ), +] + +# Property [Not First Position]: $indexStats must be the first stage in a +# pipeline. +NOT_FIRST_POSITION_TESTS: list[IndexStatsTestCase] = [ + IndexStatsTestCase( + id="after_match", + docs=[], + pipeline=[{"$match": {}}, {"$indexStats": {}}], + error_code=NOT_FIRST_STAGE_ERROR, + msg="$indexStats after $match should fail", + ), + IndexStatsTestCase( + id="after_project", + docs=[], + pipeline=[{"$project": {"a": 1}}, {"$indexStats": {}}], + error_code=NOT_FIRST_STAGE_ERROR, + msg="$indexStats after $project should fail", + ), + IndexStatsTestCase( + id="after_add_fields", + docs=[], + pipeline=[{"$addFields": {"a": 1}}, {"$indexStats": {}}], + error_code=NOT_FIRST_STAGE_ERROR, + msg="$indexStats after $addFields should fail", + ), + IndexStatsTestCase( + id="after_limit", + docs=[], + pipeline=[{"$limit": 1}, {"$indexStats": {}}], + error_code=NOT_FIRST_STAGE_ERROR, + msg="$indexStats after $limit should fail", + ), + IndexStatsTestCase( + id="after_unwind", + docs=[], + pipeline=[{"$unwind": "$a"}, {"$indexStats": {}}], + error_code=NOT_FIRST_STAGE_ERROR, + msg="$indexStats after $unwind should fail", + ), + IndexStatsTestCase( + id="two_index_stats", + docs=[], + pipeline=[{"$indexStats": {}}, {"$indexStats": {}}], + error_code=NOT_FIRST_STAGE_ERROR, + msg="Second $indexStats in same pipeline should fail", + ), + IndexStatsTestCase( + id="coll_stats_then_index_stats", + docs=[], + pipeline=[{"$collStats": {"count": {}}}, {"$indexStats": {}}], + error_code=NOT_FIRST_STAGE_ERROR, + msg="$indexStats after $collStats should fail", + ), + IndexStatsTestCase( + id="index_stats_then_coll_stats", + docs=[], + pipeline=[{"$indexStats": {}}, {"$collStats": {"count": {}}}], + error_code=NOT_FIRST_STAGE_ERROR, + msg="$collStats after $indexStats should fail", + ), +] + +# Property [Facet Restriction]: $indexStats is not allowed inside $facet. +FACET_RESTRICTION_TESTS: list[IndexStatsTestCase] = [ + IndexStatsTestCase( + id="inside_facet", + docs=[], + pipeline=[{"$facet": {"a": [{"$indexStats": {}}]}}], + error_code=FACET_PIPELINE_INVALID_STAGE_ERROR, + msg="$indexStats inside $facet should be disallowed", + ), +] + +# Property [Match Filtering]: $match after $indexStats filters index +# documents by field values. +MATCH_FILTERING_TESTS: list[IndexStatsTestCase] = [ + IndexStatsTestCase( + id="match_by_name", + indexes=[IndexModel([("a", 1)]), IndexModel([("b", -1)])], + docs=[], + pipeline=[ + {"$indexStats": {}}, + {"$match": {"name": "a_1"}}, + {"$project": {"_id": 0, "name": 1}}, + ], + expected=[{"name": "a_1"}], + msg="$match should filter indexStats output by name", + ), + IndexStatsTestCase( + id="match_by_key_field", + indexes=[IndexModel([("x", 1)]), IndexModel([("y", -1)])], + docs=[], + pipeline=[ + {"$indexStats": {}}, + {"$match": {"key.y": -1}}, + {"$project": {"_id": 0, "name": 1}}, + ], + expected=[{"name": "y_-1"}], + msg="$match should filter by nested key field", + ), + IndexStatsTestCase( + id="match_no_results", + indexes=[IndexModel([("a", 1)])], + docs=[], + pipeline=[ + {"$indexStats": {}}, + {"$match": {"name": "nonexistent"}}, + {"$project": {"_id": 0, "name": 1}}, + ], + expected=[], + msg="$match with no matching index should return empty", + ), + IndexStatsTestCase( + id="match_regex", + indexes=[IndexModel([("field_one", 1)]), IndexModel([("field_two", -1)])], + docs=[], + pipeline=[ + {"$indexStats": {}}, + {"$match": {"name": {"$regex": "^field_"}}}, + {"$project": {"_id": 0, "name": 1}}, + {"$sort": {"name": 1}}, + ], + expected=[{"name": "field_one_1"}, {"name": "field_two_-1"}], + msg="$match with regex should filter index names by pattern", + ), +] + +# Property [Sort Ordering]: $sort after $indexStats orders index documents. +SORT_ORDERING_TESTS: list[IndexStatsTestCase] = [ + IndexStatsTestCase( + id="sort_by_name_ascending", + indexes=[IndexModel([("c", 1)]), IndexModel([("a", 1)]), IndexModel([("b", 1)])], + docs=[], + pipeline=[ + {"$indexStats": {}}, + {"$match": {"name": {"$ne": "_id_"}}}, + {"$sort": {"name": 1}}, + {"$project": {"_id": 0, "name": 1}}, + ], + expected=[{"name": "a_1"}, {"name": "b_1"}, {"name": "c_1"}], + msg="$sort ascending should order indexes alphabetically", + ), + IndexStatsTestCase( + id="sort_by_name_descending", + indexes=[IndexModel([("a", 1)]), IndexModel([("b", 1)]), IndexModel([("c", 1)])], + docs=[], + pipeline=[ + {"$indexStats": {}}, + {"$match": {"name": {"$ne": "_id_"}}}, + {"$sort": {"name": -1}}, + {"$project": {"_id": 0, "name": 1}}, + ], + expected=[{"name": "c_1"}, {"name": "b_1"}, {"name": "a_1"}], + msg="$sort descending should reverse order", + ), +] + +# Property [Project Reshaping]: $project after $indexStats reshapes output +# documents. +PROJECT_RESHAPING_TESTS: list[IndexStatsTestCase] = [ + IndexStatsTestCase( + id="project_inclusion", + indexes=[IndexModel([("a", 1)])], + docs=[], + pipeline=[ + {"$indexStats": {}}, + {"$match": {"name": "a_1"}}, + {"$project": {"_id": 0, "name": 1, "key": 1}}, + ], + expected=[{"name": "a_1", "key": {"a": 1}}], + msg="$project inclusion should keep only specified fields", + ), + IndexStatsTestCase( + id="project_exclusion", + indexes=[IndexModel([("a", 1)])], + docs=[], + pipeline=[ + {"$indexStats": {}}, + {"$match": {"name": "a_1"}}, + {"$project": {"_id": 0, "host": 0, "accesses": 0, "spec": 0}}, + ], + expected=[{"name": "a_1", "key": {"a": 1}}], + msg="$project exclusion should remove specified fields", + ), + IndexStatsTestCase( + id="project_computed_field", + indexes=[IndexModel([("a", 1)])], + docs=[], + pipeline=[ + {"$indexStats": {}}, + {"$match": {"name": "a_1"}}, + { + "$project": { + "_id": 0, + "indexName": "$name", + "numKeys": {"$size": {"$objectToArray": "$key"}}, + } + }, + ], + expected=[{"indexName": "a_1", "numKeys": 1}], + msg="$project with expressions should compute new fields from indexStats output", + ), +] + +# Property [AddFields Augmentation]: $addFields after $indexStats adds +# computed fields to index documents. +ADD_FIELDS_TESTS: list[IndexStatsTestCase] = [ + IndexStatsTestCase( + id="add_fields_constant", + indexes=[IndexModel([("a", 1)])], + docs=[], + pipeline=[ + {"$indexStats": {}}, + {"$match": {"name": "a_1"}}, + {"$addFields": {"isUserIndex": True}}, + {"$project": {"_id": 0, "name": 1, "isUserIndex": 1}}, + ], + expected=[{"name": "a_1", "isUserIndex": True}], + msg="$addFields should augment indexStats documents with constants", + ), + IndexStatsTestCase( + id="add_fields_expression", + indexes=[IndexModel([("a", 1), ("b", -1)])], + docs=[], + pipeline=[ + {"$indexStats": {}}, + {"$match": {"name": "a_1_b_-1"}}, + {"$addFields": {"keyCount": {"$size": {"$objectToArray": "$key"}}}}, + {"$project": {"_id": 0, "name": 1, "keyCount": 1}}, + ], + expected=[{"name": "a_1_b_-1", "keyCount": 2}], + msg="$addFields with expression should compute from indexStats fields", + ), +] + +# Property [Group Aggregation]: $group after $indexStats aggregates across +# index documents. +GROUP_AGGREGATION_TESTS: list[IndexStatsTestCase] = [ + IndexStatsTestCase( + id="group_count_all", + indexes=[IndexModel([("a", 1)]), IndexModel([("b", 1)])], + docs=[], + pipeline=[ + {"$indexStats": {}}, + {"$group": {"_id": None, "count": {"$sum": 1}}}, + {"$project": {"_id": 0, "count": 1}}, + ], + expected=[{"count": 3}], # _id_ + a_1 + b_1 + msg="$group should count all indexes including _id", + ), + IndexStatsTestCase( + id="group_collect_names", + indexes=[IndexModel([("a", 1)]), IndexModel([("b", 1)])], + docs=[], + pipeline=[ + {"$indexStats": {}}, + {"$sort": {"name": 1}}, + {"$group": {"_id": None, "names": {"$push": "$name"}}}, + {"$project": {"_id": 0, "names": 1}}, + ], + expected=[{"names": ["_id_", "a_1", "b_1"]}], + msg="$group with $push should collect all index names", + ), +] + +# Property [Limit and Skip]: $limit and $skip after $indexStats control +# result pagination. +LIMIT_SKIP_TESTS: list[IndexStatsTestCase] = [ + IndexStatsTestCase( + id="limit_one", + indexes=[IndexModel([("a", 1)]), IndexModel([("b", 1)])], + docs=[], + pipeline=[ + {"$indexStats": {}}, + {"$sort": {"name": 1}}, + {"$limit": 1}, + {"$project": {"_id": 0, "name": 1}}, + ], + expected=[{"name": "_id_"}], + msg="$limit should restrict number of index documents returned", + ), + IndexStatsTestCase( + id="skip_one", + indexes=[IndexModel([("a", 1)]), IndexModel([("b", 1)])], + docs=[], + pipeline=[ + {"$indexStats": {}}, + {"$sort": {"name": 1}}, + {"$skip": 1}, + {"$project": {"_id": 0, "name": 1}}, + ], + expected=[{"name": "a_1"}, {"name": "b_1"}], + msg="$skip should skip index documents", + ), + IndexStatsTestCase( + id="skip_and_limit", + indexes=[ + IndexModel([("a", 1)]), + IndexModel([("b", 1)]), + IndexModel([("c", 1)]), + ], + docs=[], + pipeline=[ + {"$indexStats": {}}, + {"$sort": {"name": 1}}, + {"$skip": 1}, + {"$limit": 2}, + {"$project": {"_id": 0, "name": 1}}, + ], + expected=[{"name": "a_1"}, {"name": "b_1"}], + msg="$skip then $limit should paginate index results", + ), +] + +# Property [Unwind on Key]: $unwind after $indexStats can expand compound +# key fields into separate documents. +UNWIND_TESTS: list[IndexStatsTestCase] = [ + IndexStatsTestCase( + id="unwind_key_array", + indexes=[IndexModel([("a", 1), ("b", -1)])], + docs=[], + pipeline=[ + {"$indexStats": {}}, + {"$match": {"name": "a_1_b_-1"}}, + {"$project": {"_id": 0, "name": 1, "fields": {"$objectToArray": "$key"}}}, + {"$unwind": "$fields"}, + {"$project": {"name": 1, "fieldName": "$fields.k"}}, + ], + expected=[ + {"name": "a_1_b_-1", "fieldName": "a"}, + {"name": "a_1_b_-1", "fieldName": "b"}, + ], + msg="$unwind should expand key fields into separate documents", + ), +] + +# Property [ReplaceRoot]: $replaceRoot after $indexStats can promote +# nested fields to top level. +REPLACE_ROOT_TESTS: list[IndexStatsTestCase] = [ + IndexStatsTestCase( + id="replace_root_with_spec", + indexes=[IndexModel([("a", 1)])], + docs=[], + pipeline=[ + {"$indexStats": {}}, + {"$match": {"name": "a_1"}}, + {"$replaceRoot": {"newRoot": "$spec"}}, + {"$project": {"_id": 0, "key": 1, "name": 1}}, + ], + expected=[{"key": {"a": 1}, "name": "a_1"}], + msg="$replaceRoot should promote spec subdocument to top level", + ), +] + +# Property [Count]: $count after $indexStats counts index documents. +COUNT_TESTS: list[IndexStatsTestCase] = [ + IndexStatsTestCase( + id="count_all_indexes", + indexes=[IndexModel([("a", 1)]), IndexModel([("b", 1)])], + docs=[], + pipeline=[ + {"$indexStats": {}}, + {"$count": "totalIndexes"}, + ], + expected=[{"totalIndexes": 3}], # _id_ + a_1 + b_1 + msg="$count should return total number of indexes", + ), + IndexStatsTestCase( + id="count_after_match", + indexes=[ + IndexModel([("x", 1)]), + IndexModel([("y", 1)]), + IndexModel([("z", 1)]), + ], + docs=[], + pipeline=[ + {"$indexStats": {}}, + {"$match": {"name": {"$ne": "_id_"}}}, + {"$count": "userIndexes"}, + ], + expected=[{"userIndexes": 3}], + msg="$count after $match should count only matched indexes", + ), +] + +ALL_POSITION_TESTS = ( + FIRST_STAGE_TESTS + + NOT_FIRST_POSITION_TESTS + + FACET_RESTRICTION_TESTS + + MATCH_FILTERING_TESTS + + SORT_ORDERING_TESTS + + PROJECT_RESHAPING_TESTS + + ADD_FIELDS_TESTS + + GROUP_AGGREGATION_TESTS + + LIMIT_SKIP_TESTS + + UNWIND_TESTS + + REPLACE_ROOT_TESTS + + COUNT_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(ALL_POSITION_TESTS)) +def test_indexStats_position(collection: Collection, test_case: IndexStatsTestCase): + """Test $indexStats pipeline position requirements.""" + coll = prepare_collection(collection, test_case) + result = execute_command( + coll, + { + "aggregate": coll.name, + "pipeline": test_case.pipeline, + "cursor": {}, + }, + ) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/utils/stage_test_case.py b/documentdb_tests/compatibility/tests/core/operator/stages/utils/stage_test_case.py index 7a41a2d3..34c67aef 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/utils/stage_test_case.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/utils/stage_test_case.py @@ -9,6 +9,7 @@ from typing import Any from pymongo.collection import Collection +from pymongo.operations import IndexModel from documentdb_tests.framework.test_case import BaseTestCase @@ -17,6 +18,7 @@ class StageTestCase(BaseTestCase): """Test case for pipeline stage tests.""" + indexes: list[IndexModel] | None = None docs: list[dict[str, Any]] | None = None pipeline: list[dict[str, Any]] = field(default_factory=list) setup: Callable | None = None @@ -35,3 +37,5 @@ def populate_collection(collection: Collection, test_case: StageTestCase) -> Non collection.database.create_collection(collection.name) if test_case.docs: collection.insert_many(test_case.docs) + if test_case.indexes: + collection.create_indexes(test_case.indexes) diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 5b7b97a8..3a4f4b79 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -122,6 +122,7 @@ POW_BASE_ZERO_EXP_NEGATIVE_ERROR = 28764 NON_NUMERIC_TYPE_MISMATCH_ERROR = 28765 LN_NON_POSITIVE_INPUT_ERROR = 28766 +INDEX_STATS_ARG_ERROR = 28803 UNSET_SPEC_TYPE_ERROR = 31002 REGEX_MISSING_INPUT_ERROR = 31022 REGEX_MISSING_REGEX_ERROR = 31023