diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/facet/__init__.py b/documentdb_tests/compatibility/tests/core/operator/stages/facet/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_argument_validation.py b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_argument_validation.py new file mode 100644 index 000000000..70b58e26b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_argument_validation.py @@ -0,0 +1,75 @@ +"""Aggregation $facet stage tests - valid argument edge cases. + +Covers the positive TEST_COVERAGE.md §4 (Argument Handling) cases for $facet: +valid edge cases such as empty sub-pipelines, many sub-pipelines, and +unusual-but-valid output field names. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +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.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +DOCS = [{"_id": 1, "cat": "A"}, {"_id": 2, "cat": "B"}] + +LONG_NAME = "f" * 300 + +# Property [Valid Edge Cases]: empty sub-pipelines, many sub-pipelines, and +# unusual-but-valid output field names are accepted. +FACET_ARGUMENT_SUCCESS_TESTS: list[StageTestCase] = [ + StageTestCase( + id="many_subpipelines", + docs=DOCS, + pipeline=[{"$facet": {f"f{i}": [{"$count": "n"}] for i in range(50)}}], + expected=[{f"f{i}": [{"n": 2}] for i in range(50)}], + msg="A $facet with many valid sub-pipelines should complete without error", + ), + StageTestCase( + id="very_long_field_name", + docs=DOCS, + pipeline=[{"$facet": {LONG_NAME: [{"$count": "n"}]}}], + expected=[{LONG_NAME: [{"n": 2}]}], + msg="$facet should accept and preserve a very long output field name", + ), + StageTestCase( + id="unicode_field_name", + docs=DOCS, + pipeline=[{"$facet": {"日本語": [{"$count": "n"}]}}], + expected=[{"日本語": [{"n": 2}]}], + msg="$facet should accept and correctly retrieve a unicode output field name", + ), +] + +# Combined list for parametrization. +FACET_ARGUMENT_TESTS = FACET_ARGUMENT_SUCCESS_TESTS + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(FACET_ARGUMENT_TESTS)) +def test_facet_argument_validation(collection, test_case: StageTestCase): + """Test $facet valid argument edge cases.""" + coll = populate_collection(collection, test_case) + command: dict[str, Any] = { + "aggregate": coll.name, + "pipeline": test_case.pipeline, + "cursor": {}, + } + command.update(test_case.extra_command_fields) + result = execute_command(coll, command) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ignore_doc_order=test_case.ignore_doc_order, + ignore_order_in=test_case.ignore_order_in, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_bson_passthrough.py b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_bson_passthrough.py new file mode 100644 index 000000000..ca3dbb1b8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_bson_passthrough.py @@ -0,0 +1,168 @@ +"""Aggregation $facet stage tests - BSON type pass-through. + +Verifies that $facet passes documents of every standard BSON type through to +sub-pipelines without altering their values (TEST_COVERAGE.md §1 Data Type +Coverage, applied to the pass-through behaviour of the stage). +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +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.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [BSON Pass-Through]: $facet preserves one document of each standard +# BSON type inside its sub-pipelines. +FACET_BSON_PASSTHROUGH_TESTS: list[StageTestCase] = [ + StageTestCase( + id="int32", + docs=[{"_id": 1, "val": 42}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": 42}]}], + msg="$facet should pass an int32 value through to sub-pipelines unchanged", + ), + StageTestCase( + id="int64", + docs=[{"_id": 1, "val": Int64(42)}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": Int64(42)}]}], + msg="$facet should pass an int64 value through to sub-pipelines unchanged", + ), + StageTestCase( + id="double", + docs=[{"_id": 1, "val": 1.5}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": 1.5}]}], + msg="$facet should pass a double value through to sub-pipelines unchanged", + ), + StageTestCase( + id="decimal128", + docs=[{"_id": 1, "val": Decimal128("1.5")}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": Decimal128("1.5")}]}], + msg="$facet should pass a decimal128 value through to sub-pipelines unchanged", + ), + StageTestCase( + id="string", + docs=[{"_id": 1, "val": "hello"}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": "hello"}]}], + msg="$facet should pass a string value through to sub-pipelines unchanged", + ), + StageTestCase( + id="bool", + docs=[{"_id": 1, "val": True}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": True}]}], + msg="$facet should pass a boolean value through to sub-pipelines unchanged", + ), + StageTestCase( + id="date", + docs=[{"_id": 1, "val": datetime(2024, 1, 1, tzinfo=timezone.utc)}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": datetime(2024, 1, 1, tzinfo=timezone.utc)}]}], + msg="$facet should pass a date value through to sub-pipelines unchanged", + ), + StageTestCase( + id="null", + docs=[{"_id": 1, "val": None}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": None}]}], + msg="$facet should pass a null value through to sub-pipelines unchanged", + ), + StageTestCase( + id="object", + docs=[{"_id": 1, "val": {"a": 1, "b": {"c": 2}}}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": {"a": 1, "b": {"c": 2}}}]}], + msg="$facet should pass an object value through to sub-pipelines unchanged", + ), + StageTestCase( + id="array", + docs=[{"_id": 1, "val": [1, "two", 3.0]}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": [1, "two", 3.0]}]}], + msg="$facet should pass an array value through to sub-pipelines unchanged", + ), + StageTestCase( + id="objectId", + docs=[{"_id": 1, "val": ObjectId("507f1f77bcf86cd799439011")}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": ObjectId("507f1f77bcf86cd799439011")}]}], + msg="$facet should pass an objectId value through to sub-pipelines unchanged", + ), + StageTestCase( + id="binData", + docs=[{"_id": 1, "val": Binary(b"payload", 128)}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": Binary(b"payload", 128)}]}], + msg="$facet should pass a binary value through to sub-pipelines unchanged", + ), + StageTestCase( + id="timestamp", + docs=[{"_id": 1, "val": Timestamp(123, 4)}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": Timestamp(123, 4)}]}], + msg="$facet should pass a timestamp value through to sub-pipelines unchanged", + ), + StageTestCase( + id="minKey", + docs=[{"_id": 1, "val": MinKey()}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": MinKey()}]}], + msg="$facet should pass a minKey value through to sub-pipelines unchanged", + ), + StageTestCase( + id="maxKey", + docs=[{"_id": 1, "val": MaxKey()}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": MaxKey()}]}], + msg="$facet should pass a maxKey value through to sub-pipelines unchanged", + ), + StageTestCase( + id="regex", + docs=[{"_id": 1, "val": Regex(r"^hello", "i")}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": Regex(r"^hello", "i")}]}], + msg="$facet should pass a regex value through to sub-pipelines unchanged", + ), + StageTestCase( + id="javascript", + docs=[{"_id": 1, "val": Code("function() {}")}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "val": Code("function() {}")}]}], + msg="$facet should pass a javascript value through to sub-pipelines unchanged", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(FACET_BSON_PASSTHROUGH_TESTS)) +def test_facet_bson_passthrough(collection, test_case: StageTestCase): + """$facet passes a document field of each BSON type through unchanged.""" + coll = populate_collection(collection, test_case) + command: dict[str, Any] = { + "aggregate": coll.name, + "pipeline": test_case.pipeline, + "cursor": {}, + } + command.update(test_case.extra_command_fields) + result = execute_command(coll, command) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ignore_doc_order=test_case.ignore_doc_order, + ignore_order_in=test_case.ignore_order_in, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_collation.py b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_collation.py new file mode 100644 index 000000000..3ee161699 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_collation.py @@ -0,0 +1,76 @@ +"""Aggregation $facet stage tests - collation wiring. + +Per TEST_COVERAGE.md §19 (Foundational Spec Behaviors), collation semantics are +tested comprehensively under tests/core/collation/. These tests only verify +that $facet correctly wires the command-level collation into its sub-pipelines +(e.g. a case-insensitive $match and $sortByCount respect the collation). +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +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.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +CI = {"locale": "en", "strength": 2} # case-insensitive collation +DOCS = [{"_id": 1, "cat": "a"}, {"_id": 2, "cat": "A"}, {"_id": 3, "cat": "b"}] + +FACET_COLLATION_TESTS: list[StageTestCase] = [ + StageTestCase( + id="applies_to_subpipeline_match", + docs=DOCS, + pipeline=[{"$facet": {"ci": [{"$match": {"cat": "a"}}, {"$sort": {"_id": 1}}]}}], + expected=[{"ci": [{"_id": 1, "cat": "a"}, {"_id": 2, "cat": "A"}]}], + extra_command_fields={"collation": CI}, + msg="Case-insensitive collation should apply to a $match in a sub-pipeline", + ), + StageTestCase( + id="applies_to_subpipeline_sortByCount", + docs=DOCS, + pipeline=[ + { + "$facet": { + "byCat": [ + {"$sortByCount": "$cat"}, + {"$project": {"_id": 0, "count": 1}}, + {"$sort": {"count": -1}}, + ] + } + } + ], + expected=[{"byCat": [{"count": 2}, {"count": 1}]}], + extra_command_fields={"collation": CI}, + msg="Case-insensitive collation should merge 'a'/'A' in $sortByCount grouping", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.collation +@pytest.mark.parametrize("test_case", pytest_params(FACET_COLLATION_TESTS)) +def test_facet_collation(collection, test_case: StageTestCase): + """A command-level collation applies inside $facet sub-pipelines.""" + coll = populate_collection(collection, test_case) + command: dict[str, Any] = { + "aggregate": coll.name, + "pipeline": test_case.pipeline, + "cursor": {}, + } + command.update(test_case.extra_command_fields) + result = execute_command(coll, command) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ignore_doc_order=test_case.ignore_doc_order, + ignore_order_in=test_case.ignore_order_in, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_core_semantics.py b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_core_semantics.py new file mode 100644 index 000000000..722778268 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_core_semantics.py @@ -0,0 +1,157 @@ +"""Aggregation $facet stage tests - core pipeline stage semantics. + +Covers TEST_COVERAGE.md §15 (Pipeline Stage Coverage) core semantics for the +$facet stage: single/multiple sub-pipelines, sole-stage behavior, empty and +non-existent collections, empty sub-pipeline results, shared input snapshot, +single-output-document guarantee, always-array output fields, and case-sensitive +output field names. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +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.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, Len + +DOCS = [ + {"_id": 1, "category": "A", "price": 10}, + {"_id": 2, "category": "B", "price": 20}, + {"_id": 3, "category": "A", "price": 30}, +] + +# Property [Core Semantics]: $facet emits one output document, preserves +# sub-pipeline input independence, and produces one array per declared output +# field on empty, non-existent, and populated collections. +FACET_CORE_SEMANTICS_TESTS: list[StageTestCase] = [ + StageTestCase( + id="single_subpipeline", + docs=DOCS, + pipeline=[{"$facet": {"categoryA": [{"$match": {"category": "A"}}]}}], + expected=[ + { + "categoryA": [ + {"_id": 1, "category": "A", "price": 10}, + {"_id": 3, "category": "A", "price": 30}, + ] + } + ], + msg="Single sub-pipeline should yield one document with one array field", + ), + StageTestCase( + id="sole_stage", + docs=DOCS, + pipeline=[{"$facet": {"all": []}}], + expected=[{"all": DOCS}], + msg="$facet as the only stage should pass all docs through an empty sub-pipeline", + ), + StageTestCase( + id="empty_collection", + docs=[], + pipeline=[ + { + "$facet": { + "a": [{"$match": {"category": "A"}}], + "b": [{"$match": {"category": "B"}}], + } + } + ], + expected=[{"a": [], "b": []}], + msg="Empty collection should yield one document with empty arrays per sub-pipeline", + ), + StageTestCase( + id="nonexistent_collection", + docs=None, + pipeline=[ + { + "$facet": { + "a": [{"$match": {"category": "A"}}], + "b": [{"$match": {"category": "B"}}], + } + } + ], + expected=[{"a": [], "b": []}], + msg="Non-existent collection should yield one document with empty arrays per sub-pipeline", + ), + StageTestCase( + id="no_matching_documents", + docs=DOCS, + pipeline=[{"$facet": {"none": [{"$match": {"category": "Z"}}]}}], + expected=[{"none": []}], + msg="A non-matching sub-pipeline should return an empty array field", + ), + StageTestCase( + id="output_field_always_array", + docs=DOCS, + pipeline=[{"$facet": {"total": [{"$count": "n"}]}}], + expected={"total": [Len(1), Eq([{"n": 3}])]}, + msg="Output field should be a one-element array, not a bare document", + ), + StageTestCase( + id="output_field_names_case_sensitive", + docs=DOCS, + pipeline=[ + { + "$facet": { + "cat": [{"$match": {"category": "A"}}], + "Cat": [{"$match": {"category": "B"}}], + } + } + ], + expected=[ + { + "cat": [ + {"_id": 1, "category": "A", "price": 10}, + {"_id": 3, "category": "A", "price": 30}, + ], + "Cat": [{"_id": 2, "category": "B", "price": 20}], + } + ], + msg="Output field names 'cat' and 'Cat' should be distinct (case-sensitive)", + ), + StageTestCase( + id="skip_beyond_count_empty", + docs=DOCS, + pipeline=[{"$facet": {"a": [{"$skip": 100}]}}], + expected=[{"a": []}], + msg="$facet should yield an empty array when $skip exceeds the document count", + ), + StageTestCase( + id="allowDiskUse_true_operates_normally", + docs=DOCS, + pipeline=[{"$facet": {"n": [{"$count": "n"}]}}], + expected=[{"n": [{"n": 3}]}], + extra_command_fields={"allowDiskUse": True}, + msg="$facet should operate normally when allowDiskUse:true is set", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(FACET_CORE_SEMANTICS_TESTS)) +def test_facet_core_semantics(collection, test_case: StageTestCase): + """Test core semantic behaviours of the $facet stage.""" + coll = populate_collection(collection, test_case) + command: dict[str, Any] = { + "aggregate": coll.name, + "pipeline": test_case.pipeline, + "cursor": {}, + } + command.update(test_case.extra_command_fields) + result = execute_command(coll, command) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ignore_doc_order=test_case.ignore_doc_order, + ignore_order_in=test_case.ignore_order_in, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_errors.py b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_errors.py new file mode 100644 index 000000000..199c5b448 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_errors.py @@ -0,0 +1,412 @@ +"""Aggregation $facet stage tests - all error cases. + +Consolidates negative TEST_COVERAGE.md cases for the $facet stage: argument +handling, parse-time validation, forbidden sub-pipeline stages, sub-pipeline +runtime errors, output size limits, and boundary behaviours. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +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 ( + BAD_VALUE_ERROR, + BSON_OBJECT_TOO_LARGE_ERROR, + FACET_PIPELINE_INVALID_STAGE_ERROR, + FACET_PIPELINE_NOT_ARRAY_ERROR, + FACET_PIPELINE_STAGE_NOT_OBJECT_ERROR, + FACET_SPEC_NOT_OBJECT_ERROR, + FIELD_PATH_DOLLAR_PREFIX_ERROR, + FIELD_PATH_DOT_ERROR, + FIELD_PATH_EMPTY_COMPONENT_ERROR, + INVALID_NAMESPACE_ERROR, + LIMIT_NOT_POSITIVE_ERROR, + PIPELINE_STAGE_EXTRA_FIELD_ERROR, + UNKNOWN_PIPELINE_STAGE_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Documents used by the argument, forbidden-stage, and independence error cases. +DOCS = [{"_id": 1, "cat": "A"}, {"_id": 2, "cat": "B"}] + +# Documents used by the boundary/core-semantics error case. +CORE_DOCS = [ + {"_id": 1, "category": "A", "price": 10}, + {"_id": 2, "category": "B", "price": 20}, + {"_id": 3, "category": "A", "price": 30}, +] + +# Documents used by the output size limit error cases. +_BIG_STRING = "x" * 100_000 +_BIG_DOCS = [{"_id": i, "s": _BIG_STRING} for i in range(200)] + +# Property [Specification Type]: the $facet argument must be a non-empty object; +# non-objects, null, and an empty object are all rejected with 40169. +FACET_SPEC_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + id="spec_number", + docs=DOCS, + pipeline=[{"$facet": 1}], + error_code=FACET_SPEC_NOT_OBJECT_ERROR, + msg="Numeric $facet specification should be rejected", + ), + StageTestCase( + id="spec_string", + docs=DOCS, + pipeline=[{"$facet": "x"}], + error_code=FACET_SPEC_NOT_OBJECT_ERROR, + msg="String $facet specification should be rejected", + ), + StageTestCase( + id="spec_bool", + docs=DOCS, + pipeline=[{"$facet": True}], + error_code=FACET_SPEC_NOT_OBJECT_ERROR, + msg="Boolean $facet specification should be rejected", + ), + StageTestCase( + id="spec_array", + docs=DOCS, + pipeline=[{"$facet": [1, 2]}], + error_code=FACET_SPEC_NOT_OBJECT_ERROR, + msg="Array $facet specification should be rejected", + ), + StageTestCase( + id="spec_null", + docs=DOCS, + pipeline=[{"$facet": None}], + error_code=FACET_SPEC_NOT_OBJECT_ERROR, + msg="Null $facet specification should be rejected", + ), + StageTestCase( + id="spec_empty_object", + docs=DOCS, + pipeline=[{"$facet": {}}], + error_code=FACET_SPEC_NOT_OBJECT_ERROR, + msg="Empty $facet specification object should be rejected", + ), +] + +# Property [Sub-Pipeline Type]: each output field's value must be an array; +# non-array values are rejected with 40170. +FACET_PIPELINE_TYPE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + id="subpipeline_string", + docs=DOCS, + pipeline=[{"$facet": {"a": "notArray"}}], + error_code=FACET_PIPELINE_NOT_ARRAY_ERROR, + msg="String sub-pipeline value should be rejected", + ), + StageTestCase( + id="subpipeline_number", + docs=DOCS, + pipeline=[{"$facet": {"a": 5}}], + error_code=FACET_PIPELINE_NOT_ARRAY_ERROR, + msg="Numeric sub-pipeline value should be rejected", + ), + StageTestCase( + id="subpipeline_object", + docs=DOCS, + pipeline=[{"$facet": {"a": {"$match": {}}}}], + error_code=FACET_PIPELINE_NOT_ARRAY_ERROR, + msg="Object sub-pipeline value should be rejected", + ), +] + +# Property [Sub-Pipeline Element Type]: every element of a sub-pipeline array +# must be a stage document; non-documents and null are rejected with 40171. +FACET_PIPELINE_ELEMENT_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + id="element_number", + docs=DOCS, + pipeline=[{"$facet": {"a": [1]}}], + error_code=FACET_PIPELINE_STAGE_NOT_OBJECT_ERROR, + msg="Numeric element in a sub-pipeline should be rejected", + ), + StageTestCase( + id="element_string", + docs=DOCS, + pipeline=[{"$facet": {"a": ["x"]}}], + error_code=FACET_PIPELINE_STAGE_NOT_OBJECT_ERROR, + msg="String element in a sub-pipeline should be rejected", + ), + StageTestCase( + id="element_null", + docs=DOCS, + pipeline=[{"$facet": {"a": [None]}}], + error_code=FACET_PIPELINE_STAGE_NOT_OBJECT_ERROR, + msg="Null element in a sub-pipeline should be rejected", + ), +] + +# Property [Stage Name Validation]: unknown stage names and names without a +# leading '$' inside a sub-pipeline are rejected with 40324 at parse time. +FACET_STAGE_NAME_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + id="unknown_stage", + docs=DOCS, + pipeline=[{"$facet": {"a": [{"$notAStage": {}}]}}], + error_code=UNKNOWN_PIPELINE_STAGE_ERROR, + msg="Unknown stage name in a sub-pipeline should be rejected", + ), + StageTestCase( + id="stage_name_no_dollar", + docs=DOCS, + pipeline=[{"$facet": {"a": [{"match": {}}]}}], + error_code=UNKNOWN_PIPELINE_STAGE_ERROR, + msg="Stage name without a leading '$' should be rejected", + ), +] + +# Property [Field Name Validation]: invalid output field name forms (empty string, +# dotted path, dollar-prefix, double-dollar system-variable) are each rejected with +# the appropriate field-path error code. +FACET_FIELD_NAME_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + id="empty_string_field_name", + docs=DOCS, + pipeline=[{"$facet": {"": [{"$match": {}}]}}], + error_code=FIELD_PATH_EMPTY_COMPONENT_ERROR, + msg="Empty-string output field name should be rejected", + ), + StageTestCase( + id="dotted_field_name", + docs=DOCS, + pipeline=[{"$facet": {"a.b": [{"$count": "n"}]}}], + error_code=FIELD_PATH_DOT_ERROR, + msg="Dotted output field name should be rejected", + ), + StageTestCase( + id="dollar_prefix_field_name", + docs=DOCS, + pipeline=[{"$facet": {"$x": [{"$count": "n"}]}}], + error_code=FIELD_PATH_DOLLAR_PREFIX_ERROR, + msg="Dollar-prefixed output field name should be rejected", + ), + StageTestCase( + id="double_dollar_field_name", + docs=DOCS, + pipeline=[{"$facet": {"$$ROOT": [{"$count": "n"}]}}], + error_code=FIELD_PATH_DOLLAR_PREFIX_ERROR, + msg="System-variable-like output field name should be rejected", + ), +] + +# Property [Extra Stage Fields]: an unexpected top-level key alongside $facet in +# the stage document is rejected with 40323. +FACET_EXTRA_FIELD_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + id="extra_top_level_key", + docs=DOCS, + pipeline=[{"$facet": {"a": [{"$count": "n"}]}, "extraKey": 1}], + error_code=PIPELINE_STAGE_EXTRA_FIELD_ERROR, + msg="An unexpected extra top-level key in the $facet stage should be rejected", + ), +] + +# Property [Parse-Time Validation]: structural errors fire even on empty and +# non-existent collections, before any documents are processed. +FACET_PARSE_TIME_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + id="empty_collection_unknown_stage", + docs=[], + pipeline=[{"$facet": {"a": [{"$notAStage": {}}]}}], + error_code=UNKNOWN_PIPELINE_STAGE_ERROR, + msg="Unknown stage should be rejected at parse time on an empty collection", + ), + StageTestCase( + id="nonexistent_collection_unknown_stage", + docs=None, + pipeline=[{"$facet": {"a": [{"$notAStage": {}}]}}], + error_code=UNKNOWN_PIPELINE_STAGE_ERROR, + msg="Unknown stage should be rejected at parse time on a non-existent collection", + ), + StageTestCase( + id="empty_collection_non_array_pipeline", + docs=[], + pipeline=[{"$facet": {"a": "notArray"}}], + error_code=FACET_PIPELINE_NOT_ARRAY_ERROR, + msg="Non-array sub-pipeline should be rejected at parse time on an empty collection", + ), +] + +# Property [Core Semantic Boundary]: $limit 0 inside a sub-pipeline is rejected. +FACET_BOUNDARY_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + id="limit_zero_rejected", + docs=CORE_DOCS, + pipeline=[{"$facet": {"a": [{"$limit": 0}]}}], + error_code=LIMIT_NOT_POSITIVE_ERROR, + msg="$facet should reject $limit 0 inside a sub-pipeline", + ), +] + +# Property [Forbidden Sub-Pipeline Stages]: stages that cannot run inside a +# $facet sub-pipeline are rejected with 40600, independent of documents. +FACET_FORBIDDEN_STAGE_TESTS: list[StageTestCase] = [ + StageTestCase( + id="collStats", + docs=DOCS, + pipeline=[{"$facet": {"a": [{"$collStats": {}}]}}], + error_code=FACET_PIPELINE_INVALID_STAGE_ERROR, + msg="$collStats inside a $facet sub-pipeline should be rejected with 40600", + ), + StageTestCase( + id="nested_facet", + docs=DOCS, + pipeline=[{"$facet": {"a": [{"$facet": {"b": [{"$match": {}}]}}]}}], + error_code=FACET_PIPELINE_INVALID_STAGE_ERROR, + msg="Nested $facet should be rejected with 40600", + ), + StageTestCase( + id="geoNear", + docs=DOCS, + pipeline=[ + { + "$facet": { + "a": [ + { + "$geoNear": { + "near": {"type": "Point", "coordinates": [0, 0]}, + "distanceField": "d", + "spherical": True, + } + } + ] + } + } + ], + error_code=FACET_PIPELINE_INVALID_STAGE_ERROR, + msg="$geoNear inside a $facet sub-pipeline should be rejected with 40600", + ), + StageTestCase( + id="indexStats", + docs=DOCS, + pipeline=[{"$facet": {"a": [{"$indexStats": {}}]}}], + error_code=FACET_PIPELINE_INVALID_STAGE_ERROR, + msg="$indexStats inside a $facet sub-pipeline should be rejected with 40600", + ), + StageTestCase( + id="out", + docs=DOCS, + pipeline=[{"$facet": {"a": [{"$out": "outcoll"}]}}], + error_code=FACET_PIPELINE_INVALID_STAGE_ERROR, + msg="$out inside a $facet sub-pipeline should be rejected with 40600", + ), + StageTestCase( + id="merge", + docs=DOCS, + pipeline=[{"$facet": {"a": [{"$merge": {"into": "mcoll"}}]}}], + error_code=FACET_PIPELINE_INVALID_STAGE_ERROR, + msg="$merge inside a $facet sub-pipeline should be rejected with 40600", + ), + StageTestCase( + id="planCacheStats", + docs=DOCS, + pipeline=[{"$facet": {"a": [{"$planCacheStats": {}}]}}], + error_code=FACET_PIPELINE_INVALID_STAGE_ERROR, + msg="$planCacheStats inside a $facet sub-pipeline should be rejected with 40600", + ), + StageTestCase( + id="listSessions", + docs=DOCS, + pipeline=[{"$facet": {"a": [{"$listSessions": {}}]}}], + error_code=INVALID_NAMESPACE_ERROR, + msg="$listSessions inside a $facet sub-pipeline should be rejected with 73", + ), + StageTestCase( + id="valid_plus_forbidden", + docs=DOCS, + pipeline=[{"$facet": {"ok": [{"$match": {"cat": "A"}}], "bad": [{"$out": "outcoll"}]}}], + error_code=FACET_PIPELINE_INVALID_STAGE_ERROR, + msg="A forbidden stage in any sub-pipeline should fail the whole $facet", + ), +] + +# Property [Sub-Pipeline Independence]: a runtime error in any sub-pipeline fails +# the whole $facet stage. +INDEPENDENCE_DOCS = [ + {"_id": 1, "cat": "A", "v": 10}, + {"_id": 2, "cat": "A", "v": 20}, + {"_id": 3, "cat": "B", "v": 30}, +] +FACET_INDEPENDENCE_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + id="subpipeline_error_fails_whole_stage", + docs=INDEPENDENCE_DOCS, + pipeline=[ + { + "$facet": { + "ok": [{"$match": {"cat": "A"}}], + "bad": [{"$project": {"x": {"$divide": [1, 0]}}}], + } + } + ], + error_code=BAD_VALUE_ERROR, + msg="An error in any sub-pipeline should fail the whole $facet stage", + ), +] + +# Property [Output Size Limits]: the final $facet output document is subject to +# the 16 MiB BSON limit, and allowDiskUse does not raise that limit. +FACET_SIZE_LIMIT_ERROR_TESTS: list[StageTestCase] = [ + StageTestCase( + id="output_exceeds_16mib", + docs=_BIG_DOCS, + pipeline=[{"$facet": {"all": [{"$group": {"_id": None, "arr": {"$push": "$s"}}}]}}], + error_code=BSON_OBJECT_TOO_LARGE_ERROR, + msg="A $facet output document exceeding 16 MiB should be rejected", + ), + StageTestCase( + id="allowdiskuse_does_not_raise_16mib_limit", + docs=_BIG_DOCS, + pipeline=[{"$facet": {"all": [{"$group": {"_id": None, "arr": {"$push": "$s"}}}]}}], + error_code=BSON_OBJECT_TOO_LARGE_ERROR, + extra_command_fields={"allowDiskUse": True}, + msg="allowDiskUse:true should not raise the 16 MiB output-document limit", + ), +] + +FACET_ERROR_TESTS = ( + FACET_SPEC_TYPE_ERROR_TESTS + + FACET_PIPELINE_TYPE_ERROR_TESTS + + FACET_PIPELINE_ELEMENT_ERROR_TESTS + + FACET_STAGE_NAME_ERROR_TESTS + + FACET_FIELD_NAME_ERROR_TESTS + + FACET_EXTRA_FIELD_ERROR_TESTS + + FACET_PARSE_TIME_ERROR_TESTS + + FACET_BOUNDARY_ERROR_TESTS + + FACET_FORBIDDEN_STAGE_TESTS + + FACET_INDEPENDENCE_ERROR_TESTS + + FACET_SIZE_LIMIT_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(FACET_ERROR_TESTS)) +def test_facet_errors(collection, test_case: StageTestCase): + """Test all $facet error cases.""" + coll = populate_collection(collection, test_case) + command: dict[str, Any] = { + "aggregate": coll.name, + "pipeline": test_case.pipeline, + "cursor": {}, + } + command.update(test_case.extra_command_fields) + result = execute_command(coll, command) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ignore_doc_order=test_case.ignore_doc_order, + ignore_order_in=test_case.ignore_order_in, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_independence.py b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_independence.py new file mode 100644 index 000000000..865386908 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_independence.py @@ -0,0 +1,118 @@ +"""Aggregation $facet stage tests - sub-pipeline independence. + +Verifies that $facet sub-pipelines are fully independent: they share the same +input snapshot, one sub-pipeline's transformations do not affect another's +input, and a $limit/$match in one does not reduce the input seen by others. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +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.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +DOCS = [ + {"_id": 1, "cat": "A", "v": 10}, + {"_id": 2, "cat": "A", "v": 20}, + {"_id": 3, "cat": "B", "v": 30}, +] + +# Property [Sub-Pipeline Independence]: a $limit, $match, or $addFields in one +# sub-pipeline does not affect the input seen by another; a runtime error in any +# sub-pipeline fails the whole stage. +FACET_INDEPENDENCE_TESTS: list[StageTestCase] = [ + StageTestCase( + id="limit_in_one_does_not_reduce_input", + docs=DOCS, + pipeline=[ + { + "$facet": { + "limited": [{"$sort": {"_id": 1}}, {"$limit": 1}], + "counted": [{"$count": "n"}], + } + } + ], + expected=[{"limited": [DOCS[0]], "counted": [{"n": 3}]}], + msg="A $limit in one sub-pipeline must not affect another sub-pipeline's input", + ), + StageTestCase( + id="count_counts_all_input", + docs=DOCS, + pipeline=[ + { + "$facet": { + "filtered": [{"$match": {"cat": "A"}}], + "counted": [{"$count": "n"}], + } + } + ], + expected=[{"filtered": [DOCS[0], DOCS[1]], "counted": [{"n": 3}]}], + msg="$count should count all input documents regardless of sibling sub-pipelines", + ), + StageTestCase( + id="transformation_does_not_leak", + docs=DOCS, + pipeline=[ + { + "$facet": { + "adder": [{"$addFields": {"extra": 1}}, {"$match": {"_id": 1}}], + "viewer": [{"$match": {"_id": 1}}, {"$project": {"_id": 1, "extra": 1}}], + } + } + ], + expected=[{"adder": [{"_id": 1, "cat": "A", "v": 10, "extra": 1}], "viewer": [{"_id": 1}]}], + msg="A field added in one sub-pipeline must not leak into another sub-pipeline", + ), + StageTestCase( + id="same_result_as_independent_pipelines", + docs=DOCS, + pipeline=[ + { + "$facet": { + "byCat": [ + {"$group": {"_id": "$cat", "sum": {"$sum": "$v"}}}, + {"$sort": {"_id": 1}}, + ], + "high": [{"$match": {"v": {"$gte": 20}}}, {"$sort": {"_id": 1}}], + } + } + ], + expected=[ + { + "byCat": [{"_id": "A", "sum": 30}, {"_id": "B", "sum": 30}], + "high": [DOCS[1], DOCS[2]], + } + ], + msg="Sub-pipeline results should equal running each pipeline independently", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(FACET_INDEPENDENCE_TESTS)) +def test_facet_independence(collection, test_case: StageTestCase): + """Test sub-pipeline independence of the $facet stage.""" + coll = populate_collection(collection, test_case) + command: dict[str, Any] = { + "aggregate": coll.name, + "pipeline": test_case.pipeline, + "cursor": {}, + } + command.update(test_case.extra_command_fields) + result = execute_command(coll, command) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ignore_doc_order=test_case.ignore_doc_order, + ignore_order_in=test_case.ignore_order_in, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_output_structure.py b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_output_structure.py new file mode 100644 index 000000000..465885cb7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_output_structure.py @@ -0,0 +1,105 @@ +"""Aggregation $facet stage tests - output document structure. + +Covers TEST_COVERAGE.md §15 (Document Handling): the single output document, +one array field per sub-pipeline, output-field order matching declaration +order, in-array document order preservation, and faithful representation of +nested arrays, null fields, array-of-arrays, and many-field documents. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +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.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, OrderedKeys + +DOCS = [ + {"_id": 1, "cat": "A", "v": 30}, + {"_id": 2, "cat": "B", "v": 10}, + {"_id": 3, "cat": "C", "v": 20}, +] + +MANY_FIELD_DOC = {"_id": 1} +MANY_FIELD_DOC.update({f"k{i}": i for i in range(30)}) + +# Property [Output Structure]: $facet produces a single document with one array +# per sub-pipeline; array order, output-field order, and nested/special values are +# preserved. +FACET_OUTPUT_STRUCTURE_TESTS: list[StageTestCase] = [ + StageTestCase( + id="array_preserves_subpipeline_order", + docs=DOCS, + pipeline=[{"$facet": {"sorted": [{"$sort": {"v": 1}}, {"$project": {"_id": 1}}]}}], + expected=[{"sorted": [{"_id": 2}, {"_id": 3}, {"_id": 1}]}], + msg="Output array should preserve the sub-pipeline's document order", + ), + StageTestCase( + id="field_order_matches_declaration", + docs=DOCS, + pipeline=[ + {"$facet": {"z": [{"$count": "n"}], "a": [{"$count": "n"}], "m": [{"$count": "n"}]}} + ], + expected={"": OrderedKeys(["z", "a", "m"])}, + msg="Output field order should match sub-pipeline declaration order", + ), + StageTestCase( + id="one_field_per_subpipeline", + docs=DOCS, + pipeline=[{"$facet": {"one": [{"$count": "n"}], "two": [{"$count": "n"}]}}], + expected=[{"one": [{"n": 3}], "two": [{"n": 3}]}], + msg="Output should contain exactly one field per sub-pipeline", + ), + StageTestCase( + id="nested_arrays_preserved", + docs=[{"_id": 1, "nested": [[1, 2], [3, 4]]}], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [{"_id": 1, "nested": [[1, 2], [3, 4]]}]}], + msg="Nested arrays should be preserved in the output array", + ), + StageTestCase( + id="array_of_arrays_from_push", + docs=[{"_id": 1, "arr": [1, 2]}, {"_id": 2, "arr": [3, 4]}], + pipeline=[ + {"$facet": {"grouped": [{"$group": {"_id": None, "pushed": {"$push": "$arr"}}}]}} + ], + expected={"grouped": Eq([{"_id": None, "pushed": [[1, 2], [3, 4]]}])}, + msg="$push of an array field should yield an array-of-arrays in the output", + ), + StageTestCase( + id="many_field_document", + docs=[MANY_FIELD_DOC], + pipeline=[{"$facet": {"docs": [{"$match": {"_id": 1}}]}}], + expected=[{"docs": [MANY_FIELD_DOC]}], + msg="A many-field document should appear intact in the output array", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(FACET_OUTPUT_STRUCTURE_TESTS)) +def test_facet_output_structure(collection, test_case: StageTestCase): + """Test output document structure of the $facet stage.""" + coll = populate_collection(collection, test_case) + command: dict[str, Any] = { + "aggregate": coll.name, + "pipeline": test_case.pipeline, + "cursor": {}, + } + command.update(test_case.extra_command_fields) + result = execute_command(coll, command) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ignore_doc_order=test_case.ignore_doc_order, + ignore_order_in=test_case.ignore_order_in, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_subpipeline_stages.py b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_subpipeline_stages.py new file mode 100644 index 000000000..c1836544c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/facet/test_facet_subpipeline_stages.py @@ -0,0 +1,413 @@ +"""Aggregation $facet stage tests - sub-pipeline stage support. + +Verifies that a variety of aggregation stages work inside $facet sub-pipelines +and produce the expected output arrays. Per the container-features rule +(FOLDER_STRUCTURE.md), these are one-case-per-sub-feature smoke tests, not +exhaustive edge-case coverage of each inner stage. Also covers JS-derived +use-case scenarios (stages.js, use_cases.js). +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.utils.stage_test_case import ( + StageTestCase, + populate_collection, +) +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, Len + +DOCS = [ + {"_id": 1, "cat": "A", "price": 10, "tags": ["x", "y"]}, + {"_id": 2, "cat": "A", "price": 20, "tags": ["y"]}, + {"_id": 3, "cat": "B", "price": 30, "tags": ["z"]}, + {"_id": 4, "cat": "C", "price": 40, "tags": []}, +] + +# Property [Sub-Pipeline Stage Support]: common aggregation stages and +# use-case combinations produce the expected output arrays inside $facet. +FACET_SUBPIPELINE_STAGE_TESTS: list[StageTestCase] = [ + StageTestCase( + id="sortByCount", + docs=DOCS, + pipeline=[{"$facet": {"byCat": [{"$sortByCount": "$cat"}]}}], + expected=[ + { + "byCat": [ + {"_id": "A", "count": 2}, + {"_id": "B", "count": 1}, + {"_id": "C", "count": 1}, + ] + } + ], + msg="$sortByCount sub-pipeline should return {_id, count} documents sorted by count", + ignore_order_in=["byCat"], + ), + StageTestCase( + id="bucket", + docs=DOCS, + pipeline=[ + {"$facet": {"buckets": [{"$bucket": {"groupBy": "$price", "boundaries": [0, 25, 50]}}]}} + ], + expected=[{"buckets": [{"_id": 0, "count": 2}, {"_id": 25, "count": 2}]}], + msg="$bucket sub-pipeline should return bucket documents", + ), + StageTestCase( + id="bucketAuto", + docs=DOCS, + pipeline=[{"$facet": {"auto": [{"$bucketAuto": {"groupBy": "$price", "buckets": 2}}]}}], + expected=[ + { + "auto": [ + {"_id": {"min": 10, "max": 30}, "count": 2}, + {"_id": {"min": 30, "max": 40}, "count": 2}, + ] + } + ], + msg="$bucketAuto sub-pipeline should return auto-bucket documents", + ), + StageTestCase( + id="match_count", + docs=DOCS, + pipeline=[{"$facet": {"nA": [{"$match": {"cat": "A"}}, {"$count": "n"}]}}], + expected=[{"nA": [{"n": 2}]}], + msg="$match + $count sub-pipeline should return a single count document", + ), + StageTestCase( + id="group_sort", + docs=DOCS, + pipeline=[ + { + "$facet": { + "totals": [ + {"$group": {"_id": "$cat", "total": {"$sum": "$price"}}}, + {"$sort": {"_id": 1}}, + ] + } + } + ], + expected=[ + { + "totals": [ + {"_id": "A", "total": 30}, + {"_id": "B", "total": 30}, + {"_id": "C", "total": 40}, + ] + } + ], + msg="$group + $sort sub-pipeline should return sorted grouped results", + ), + StageTestCase( + id="limit", + docs=DOCS, + pipeline=[{"$facet": {"first2": [{"$sort": {"_id": 1}}, {"$limit": 2}]}}], + expected=[{"first2": [DOCS[0], DOCS[1]]}], + msg="$limit sub-pipeline should return at most N documents", + ), + StageTestCase( + id="skip", + docs=DOCS, + pipeline=[{"$facet": {"rest": [{"$sort": {"_id": 1}}, {"$skip": 2}]}}], + expected=[{"rest": [DOCS[2], DOCS[3]]}], + msg="$skip sub-pipeline should skip the specified number of documents", + ), + StageTestCase( + id="project", + docs=DOCS, + pipeline=[ + {"$facet": {"proj": [{"$sort": {"_id": 1}}, {"$project": {"_id": 0, "cat": 1}}]}} + ], + expected=[{"proj": [{"cat": "A"}, {"cat": "A"}, {"cat": "B"}, {"cat": "C"}]}], + msg="$project sub-pipeline should return only projected fields", + ), + StageTestCase( + id="unwind", + docs=DOCS, + pipeline=[ + { + "$facet": { + "unwound": [ + {"$match": {"_id": 1}}, + {"$unwind": "$tags"}, + {"$project": {"_id": 1, "tags": 1}}, + ] + } + } + ], + expected=[{"unwound": [{"_id": 1, "tags": "x"}, {"_id": 1, "tags": "y"}]}], + msg="$unwind sub-pipeline should return unwound documents", + ), + StageTestCase( + id="addFields", + docs=DOCS, + pipeline=[ + { + "$facet": { + "withDouble": [ + {"$match": {"_id": 1}}, + {"$addFields": {"double": {"$multiply": ["$price", 2]}}}, + ] + } + } + ], + expected=[ + {"withDouble": [{"_id": 1, "cat": "A", "price": 10, "tags": ["x", "y"], "double": 20}]} + ], + msg="$addFields sub-pipeline should add a computed field", + ), + StageTestCase( + id="replaceRoot", + docs=DOCS, + pipeline=[ + { + "$facet": { + "replaced": [ + {"$match": {"_id": 1}}, + {"$replaceRoot": {"newRoot": {"only": "$cat"}}}, + ] + } + } + ], + expected=[{"replaced": [{"only": "A"}]}], + msg="$replaceRoot sub-pipeline should replace the document root", + ), + StageTestCase( + id="multiple_chained_stages", + docs=DOCS, + pipeline=[ + { + "$facet": { + "chain": [ + {"$match": {"cat": "A"}}, + {"$group": {"_id": None, "total": {"$sum": "$price"}}}, + {"$project": {"_id": 0, "total": 1}}, + ] + } + } + ], + expected=[{"chain": [{"total": 30}]}], + msg="A chained sub-pipeline should return the result of the full chain", + ), + StageTestCase( + id="two_group_subpipelines", + docs=DOCS, + pipeline=[ + { + "$facet": { + "byCat": [ + {"$group": {"_id": "$cat", "sum": {"$sum": "$price"}}}, + {"$sort": {"_id": 1}}, + ], + "byId": [ + {"$group": {"_id": "$_id", "sum": {"$sum": "$price"}}}, + {"$sort": {"_id": 1}}, + ], + } + } + ], + expected=[ + { + "byCat": [ + {"_id": "A", "sum": 30}, + {"_id": "B", "sum": 30}, + {"_id": "C", "sum": 40}, + ], + "byId": [ + {"_id": 1, "sum": 10}, + {"_id": 2, "sum": 20}, + {"_id": 3, "sum": 30}, + {"_id": 4, "sum": 40}, + ], + } + ], + msg="Two $group sub-pipelines should each return their own grouped results", + ), + StageTestCase( + id="multi_subpipeline_use_case", + docs=DOCS, + pipeline=[ + { + "$facet": { + "byCat": [{"$sortByCount": "$cat"}, {"$sort": {"count": -1, "_id": 1}}], + "priceBuckets": [ + { + "$bucket": { + "groupBy": "$price", + "boundaries": [0, 25, 50], + "default": "other", + } + } + ], + "autoBuckets": [{"$bucketAuto": {"groupBy": "$price", "buckets": 2}}], + } + } + ], + expected=[ + { + "byCat": [ + {"_id": "A", "count": 2}, + {"_id": "B", "count": 1}, + {"_id": "C", "count": 1}, + ], + "priceBuckets": [{"_id": 0, "count": 2}, {"_id": 25, "count": 2}], + "autoBuckets": [ + {"_id": {"min": 10, "max": 30}, "count": 2}, + {"_id": {"min": 30, "max": 40}, "count": 2}, + ], + } + ], + msg="Multi-sub-pipeline use case should return three independent result arrays", + ), + StageTestCase( + id="match_dotted_array_path_count", + docs=DOCS, + pipeline=[{"$facet": {"withY": [{"$match": {"tags": "y"}}, {"$count": "n"}]}}], + expected=[{"withY": [{"n": 2}]}], + msg="$match on an array field + $count should count matching documents", + ), + StageTestCase( + id="nonexistent_field_no_crash", + docs=DOCS, + pipeline=[ + { + "$facet": { + "byMissing": [{"$sortByCount": "$doesNotExist"}], + "autoMissing": [{"$bucketAuto": {"groupBy": "$doesNotExist", "buckets": 1}}], + } + } + ], + expected={ + "byMissing": Eq([{"_id": None, "count": 4}]), + "autoMissing": [Len(1), Eq([{"_id": {"min": None, "max": None}, "count": 4}])], + }, + msg="Sub-pipelines on a non-existent field should not crash and return one doc each", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(FACET_SUBPIPELINE_STAGE_TESTS)) +def test_facet_subpipeline_stages(collection, test_case: StageTestCase): + """Test that common stages work correctly inside $facet sub-pipelines.""" + coll = populate_collection(collection, test_case) + command: dict[str, Any] = { + "aggregate": coll.name, + "pipeline": test_case.pipeline, + "cursor": {}, + } + command.update(test_case.extra_command_fields) + result = execute_command(coll, command) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ignore_doc_order=test_case.ignore_doc_order, + ignore_order_in=test_case.ignore_order_in, + ) + + +@pytest.mark.aggregate +def test_facet_subpipeline_lookup_basic(collection): + """A simple equi-join $lookup inside a $facet sub-pipeline returns joined results.""" + collection.insert_many([{"_id": 1, "lf": "a"}, {"_id": 2, "lf": "b"}]) + foreign = f"{collection.name}_foreign" + db = collection.database + db[foreign].insert_many([{"_id": 10, "ff": "a"}, {"_id": 11, "ff": "b"}]) + try: + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$facet": { + "joined": [ + { + "$lookup": { + "from": foreign, + "localField": "lf", + "foreignField": "ff", + "as": "j", + } + }, + {"$sort": {"_id": 1}}, + ] + } + } + ], + "cursor": {}, + }, + ) + assertSuccess( + result, + [ + { + "joined": [ + {"_id": 1, "lf": "a", "j": [{"_id": 10, "ff": "a"}]}, + {"_id": 2, "lf": "b", "j": [{"_id": 11, "ff": "b"}]}, + ] + } + ], + msg="Equi-join $lookup inside $facet should return joined arrays", + ) + finally: + db.drop_collection(foreign) + + +@pytest.mark.aggregate +def test_facet_subpipeline_graphlookup_basic(collection): + """A $graphLookup inside a $facet sub-pipeline returns the traversal.""" + collection.insert_many( + [ + {"_id": 1, "name": "a", "reportsTo": None}, + {"_id": 2, "name": "b", "reportsTo": "a"}, + {"_id": 3, "name": "c", "reportsTo": "b"}, + ] + ) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$facet": { + "graph": [ + {"$match": {"_id": 3}}, + { + "$graphLookup": { + "from": collection.name, + "startWith": "$reportsTo", + "connectFromField": "reportsTo", + "connectToField": "name", + "as": "chain", + } + }, + { + "$project": { + "_id": 1, + "chainNames": { + "$sortArray": { + "input": "$chain.name", + "sortBy": 1, + } + }, + } + }, + ] + } + } + ], + "cursor": {}, + }, + ) + assertSuccess( + result, + [{"graph": [{"_id": 3, "chainNames": ["a", "b"]}]}], + msg="$graphLookup inside $facet should return the traversal chain", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_facet.py b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_facet.py new file mode 100644 index 000000000..d1a6ac6ca --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/test_stages_position_facet.py @@ -0,0 +1,225 @@ +"""Tests for $facet composing with other stages at different pipeline positions. + +Per FOLDER_STRUCTURE.md, interactions between $facet and adjacent stages live +in the parent stages/ directory. Covers stages before $facet (which shape the +shared input), stages after $facet (which consume facet output arrays), +consecutive $facet stages, $facet as a middle stage, and preservation of +index-sorted input order into sub-pipelines. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from pymongo.operations import IndexModel + +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.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +DOCS = [ + {"_id": 1, "cat": "A", "v": 30, "tags": ["x", "y"]}, + {"_id": 2, "cat": "A", "v": 10, "tags": ["y"]}, + {"_id": 3, "cat": "B", "v": 20, "tags": ["z"]}, +] + +# Property [Position Interactions]: $facet composes correctly with adjacent +# stages, preserves pre-sorted and pre-filtered input, and allows later stages +# to consume its output arrays. +FACET_POSITION_TESTS: list[StageTestCase] = [ + StageTestCase( + id="first_stage_processes_all_documents", + docs=DOCS, + pipeline=[{"$facet": {"n": [{"$count": "n"}]}}], + expected=[{"n": [{"n": 3}]}], + msg="$facet as first stage should process all documents", + ), + StageTestCase( + id="match_before_facet_filters_subpipelines", + docs=DOCS, + pipeline=[{"$match": {"cat": "A"}}, {"$facet": {"n": [{"$count": "n"}]}}], + expected=[{"n": [{"n": 2}]}], + msg="$match before $facet should filter input to all sub-pipelines", + ), + StageTestCase( + id="sort_before_facet_preserves_order", + docs=DOCS, + pipeline=[ + {"$sort": {"v": 1}}, + {"$facet": {"docs": [{"$project": {"_id": 1}}]}}, + ], + expected=[{"docs": [{"_id": 2}, {"_id": 3}, {"_id": 1}]}], + msg="$sort before $facet should preserve document order into sub-pipelines", + ), + StageTestCase( + id="limit_before_facet_reduces_input", + docs=DOCS, + pipeline=[ + {"$sort": {"_id": 1}}, + {"$limit": 2}, + {"$facet": {"n": [{"$count": "n"}]}}, + ], + expected=[{"n": [{"n": 2}]}], + msg="$limit before $facet should reduce the input set", + ), + StageTestCase( + id="skip_before_facet_removes_documents", + docs=DOCS, + pipeline=[ + {"$sort": {"_id": 1}}, + {"$skip": 2}, + {"$facet": {"docs": [{"$project": {"_id": 1}}]}}, + ], + expected=[{"docs": [{"_id": 3}]}], + msg="$skip before $facet should remove skipped documents", + ), + StageTestCase( + id="unwind_before_facet_expands_documents", + docs=DOCS, + pipeline=[ + {"$unwind": "$tags"}, + {"$facet": {"n": [{"$count": "n"}]}}, + ], + expected=[{"n": [{"n": 4}]}], + msg="$unwind before $facet should expand input documents", + ), + StageTestCase( + id="group_before_facet_passes_grouped_documents", + docs=DOCS, + pipeline=[ + {"$group": {"_id": "$cat", "total": {"$sum": "$v"}}}, + {"$facet": {"grouped": [{"$sort": {"_id": 1}}]}}, + ], + expected=[{"grouped": [{"_id": "A", "total": 40}, {"_id": "B", "total": 20}]}], + msg="$group before $facet should pass grouped documents to sub-pipelines", + ), + StageTestCase( + id="addfields_before_facet_visible_in_subpipelines", + docs=DOCS, + pipeline=[ + {"$addFields": {"doubled": {"$multiply": ["$v", 2]}}}, + {"$facet": {"docs": [{"$match": {"_id": 1}}, {"$project": {"_id": 1, "doubled": 1}}]}}, + ], + expected=[{"docs": [{"_id": 1, "doubled": 60}]}], + msg="Fields added before $facet should be visible in sub-pipelines", + ), + StageTestCase( + id="project_before_facet_reduces_fields", + docs=DOCS, + pipeline=[ + {"$project": {"_id": 1, "cat": 1}}, + {"$facet": {"docs": [{"$match": {"_id": 1}}]}}, + ], + expected=[{"docs": [{"_id": 1, "cat": "A"}]}], + msg="$project before $facet should reduce fields seen by sub-pipelines", + ), + StageTestCase( + id="replaceroot_before_facet_visible_in_subpipelines", + docs=DOCS, + pipeline=[ + {"$match": {"_id": 1}}, + {"$replaceRoot": {"newRoot": {"only": "$cat"}}}, + {"$facet": {"docs": [{"$project": {"_id": 0, "only": 1}}]}}, + ], + expected=[{"docs": [{"only": "A"}]}], + msg="$replaceRoot before $facet should be visible to sub-pipelines", + ), + StageTestCase( + id="project_after_facet_references_output_arrays", + docs=DOCS, + pipeline=[ + {"$facet": {"a": [{"$match": {"cat": "A"}}], "b": [{"$match": {"cat": "B"}}]}}, + {"$project": {"a": 1}}, + ], + expected=[ + { + "a": [ + {"_id": 1, "cat": "A", "v": 30, "tags": ["x", "y"]}, + {"_id": 2, "cat": "A", "v": 10, "tags": ["y"]}, + ] + } + ], + msg="$project after $facet should reference facet output arrays", + ), + StageTestCase( + id="unwind_after_facet_deconstructs_output_array", + docs=DOCS, + pipeline=[ + {"$facet": {"a": [{"$match": {"cat": "A"}}, {"$sort": {"_id": 1}}]}}, + {"$unwind": "$a"}, + {"$project": {"_id": "$a._id"}}, + ], + expected=[{"_id": 1}, {"_id": 2}], + msg="$unwind after $facet should deconstruct the facet output array", + ), + StageTestCase( + id="addfields_after_facet_derives_from_output", + docs=DOCS, + pipeline=[ + {"$facet": {"a": [{"$match": {"cat": "A"}}]}}, + {"$addFields": {"count": {"$size": "$a"}}}, + {"$project": {"count": 1}}, + ], + expected=[{"count": 2}], + msg="$addFields after $facet should derive fields from output arrays", + ), + StageTestCase( + id="consecutive_facet_stages", + docs=DOCS, + pipeline=[ + {"$facet": {"a": [{"$match": {"cat": "A"}}]}}, + {"$facet": {"outer": [{"$project": {"n": {"$size": "$a"}}}]}}, + ], + expected=[{"outer": [{"n": 2}]}], + msg="A second $facet should receive the first $facet's single output document", + ), + StageTestCase( + id="facet_as_middle_stage", + docs=DOCS, + pipeline=[ + {"$match": {"cat": "A"}}, + {"$facet": {"a": [{"$sort": {"_id": 1}}]}}, + {"$project": {"first_id": {"$arrayElemAt": ["$a._id", 0]}}}, + ], + expected=[{"first_id": 1}], + msg="$facet as a middle stage should compose with surrounding stages", + ), + StageTestCase( + id="indexed_sort_before_facet_preserves_order", + docs=DOCS, + indexes=[IndexModel([("v", 1)])], + pipeline=[ + {"$sort": {"v": 1}}, + {"$facet": {"docs": [{"$project": {"_id": 1}}]}}, + ], + expected=[{"docs": [{"_id": 2}, {"_id": 3}, {"_id": 1}]}], + msg="Index-sorted order should be preserved into $facet sub-pipelines", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(FACET_POSITION_TESTS)) +def test_stages_position_facet(collection, test_case: StageTestCase): + """Test $facet composing with stages at different pipeline positions.""" + coll = populate_collection(collection, test_case) + command: dict[str, Any] = { + "aggregate": coll.name, + "pipeline": test_case.pipeline, + "cursor": {}, + } + command.update(test_case.extra_command_fields) + result = execute_command(coll, command) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ignore_doc_order=test_case.ignore_doc_order, + ignore_order_in=test_case.ignore_order_in, + ) 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 0fa6bb76e..d6a7eb7cc 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 @@ -24,6 +24,9 @@ class StageTestCase(BaseTestCase): docs: list[dict[str, Any]] | None = None setup: Callable | None = None pipeline: list[dict[str, Any]] = field(default_factory=list) + ignore_doc_order: bool = False + ignore_order_in: list[str] | None = None + extra_command_fields: dict[str, Any] = field(default_factory=dict) def populate_collection(collection: Collection, test_case: StageTestCase) -> Collection: diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 78a86b0c1..2982b4052 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -291,6 +291,9 @@ COUNT_FIELD_DOLLAR_PREFIX_ERROR = 40158 COUNT_FIELD_NULL_BYTE_ERROR = 40159 COUNT_FIELD_DOT_ERROR = 40160 +FACET_SPEC_NOT_OBJECT_ERROR = 40169 +FACET_PIPELINE_NOT_ARRAY_ERROR = 40170 +FACET_PIPELINE_STAGE_NOT_OBJECT_ERROR = 40171 CONFLICTING_PATH_ERROR = 40176 MULTIPLE_EXPRESSIONS_ERROR = 40181 DOTTED_FIELD_IN_SUB_OBJECT_ERROR = 40183