From 3daaa85ba9c7a3bf14a595b5e4f7ad3c796a2fb8 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Tue, 21 Jul 2026 17:07:18 -0700 Subject: [PATCH] Add verbose syntax tests for $lookup Signed-off-by: Daniel Frankcom --- .../stages/lookup/test_lookup_with_expr.py | 294 ------- .../stages/lookup/utils/lookup_common.py | 40 +- .../stages/lookup/verbose/__init__.py | 0 .../lookup/verbose/test_verbose_let_wiring.py | 512 ++++++++++++ .../verbose/test_verbose_nested_lookup.py | 773 ++++++++++++++++++ .../test_verbose_stage_combinations.py | 280 +++++++ 6 files changed, 1590 insertions(+), 309 deletions(-) delete mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_with_expr.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/lookup/verbose/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/lookup/verbose/test_verbose_let_wiring.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/lookup/verbose/test_verbose_nested_lookup.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/stages/lookup/verbose/test_verbose_stage_combinations.py diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_with_expr.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_with_expr.py deleted file mode 100644 index 3b4c89256..000000000 --- a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_with_expr.py +++ /dev/null @@ -1,294 +0,0 @@ -""" -Tests for $expr in $lookup subpipeline. - -Covers $lookup with $expr join conditions, let variables, -arithmetic in $expr, null/missing field handling, and error cases. -""" - -import pytest - -from documentdb_tests.framework.assertions import assertFailureCode, assertSuccess -from documentdb_tests.framework.error_codes import LET_UNDEFINED_VARIABLE_ERROR -from documentdb_tests.framework.executor import execute_command - - -@pytest.mark.aggregate -def test_expr_lookup_basic_eq(database_client): - """Test $lookup with $expr $eq joining on let variable.""" - orders = database_client.create_collection("orders_test") - customers = database_client.create_collection("customers_test") - customers.insert_many([{"_id": 1, "name": "Alice"}, {"_id": 2, "name": "Bob"}]) - orders.insert_many( - [ - {"_id": 10, "customer_id": 1, "item": "A"}, - {"_id": 11, "customer_id": 1, "item": "B"}, - {"_id": 12, "customer_id": 2, "item": "C"}, - ] - ) - result = execute_command( - customers, - { - "aggregate": customers.name, - "pipeline": [ - { - "$lookup": { - "from": orders.name, - "let": {"cust_id": "$_id"}, - "pipeline": [ - { - "$match": { - "$expr": { - "$eq": [ - "$customer_id", - "$$cust_id", - ] - } - } - }, - {"$project": {"_id": 0, "item": 1}}, - ], - "as": "orders", - } - }, - {"$sort": {"_id": 1}}, - ], - "cursor": {}, - }, - ) - assertSuccess( - result, - [ - { - "_id": 1, - "name": "Alice", - "orders": [{"item": "A"}, {"item": "B"}], - }, - {"_id": 2, "name": "Bob", "orders": [{"item": "C"}]}, - ], - msg="$lookup with $expr $eq should join on let variable", - ) - - -@pytest.mark.aggregate -def test_expr_lookup_range_gt(database_client): - """Test $lookup with $expr $gt for range join.""" - items = database_client.create_collection("items_test") - thresholds = database_client.create_collection("thresholds_test") - thresholds.insert_one({"_id": 1, "min_qty": 5}) - items.insert_many( - [ - {"_id": 10, "qty": 10}, - {"_id": 11, "qty": 3}, - {"_id": 12, "qty": 7}, - ] - ) - result = execute_command( - thresholds, - { - "aggregate": thresholds.name, - "pipeline": [ - { - "$lookup": { - "from": items.name, - "let": {"min": "$min_qty"}, - "pipeline": [ - {"$match": {"$expr": {"$gt": ["$qty", "$$min"]}}}, - {"$project": {"_id": 0, "qty": 1}}, - {"$sort": {"qty": 1}}, - ], - "as": "above_min", - } - } - ], - "cursor": {}, - }, - ) - assertSuccess( - result, - [ - { - "_id": 1, - "min_qty": 5, - "above_min": [{"qty": 7}, {"qty": 10}], - } - ], - msg="$lookup with $expr $gt should filter by range condition", - ) - - -@pytest.mark.aggregate -def test_expr_lookup_arithmetic(database_client): - """Test $lookup with $expr using arithmetic on let variable.""" - orders = database_client.create_collection("orders_arith") - limits = database_client.create_collection("limits_arith") - limits.insert_one({"_id": 1, "base_limit": 50}) - orders.insert_many([{"_id": 10, "amount": 120}, {"_id": 11, "amount": 80}]) - result = execute_command( - limits, - { - "aggregate": limits.name, - "pipeline": [ - { - "$lookup": { - "from": orders.name, - "let": {"limit": "$base_limit"}, - "pipeline": [ - { - "$match": { - "$expr": { - "$gt": [ - "$amount", - {"$multiply": ["$$limit", 2]}, - ] - } - } - }, - {"$project": {"_id": 0, "amount": 1}}, - ], - "as": "over_double", - } - } - ], - "cursor": {}, - }, - ) - assertSuccess( - result, - [{"_id": 1, "base_limit": 50, "over_double": [{"amount": 120}]}], - msg="$lookup with $expr should support arithmetic on let variables", - ) - - -@pytest.mark.aggregate -def test_expr_lookup_let_null(database_client): - """Test $lookup with let variable resolving to null.""" - inner = database_client.create_collection("inner_null") - outer = database_client.create_collection("outer_null") - outer.insert_one({"_id": 1, "val": None}) - inner.insert_many([{"_id": 10, "x": None}, {"_id": 11, "x": 1}]) - result = execute_command( - outer, - { - "aggregate": outer.name, - "pipeline": [ - { - "$lookup": { - "from": inner.name, - "let": {"v": "$val"}, - "pipeline": [ - {"$match": {"$expr": {"$eq": ["$x", "$$v"]}}}, - {"$project": {"_id": 1}}, - ], - "as": "matched", - } - } - ], - "cursor": {}, - }, - ) - assertSuccess( - result, - [{"_id": 1, "val": None, "matched": [{"_id": 10}]}], - msg="$lookup with let variable resolving to null should match null values", - ) - - -@pytest.mark.aggregate -def test_expr_lookup_let_missing_field(database_client): - """Test $lookup with let variable from missing field.""" - inner = database_client.create_collection("inner_miss") - outer = database_client.create_collection("outer_miss") - outer.insert_one({"_id": 1}) - inner.insert_many([{"_id": 10, "x": None}, {"_id": 11, "x": 1}]) - result = execute_command( - outer, - { - "aggregate": outer.name, - "pipeline": [ - { - "$lookup": { - "from": inner.name, - "let": {"v": "$val"}, - "pipeline": [ - {"$match": {"$expr": {"$eq": ["$x", "$$v"]}}}, - {"$project": {"_id": 1}}, - ], - "as": "matched", - } - } - ], - "cursor": {}, - }, - ) - # Missing field in let resolves to missing, which doesn't match null - assertSuccess( - result, - [{"_id": 1, "matched": []}], - msg="$lookup with let variable from missing field should not match any docs", - ) - - -@pytest.mark.aggregate -def test_expr_lookup_no_match(database_client): - """Test $lookup with $expr where no inner docs match.""" - inner = database_client.create_collection("inner_nomatch") - outer = database_client.create_collection("outer_nomatch") - outer.insert_one({"_id": 1, "val": 999}) - inner.insert_many([{"_id": 10, "x": 1}, {"_id": 11, "x": 2}]) - result = execute_command( - outer, - { - "aggregate": outer.name, - "pipeline": [ - { - "$lookup": { - "from": inner.name, - "let": {"v": "$val"}, - "pipeline": [ - {"$match": {"$expr": {"$eq": ["$x", "$$v"]}}}, - ], - "as": "matched", - } - } - ], - "cursor": {}, - }, - ) - assertSuccess( - result, - [{"_id": 1, "val": 999, "matched": []}], - msg="$lookup with $expr should return empty array when no docs match", - ) - - -@pytest.mark.aggregate -def test_expr_lookup_undefined_variable(database_client): - """Test $lookup with $expr referencing undefined let variable.""" - inner = database_client.create_collection("inner_undef") - outer = database_client.create_collection("outer_undef") - outer.insert_one({"_id": 1}) - inner.insert_one({"_id": 10}) - result = execute_command( - outer, - { - "aggregate": outer.name, - "pipeline": [ - { - "$lookup": { - "from": inner.name, - "let": {}, - "pipeline": [ - {"$match": {"$expr": {"$eq": ["$x", "$$undefined_var"]}}}, - ], - "as": "matched", - } - } - ], - "cursor": {}, - }, - ) - assertFailureCode( - result, - LET_UNDEFINED_VARIABLE_ERROR, - msg="$lookup with $expr referencing undefined let variable should fail", - ) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/utils/lookup_common.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/utils/lookup_common.py index e278a84d0..2858eb1dc 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/utils/lookup_common.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/utils/lookup_common.py @@ -59,24 +59,34 @@ def _substitute_foreign( pipeline: list[dict[str, Any]], foreign_name: str, ) -> list[dict[str, Any]]: - """Replace FOREIGN sentinel in $lookup 'from' fields, including nested pipelines.""" + """Replace the FOREIGN sentinel in $lookup and $graphLookup 'from' fields. + + Recurses into nested $lookup sub-pipelines. + """ result = list(pipeline) for i, stage in enumerate(result): - if not isinstance(stage, dict) or "$lookup" not in stage: - continue - spec = stage["$lookup"] - if not isinstance(spec, dict): + if not isinstance(stage, dict): continue - changed = False - spec = dict(spec) - if spec.get("from") is FOREIGN: - spec["from"] = foreign_name - changed = True - if isinstance(spec.get("pipeline"), list): - spec["pipeline"] = _substitute_foreign(spec["pipeline"], foreign_name) - changed = True - if changed: - result[i] = {"$lookup": spec} + if "$lookup" in stage: + spec = stage["$lookup"] + if not isinstance(spec, dict): + continue + changed = False + spec = dict(spec) + if spec.get("from") is FOREIGN: + spec["from"] = foreign_name + changed = True + if isinstance(spec.get("pipeline"), list): + spec["pipeline"] = _substitute_foreign(spec["pipeline"], foreign_name) + changed = True + if changed: + result[i] = {"$lookup": spec} + elif "$graphLookup" in stage: + spec = stage["$graphLookup"] + if isinstance(spec, dict) and spec.get("from") is FOREIGN: + spec = dict(spec) + spec["from"] = foreign_name + result[i] = {"$graphLookup": spec} return result diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/verbose/__init__.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/verbose/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/verbose/test_verbose_let_wiring.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/verbose/test_verbose_let_wiring.py new file mode 100644 index 000000000..c144fc611 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/verbose/test_verbose_let_wiring.py @@ -0,0 +1,512 @@ +"""Tests that $lookup sub-pipeline stages recognize and resolve let variables.""" + +from __future__ import annotations + +import pytest +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.operator.stages.lookup.utils.lookup_common import ( + FOREIGN, + LookupTestCase, + build_lookup_command, + setup_lookup, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Sub-Pipeline Let Wiring]: a let variable is recognized and resolved +# inside each supported sub-pipeline stage, evaluated against the outer document. +LOOKUP_VERBOSE_LET_WIRING_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "match_resolves_let", + foreign_docs=[{"_id": 10, "type": "A"}, {"_id": 11, "type": "B"}], + docs=[{"_id": 1, "cat": "A"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"v": "$cat"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$type", "$$v"]}}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "cat": "A", "joined": [{"_id": 10, "type": "A"}]}], + msg="$lookup sub-pipeline $match should resolve a let variable in $expr", + ), + LookupTestCase( + "add_fields_resolves_let", + foreign_docs=[{"_id": 10, "type": "A"}, {"_id": 11, "type": "B"}], + docs=[{"_id": 1, "cat": "A"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"v": "$cat"}, + "pipeline": [{"$addFields": {"lv": "$$v"}}, {"$sort": {"_id": 1}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "cat": "A", + "joined": [ + {"_id": 10, "type": "A", "lv": "A"}, + {"_id": 11, "type": "B", "lv": "A"}, + ], + } + ], + msg="$lookup sub-pipeline $addFields should resolve a let variable", + ), + LookupTestCase( + "set_resolves_let", + foreign_docs=[{"_id": 10, "type": "A"}, {"_id": 11, "type": "B"}], + docs=[{"_id": 1, "cat": "A"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"v": "$cat"}, + "pipeline": [{"$set": {"lv": "$$v"}}, {"$sort": {"_id": 1}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "cat": "A", + "joined": [ + {"_id": 10, "type": "A", "lv": "A"}, + {"_id": 11, "type": "B", "lv": "A"}, + ], + } + ], + msg="$lookup sub-pipeline $set should resolve a let variable", + ), + LookupTestCase( + "project_resolves_let", + foreign_docs=[{"_id": 10, "type": "A"}, {"_id": 11, "type": "B"}], + docs=[{"_id": 1, "cat": "A"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"v": "$cat"}, + "pipeline": [{"$project": {"_id": 1, "matchesLet": {"$eq": ["$type", "$$v"]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "cat": "A", + "joined": [{"_id": 10, "matchesLet": True}, {"_id": 11, "matchesLet": False}], + } + ], + msg="$lookup sub-pipeline $project should resolve a let variable", + ), + LookupTestCase( + "group_id_resolves_let", + foreign_docs=[{"_id": 10, "type": "A"}, {"_id": 11, "type": "B"}], + docs=[{"_id": 1, "cat": "A"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"v": "$cat"}, + "pipeline": [{"$group": {"_id": "$$v", "count": {"$sum": 1}}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "cat": "A", "joined": [{"_id": "A", "count": 2}]}], + msg="$lookup sub-pipeline $group should resolve a let variable as the group key", + ), + LookupTestCase( + "group_accumulator_resolves_let", + foreign_docs=[{"_id": 10, "type": "A"}, {"_id": 11, "type": "B"}], + docs=[{"_id": 1, "cat": "A"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"v": "$cat"}, + "pipeline": [ + { + "$group": { + "_id": None, + "matching": {"$sum": {"$cond": [{"$eq": ["$type", "$$v"]}, 1, 0]}}, + } + } + ], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "cat": "A", "joined": [{"_id": None, "matching": 1}]}], + msg="$lookup sub-pipeline $group should resolve a let variable inside an " + "accumulator expression", + ), + LookupTestCase( + "sort_by_count_resolves_let", + foreign_docs=[{"_id": 10, "type": "A"}, {"_id": 11, "type": "B"}], + docs=[{"_id": 1, "cat": "A"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"v": "$cat"}, + "pipeline": [{"$sortByCount": "$$v"}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "cat": "A", "joined": [{"_id": "A", "count": 2}]}], + msg="$lookup sub-pipeline $sortByCount should resolve a let variable", + ), + LookupTestCase( + "bucket_resolves_let", + foreign_docs=[{"_id": 10, "n": 1}, {"_id": 11, "n": 5}], + docs=[{"_id": 1, "cat": "A"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"v": "$cat"}, + "pipeline": [ + { + "$bucket": { + "groupBy": "$n", + "boundaries": [0, 4, 10], + "output": {"lv": {"$first": "$$v"}}, + } + } + ], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "cat": "A", "joined": [{"_id": 0, "lv": "A"}, {"_id": 4, "lv": "A"}]}], + msg="$lookup sub-pipeline $bucket should resolve a let variable in output", + ), + LookupTestCase( + "bucket_auto_resolves_let", + foreign_docs=[{"_id": 10, "n": 1}, {"_id": 11, "n": 5}], + docs=[{"_id": 1, "cat": "A"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"v": "$cat"}, + "pipeline": [ + { + "$bucketAuto": { + "groupBy": "$n", + "buckets": 1, + "output": {"lv": {"$first": "$$v"}}, + } + } + ], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "cat": "A", "joined": [{"_id": {"min": 1, "max": 5}, "lv": "A"}]}], + msg="$lookup sub-pipeline $bucketAuto should resolve a let variable in output", + ), + LookupTestCase( + "facet_resolves_let", + foreign_docs=[{"_id": 10, "type": "A"}, {"_id": 11, "type": "B"}], + docs=[{"_id": 1, "cat": "A"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"v": "$cat"}, + "pipeline": [ + {"$facet": {"branch": [{"$match": {"$expr": {"$eq": ["$type", "$$v"]}}}]}} + ], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "cat": "A", "joined": [{"branch": [{"_id": 10, "type": "A"}]}]}], + msg="$lookup sub-pipeline $facet should resolve a let variable inside a branch pipeline", + ), + LookupTestCase( + "union_with_resolves_let", + foreign_docs=[{"_id": 10, "type": "A"}], + docs=[{"_id": 1, "cat": "A"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"v": "$cat"}, + "pipeline": [ + { + "$unionWith": { + "pipeline": [ + {"$documents": [{"src": "union"}]}, + {"$addFields": {"lv": "$$v"}}, + ] + } + }, + {"$project": {"_id": 0, "type": 1, "src": 1, "lv": 1}}, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "cat": "A", + "joined": [{"type": "A"}, {"src": "union", "lv": "A"}], + } + ], + msg="$lookup sub-pipeline $unionWith should resolve an outer let variable in " + "its pipeline", + ), + LookupTestCase( + "set_window_fields_resolves_let", + foreign_docs=[{"_id": 10, "type": "A"}, {"_id": 11, "type": "B"}], + docs=[{"_id": 1, "cat": "A"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"v": "$cat"}, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$$v", + "sortBy": {"_id": 1}, + "output": {"r": {"$rank": {}}}, + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "cat": "A", + "joined": [ + {"_id": 10, "type": "A", "r": 1}, + {"_id": 11, "type": "B", "r": 2}, + ], + } + ], + msg="$lookup sub-pipeline $setWindowFields should resolve a let variable " + "as the partition key", + ), + LookupTestCase( + "fill_resolves_let", + foreign_docs=[{"_id": 10, "type": "A"}, {"_id": 11, "type": "B"}], + docs=[{"_id": 1, "cat": "A"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"v": "$cat"}, + "pipeline": [ + {"$fill": {"sortBy": {"_id": 1}, "output": {"lv": {"value": "$$v"}}}}, + {"$sort": {"_id": 1}}, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "cat": "A", + "joined": [ + {"_id": 10, "type": "A", "lv": "A"}, + {"_id": 11, "type": "B", "lv": "A"}, + ], + } + ], + msg="$lookup sub-pipeline $fill should resolve a let variable in an output " + "value expression", + ), + LookupTestCase( + "redact_resolves_let", + foreign_docs=[{"_id": 10, "type": "A"}, {"_id": 11, "type": "B"}], + docs=[{"_id": 1, "cat": "A"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"v": "$cat"}, + "pipeline": [ + {"$redact": {"$cond": [{"$eq": ["$type", "$$v"]}, "$$KEEP", "$$PRUNE"]}} + ], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "cat": "A", "joined": [{"_id": 10, "type": "A"}]}], + msg="$lookup sub-pipeline $redact should resolve a let variable in its expression", + ), + LookupTestCase( + "replace_root_resolves_let", + foreign_docs=[{"_id": 10, "type": "A"}], + docs=[{"_id": 1, "cat": "A"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"v": "$cat"}, + "pipeline": [{"$replaceRoot": {"newRoot": {"t": "$type", "lv": "$$v"}}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "cat": "A", "joined": [{"t": "A", "lv": "A"}]}], + msg="$lookup sub-pipeline $replaceRoot should resolve a let variable in the new root", + ), + LookupTestCase( + "replace_with_resolves_let", + foreign_docs=[{"_id": 10, "type": "A"}], + docs=[{"_id": 1, "cat": "A"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"v": "$cat"}, + "pipeline": [{"$replaceWith": {"t": "$type", "lv": "$$v"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "cat": "A", "joined": [{"t": "A", "lv": "A"}]}], + msg="$lookup sub-pipeline $replaceWith should resolve a let variable in the new root", + ), + LookupTestCase( + "graph_lookup_start_with_resolves_let", + foreign_docs=[{"_id": 10, "type": "A"}, {"_id": 11, "type": "B"}], + docs=[{"_id": 1, "cat": "A"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"v": "$cat"}, + "pipeline": [ + { + "$graphLookup": { + "from": FOREIGN, + "startWith": "$$v", + "connectFromField": "type", + "connectToField": "type", + "as": "g", + } + }, + {"$project": {"_id": 1, "gTypes": "$g.type"}}, + {"$sort": {"_id": 1}}, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "cat": "A", + "joined": [ + {"_id": 10, "gTypes": ["A"]}, + {"_id": 11, "gTypes": ["A"]}, + ], + } + ], + msg="$lookup sub-pipeline $graphLookup should resolve a let variable in startWith", + ), + LookupTestCase( + "graph_lookup_restrict_search_resolves_let", + foreign_docs=[ + {"_id": 10, "link": "root", "next": "a", "type": "A"}, + {"_id": 11, "link": "a", "next": "b", "type": "A"}, + {"_id": 12, "link": "b", "next": "c", "type": "B"}, + {"_id": 13, "link": "c", "type": "A"}, + ], + docs=[{"_id": 1, "cat": "A"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"v": "$cat"}, + "pipeline": [ + {"$match": {"_id": 10}}, + { + "$graphLookup": { + "from": FOREIGN, + "startWith": "$next", + "connectFromField": "next", + "connectToField": "link", + "as": "g", + "restrictSearchWithMatch": {"$expr": {"$eq": ["$type", "$$v"]}}, + } + }, + {"$project": {"_id": 1, "gIds": "$g._id"}}, + ], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "cat": "A", "joined": [{"_id": 10, "gIds": [11]}]}], + msg="$lookup sub-pipeline $graphLookup should resolve a let variable in " + "restrictSearchWithMatch", + ), + LookupTestCase( + "geo_near_resolves_let", + foreign_indexes=[IndexModel([("loc", "2dsphere")])], + foreign_docs=[ + {"_id": 10, "type": "A", "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 11, "type": "B", "loc": {"type": "Point", "coordinates": [1, 1]}}, + ], + docs=[{"_id": 1, "cat": "A"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"v": "$cat"}, + "pipeline": [ + { + "$geoNear": { + "near": {"type": "Point", "coordinates": [0, 0]}, + "distanceField": "d", + "query": {"$expr": {"$eq": ["$type", "$$v"]}}, + } + }, + {"$project": {"_id": 1, "type": 1}}, + ], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "cat": "A", "joined": [{"_id": 10, "type": "A"}]}], + msg="$lookup sub-pipeline $geoNear should resolve a let variable in its query filter", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(LOOKUP_VERBOSE_LET_WIRING_TESTS)) +def test_verbose_let_wiring(collection, test_case: LookupTestCase): + """Test $lookup sub-pipeline stages resolve let variables.""" + with setup_lookup(collection, test_case) as foreign_name: + command = build_lookup_command(collection, test_case, foreign_name) + result = execute_command(collection, command) + 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/lookup/verbose/test_verbose_nested_lookup.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/verbose/test_verbose_nested_lookup.py new file mode 100644 index 000000000..d2e93b510 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/verbose/test_verbose_nested_lookup.py @@ -0,0 +1,773 @@ +"""Tests for let variable scoping across nested $lookup sub-pipelines. + +Covers multi-level propagation, shadowing when an inner let reuses a name, +scope isolation between sibling or same-named contexts, and undefined-variable +errors at a nesting level. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.lookup.utils.lookup_common import ( + FOREIGN, + LookupTestCase, + build_lookup_command, + setup_lookup, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import LET_UNDEFINED_VARIABLE_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Nested Let Propagation]: outer let variables remain accessible in +# deeply nested sub-pipelines, and each level's let is visible at deeper levels. +LOOKUP_NESTED_PROPAGATION_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "outer_let_accessible_in_doubly_nested", + foreign_docs=[{"_id": 10}], + docs=[{"_id": 1, "val": "from_outer"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$val"}, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "pipeline": [{"$addFields": {"deep": "$$x"}}], + "as": "level3", + } + } + ], + "as": "level2", + } + } + ], + "as": "level1", + } + } + ], + expected=[ + { + "_id": 1, + "val": "from_outer", + "level1": [ + { + "_id": 10, + "level2": [{"_id": 10, "level3": [{"_id": 10, "deep": "from_outer"}]}], + } + ], + } + ], + msg="$lookup outer let var should be accessible in a triply-nested sub-pipeline", + ), + LookupTestCase( + "outer_and_inner_let_both_accessible", + foreign_docs=[{"_id": 10, "b": "inner_b"}], + docs=[{"_id": 1, "a": "outer_a"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$a"}, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "let": {"y": "$b"}, + "pipeline": [ + {"$addFields": {"from_outer": "$$x", "from_inner": "$$y"}} + ], + "as": "nested", + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "a": "outer_a", + "joined": [ + { + "_id": 10, + "b": "inner_b", + "nested": [ + { + "_id": 10, + "b": "inner_b", + "from_outer": "outer_a", + "from_inner": "inner_b", + } + ], + } + ], + } + ], + msg="$lookup nested let should keep both the outer and inner variables " + "accessible at the innermost level", + ), + LookupTestCase( + "inner_let_defined_from_outer_var", + foreign_docs=[{"_id": 10}], + docs=[{"_id": 1, "a": "outer"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$a"}, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "let": {"y": {"$concat": ["$$x", "!"]}}, + "pipeline": [{"$addFields": {"r": "$$y"}}], + "as": "inner", + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "a": "outer", + "joined": [{"_id": 10, "inner": [{"_id": 10, "r": "outer!"}]}], + } + ], + msg="$lookup inner let defining expression should resolve an outer let " + "variable from the enclosing scope", + ), + LookupTestCase( + "outer_let_in_inner_match_expr", + foreign_docs=[{"_id": 10, "tag": "target"}, {"_id": 11, "tag": "other"}], + docs=[{"_id": 1, "ref": "target"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"r": "$ref"}, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "pipeline": [{"$match": {"$expr": {"$eq": ["$tag", "$$r"]}}}], + "as": "inner_match", + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "ref": "target", + "joined": [ + {"_id": 10, "tag": "target", "inner_match": [{"_id": 10, "tag": "target"}]}, + {"_id": 11, "tag": "other", "inner_match": [{"_id": 10, "tag": "target"}]}, + ], + } + ], + msg="$lookup outer let var should be usable in a $match $expr inside a nested sub-pipeline", + ), + LookupTestCase( + "three_levels_each_with_own_let", + foreign_docs=[{"_id": 10, "b": "L2", "c": "L3"}], + docs=[{"_id": 1, "a": "L1"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"v1": "$a"}, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "let": {"v2": "$b"}, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "let": {"v3": "$c"}, + "pipeline": [ + { + "$addFields": { + "r1": "$$v1", + "r2": "$$v2", + "r3": "$$v3", + } + } + ], + "as": "deep", + } + } + ], + "as": "mid", + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "a": "L1", + "joined": [ + { + "_id": 10, + "b": "L2", + "c": "L3", + "mid": [ + { + "_id": 10, + "b": "L2", + "c": "L3", + "deep": [ + { + "_id": 10, + "b": "L2", + "c": "L3", + "r1": "L1", + "r2": "L2", + "r3": "L3", + } + ], + } + ], + } + ], + } + ], + msg="$lookup 3-level nesting where each level defines its own let should " + "expose all three at the deepest level", + ), + LookupTestCase( + "inner_let_references_field_from_addFields", + foreign_docs=[{"_id": 10}], + docs=[{"_id": 1, "base": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"b": "$base"}, + "pipeline": [ + {"$addFields": {"computed": {"$multiply": ["$$b", 2]}}}, + { + "$lookup": { + "from": FOREIGN, + "let": {"c": "$computed"}, + "pipeline": [{"$addFields": {"result": "$$c"}}], + "as": "inner", + } + }, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "base": 10, + "joined": [{"_id": 10, "computed": 20, "inner": [{"_id": 10, "result": 20}]}], + } + ], + msg="$lookup inner let should be able to reference a field created by a " + "preceding $addFields", + ), + LookupTestCase( + "outer_let_reaches_inner_uncorrelated_pipeline", + foreign_docs=[{"_id": 10, "tag": "A"}, {"_id": 11, "tag": "B"}], + docs=[{"_id": 1, "cat": "A"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"v": "$cat"}, + "pipeline": [ + {"$match": {"_id": 10}}, + { + "$lookup": { + "from": FOREIGN, + "pipeline": [{"$match": {"$expr": {"$eq": ["$tag", "$$v"]}}}], + "as": "inner", + } + }, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "cat": "A", + "joined": [{"_id": 10, "tag": "A", "inner": [{"_id": 10, "tag": "A"}]}], + } + ], + msg="$lookup outer let var should be accessible in an inner uncorrelated " + "lookup's pipeline", + ), + LookupTestCase( + "outer_let_reaches_inner_concise_pipeline", + foreign_docs=[ + {"_id": 10, "lf": "k", "tag": "A"}, + {"_id": 11, "lf": "k", "tag": "B"}, + ], + docs=[{"_id": 1, "cat": "A"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"v": "$cat"}, + "pipeline": [ + {"$match": {"_id": 10}}, + { + "$lookup": { + "from": FOREIGN, + "localField": "lf", + "foreignField": "lf", + "pipeline": [{"$match": {"$expr": {"$eq": ["$tag", "$$v"]}}}], + "as": "inner", + } + }, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "cat": "A", + "joined": [ + { + "_id": 10, + "lf": "k", + "tag": "A", + "inner": [{"_id": 10, "lf": "k", "tag": "A"}], + } + ], + } + ], + msg="$lookup outer let var should be accessible in an inner concise lookup's pipeline", + ), +] + +# Property [Nested Let Shadowing]: an inner let that reuses an outer variable +# name shadows it within the inner scope, and the shadow does not leak upward. +LOOKUP_NESTED_SHADOW_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "partial_shadow_inner_redefines_one_of_two", + foreign_docs=[{"_id": 10, "c": "inner_c"}], + docs=[{"_id": 1, "a": "outer_a", "b": "outer_b"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$a", "y": "$b"}, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$c"}, + "pipeline": [{"$addFields": {"rx": "$$x", "ry": "$$y"}}], + "as": "nested", + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "a": "outer_a", + "b": "outer_b", + "joined": [ + { + "_id": 10, + "c": "inner_c", + "nested": [{"_id": 10, "c": "inner_c", "rx": "inner_c", "ry": "outer_b"}], + } + ], + } + ], + msg="$lookup inner let redefining only x should shadow x while outer y remains accessible", + ), + LookupTestCase( + "inner_shadow_defined_from_outer_value", + foreign_docs=[{"_id": 10}], + docs=[{"_id": 1, "a": "outer"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$a"}, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": {"$concat": ["$$x", "_inner"]}}, + "pipeline": [{"$addFields": {"r": "$$x"}}], + "as": "inner", + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "a": "outer", + "joined": [{"_id": 10, "inner": [{"_id": 10, "r": "outer_inner"}]}], + } + ], + msg="$lookup inner let redefining a name from its own outer value should " + "read the outer value before the shadow takes effect", + ), + LookupTestCase( + "shadow_does_not_leak_up", + foreign_docs=[{"_id": 10, "inner_val": "shadowed"}], + docs=[{"_id": 1, "val": "outer_val"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$val"}, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$inner_val"}, + "pipeline": [{"$addFields": {"inner_x": "$$x"}}], + "as": "inner", + } + }, + {"$addFields": {"after_inner_x": "$$x"}}, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "val": "outer_val", + "joined": [ + { + "_id": 10, + "inner_val": "shadowed", + "inner": [{"_id": 10, "inner_val": "shadowed", "inner_x": "shadowed"}], + "after_inner_x": "outer_val", + } + ], + } + ], + msg="$lookup inner shadow should not leak upward, so the outer scope keeps " + "its original value", + ), + LookupTestCase( + "progressive_shadow_three_levels", + foreign_docs=[{"_id": 10, "v": "L2"}], + docs=[{"_id": 1, "v": "L1"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$v"}, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$v"}, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "deepest"}, + "pipeline": [{"$addFields": {"val": "$$x"}}], + "as": "l3", + } + } + ], + "as": "l2", + } + } + ], + "as": "l1", + } + } + ], + expected=[ + { + "_id": 1, + "v": "L1", + "l1": [ + { + "_id": 10, + "v": "L2", + "l2": [ + { + "_id": 10, + "v": "L2", + "l3": [{"_id": 10, "v": "L2", "val": "deepest"}], + } + ], + } + ], + } + ], + msg="$lookup triple progressive shadow should let the deepest level see " + "its own redefined value", + ), + LookupTestCase( + "shadow_with_different_type", + foreign_docs=[{"_id": 10, "x": "hello"}], + docs=[{"_id": 1, "x": 42}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"v": "$x"}, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "let": {"v": "$x"}, + "pipeline": [{"$addFields": {"val": "$$v", "t": {"$type": "$$v"}}}], + "as": "nested", + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "x": 42, + "joined": [ + { + "_id": 10, + "x": "hello", + "nested": [{"_id": 10, "x": "hello", "val": "hello", "t": "string"}], + } + ], + } + ], + msg="$lookup inner shadow can change a variable's type, with an outer int " + "shadowed by an inner string", + ), +] + +# Property [Nested Scope Isolation]: same-named let variables and output fields +# in sibling or nested contexts remain isolated to their own scope. +LOOKUP_NESTED_ISOLATION_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "sibling_nested_lookups_isolate_same_named_var", + foreign_docs=[{"_id": 10, "tag": "p"}, {"_id": 11, "tag": "q"}], + docs=[{"_id": 1}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "pipeline": [ + {"$match": {"_id": 10}}, + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "p"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$tag", "$$x"]}}}], + "as": "a", + } + }, + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "q"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$tag", "$$x"]}}}], + "as": "b", + } + }, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "joined": [ + { + "_id": 10, + "tag": "p", + "a": [{"_id": 10, "tag": "p"}], + "b": [{"_id": 11, "tag": "q"}], + } + ], + } + ], + msg="$lookup sibling nested lookups should isolate a same-named let " + "variable to each branch", + ), + LookupTestCase( + "sibling_var_undefined_in_other_branch", + foreign_docs=[{"_id": 10, "tag": "p"}], + docs=[{"_id": 1}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "pipeline": [ + {"$match": {"_id": 10}}, + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "p"}, + "pipeline": [{"$match": {}}], + "as": "a", + } + }, + { + "$lookup": { + "from": FOREIGN, + "pipeline": [{"$addFields": {"r": "$$x"}}], + "as": "b", + } + }, + ], + "as": "joined", + } + } + ], + error_code=LET_UNDEFINED_VARIABLE_ERROR, + msg="$lookup should treat a let variable defined only in a sibling branch " + "as undefined in the other branch", + ), + LookupTestCase( + "inner_as_same_name_as_outer_no_conflict", + foreign_docs=[{"_id": 10, "v": "mid"}], + docs=[{"_id": 1, "v": "outer"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$v"}, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "pipeline": [{"$addFields": {"src": "$$x"}}], + "as": "joined", + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "v": "outer", + "joined": [ + { + "_id": 10, + "v": "mid", + "joined": [{"_id": 10, "v": "mid", "src": "outer"}], + } + ], + } + ], + msg="$lookup inner as with the same name as the outer as should not " + "conflict across contexts", + ), +] + +# Property [Nested Undefined Variable]: referencing a variable never defined at +# any enclosing level produces an undefined variable error. +LOOKUP_NESTED_ERROR_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "missing_var_at_one_level_errors", + foreign_docs=[{"_id": 10}], + docs=[{"_id": 1}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"o": "O"}, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "let": {"i": "I"}, + "pipeline": [ + { + "$addFields": { + "combined": { + "$concat": ["$$o", "$$m", "$$i"] + } + } + } + ], + "as": "lvl3", + } + } + ], + "as": "lvl2", + } + } + ], + "as": "joined", + } + } + ], + error_code=LET_UNDEFINED_VARIABLE_ERROR, + msg="$lookup referencing a never-defined variable at a nesting level should error", + ), +] + +LOOKUP_VERBOSE_NESTED_TESTS: list[LookupTestCase] = ( + LOOKUP_NESTED_PROPAGATION_TESTS + + LOOKUP_NESTED_SHADOW_TESTS + + LOOKUP_NESTED_ISOLATION_TESTS + + LOOKUP_NESTED_ERROR_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(LOOKUP_VERBOSE_NESTED_TESTS)) +def test_verbose_nested_lookup(collection, test_case: LookupTestCase): + """Test let variable scoping across nested $lookup sub-pipelines.""" + with setup_lookup(collection, test_case) as foreign_name: + command = build_lookup_command(collection, test_case, foreign_name) + result = execute_command(collection, command) + 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/lookup/verbose/test_verbose_stage_combinations.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/verbose/test_verbose_stage_combinations.py new file mode 100644 index 000000000..aba1f9459 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/verbose/test_verbose_stage_combinations.py @@ -0,0 +1,280 @@ +"""Tests for let variable access across multi-stage $lookup sub-pipelines. + +Verifies a let variable stays accessible when other aggregation stages sit +between the let binding and the stage that uses it, and when several let +variables are used across a sequence of stages. +""" + +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.stages.lookup.utils.lookup_common import ( + FOREIGN, + LookupTestCase, + build_lookup_command, + setup_lookup, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Stage Combination Let Access]: let variables remain accessible +# across a multi-stage sub-pipeline, both before and after intervening stages. +LOOKUP_VERBOSE_STAGE_COMBINATION_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "let_var_after_unwind", + foreign_docs=[{"_id": 10, "items": [{"type": "A", "v": 1}, {"type": "B", "v": 2}]}], + docs=[{"_id": 1, "wantType": "A"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"t": "$wantType"}, + "pipeline": [ + {"$unwind": "$items"}, + {"$match": {"$expr": {"$eq": ["$items.type", "$$t"]}}}, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "wantType": "A", + "joined": [{"_id": 10, "items": {"type": "A", "v": 1}}], + } + ], + msg="$lookup let var should be accessible in $match after $unwind in the sub-pipeline", + ), + LookupTestCase( + "let_var_after_group", + foreign_docs=[ + {"_id": 10, "cat": "A", "amount": 60}, + {"_id": 11, "cat": "A", "amount": 50}, + {"_id": 12, "cat": "B", "amount": 30}, + ], + docs=[{"_id": 1, "minTotal": 100}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"min": "$minTotal"}, + "pipeline": [ + {"$group": {"_id": "$cat", "total": {"$sum": "$amount"}}}, + {"$match": {"$expr": {"$gte": ["$total", "$$min"]}}}, + ], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "minTotal": 100, "joined": [{"_id": "A", "total": 110}]}], + msg="$lookup let var should be accessible in $match after $group in the sub-pipeline", + ), + LookupTestCase( + "let_var_before_and_after_group", + foreign_docs=[ + {"_id": 10, "type": "A", "amount": 5}, + {"_id": 11, "type": "A", "amount": 7}, + {"_id": 12, "type": "B", "amount": 9}, + ], + docs=[{"_id": 1, "cat": "A"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"c": "$cat"}, + "pipeline": [ + {"$match": {"$expr": {"$eq": ["$type", "$$c"]}}}, + {"$group": {"_id": "$type", "total": {"$sum": "$amount"}}}, + {"$addFields": {"whichCat": "$$c"}}, + ], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "cat": "A", "joined": [{"_id": "A", "total": 12, "whichCat": "A"}]}], + msg="$lookup should keep a let var accessible both in a $match before a " + "$group and in an $addFields after it", + ), + LookupTestCase( + "let_var_in_addFields_then_sort", + foreign_docs=[ + {"_id": 10, "score": 60}, + {"_id": 11, "score": 80}, + {"_id": 12, "score": 75}, + ], + docs=[{"_id": 1, "target": 70}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"tgt": "$target"}, + "pipeline": [ + {"$addFields": {"diff": {"$abs": {"$subtract": ["$score", "$$tgt"]}}}}, + {"$sort": {"diff": 1, "_id": 1}}, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "target": 70, + "joined": [ + {"_id": 12, "score": 75, "diff": 5}, + {"_id": 10, "score": 60, "diff": 10}, + {"_id": 11, "score": 80, "diff": 10}, + ], + } + ], + msg="$lookup let var in $addFields should feed a following $sort by " + "proximity to the target", + ), + LookupTestCase( + "let_var_top_n_pattern", + foreign_docs=[ + {"_id": 10, "type": "A", "score": 50}, + {"_id": 11, "type": "A", "score": 90}, + {"_id": 12, "type": "A", "score": 70}, + {"_id": 13, "type": "B", "score": 95}, + ], + docs=[{"_id": 1, "cat": "A"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"c": "$cat"}, + "pipeline": [ + {"$match": {"$expr": {"$eq": ["$type", "$$c"]}}}, + {"$sort": {"score": -1}}, + {"$limit": 2}, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "cat": "A", + "joined": [ + {"_id": 11, "type": "A", "score": 90}, + {"_id": 12, "type": "A", "score": 70}, + ], + } + ], + msg="$lookup top-N pattern with a let var $match then $sort and $limit " + "should return the top matches", + ), + LookupTestCase( + "let_var_reused_in_multiple_stages", + foreign_docs=[{"_id": 10, "score": 80}, {"_id": 11, "score": 30}], + docs=[{"_id": 1, "threshold": 50}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"thr": "$threshold"}, + "pipeline": [ + {"$match": {"$expr": {"$gte": ["$score", "$$thr"]}}}, + {"$addFields": {"above_by": {"$subtract": ["$score", "$$thr"]}}}, + ], + "as": "joined", + } + } + ], + expected=[ + {"_id": 1, "threshold": 50, "joined": [{"_id": 10, "score": 80, "above_by": 30}]} + ], + msg="$lookup should keep the same let var accessible in both $match and a later $addFields", + ), + LookupTestCase( + "three_different_let_vars_in_sequence", + foreign_docs=[{"_id": 10, "score": 80}, {"_id": 11, "score": 30}], + docs=[{"_id": 1, "minScore": 50, "label": "high", "multiplier": 2}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"min": "$minScore", "lbl": "$label", "mult": "$multiplier"}, + "pipeline": [ + {"$match": {"$expr": {"$gte": ["$score", "$$min"]}}}, + {"$addFields": {"tag": "$$lbl"}}, + {"$addFields": {"scaled": {"$multiply": ["$score", "$$mult"]}}}, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "minScore": 50, + "label": "high", + "multiplier": 2, + "joined": [{"_id": 10, "score": 80, "tag": "high", "scaled": 160}], + } + ], + msg="$lookup should access three different let vars used across sequential " + "sub-pipeline stages", + ), + LookupTestCase( + "let_var_after_project", + foreign_docs=[{"_id": 10, "name": "task", "extra": "ignored"}], + docs=[{"_id": 1, "suffix": "_done"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"sfx": "$suffix"}, + "pipeline": [ + {"$project": {"name": 1, "_id": 0}}, + {"$addFields": {"full": {"$concat": ["$name", "$$sfx"]}}}, + ], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "suffix": "_done", "joined": [{"name": "task", "full": "task_done"}]}], + msg="$lookup let var should be accessible in $addFields after $project in the sub-pipeline", + ), + LookupTestCase( + "let_var_after_replace_root", + foreign_docs=[{"_id": 10, "score": 80}], + docs=[{"_id": 1, "tag": "kept"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"t": "$tag"}, + "pipeline": [ + {"$replaceRoot": {"newRoot": {"score": "$score"}}}, + {"$addFields": {"lv": "$$t"}}, + ], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "tag": "kept", "joined": [{"score": 80, "lv": "kept"}]}], + msg="$lookup let var should remain accessible in $addFields after " + "$replaceRoot swaps the document root", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(LOOKUP_VERBOSE_STAGE_COMBINATION_TESTS)) +def test_verbose_stage_combinations(collection, test_case: LookupTestCase): + """Test let variable access across multi-stage $lookup sub-pipelines.""" + with setup_lookup(collection, test_case) as foreign_name: + command = build_lookup_command(collection, test_case, foreign_name) + result = execute_command(collection, command) + assertResult( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + )