diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_concise_correlated_subquery.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_concise_correlated_subquery.py index 53d9c686e..0d2a8e15a 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_concise_correlated_subquery.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_concise_correlated_subquery.py @@ -1,4 +1,9 @@ -"""Tests for $lookup concise correlated subquery — localField + foreignField + pipeline.""" +"""Tests for $lookup concise correlated subquery — localField + foreignField + pipeline. + +Covers equality-match-then-pipeline, let combined with equality, null/empty-string +field degradation to uncorrelated, empty-pipeline equivalence, no-match behavior, +and array localField matching. +""" from __future__ import annotations @@ -212,6 +217,101 @@ " returning all foreign documents" ), ), + LookupTestCase( + "concise_empty_pipeline_same_as_simple_equality", + docs=[{"_id": 1, "x": "val"}], + foreign_docs=[ + {"_id": 10, "y": "val"}, + {"_id": 11, "y": "other"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "localField": "x", + "foreignField": "y", + "pipeline": [], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "x": "val", + "joined": [{"_id": 10, "y": "val"}], + } + ], + msg=( + "$lookup concise syntax with empty pipeline should be" + " equivalent to simple equality lookup" + ), + ), + LookupTestCase( + "concise_equality_no_match_pipeline_not_executed", + docs=[{"_id": 1, "x": "no_match"}], + foreign_docs=[ + {"_id": 10, "y": "val1"}, + {"_id": 11, "y": "val2"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "localField": "x", + "foreignField": "y", + "let": {"v": "$x"}, + "pipeline": [{"$addFields": {"src": "$$v"}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "x": "no_match", + "joined": [], + } + ], + msg=( + "$lookup concise syntax with no equality matches should" + " result in empty array (pipeline never runs on empty set)" + ), + ), + LookupTestCase( + "concise_localField_array_matches_scalar", + docs=[{"_id": 1, "refs": [10, 20]}], + foreign_docs=[ + {"_id": 10, "val": "a"}, + {"_id": 20, "val": "b"}, + {"_id": 30, "val": "c"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "localField": "refs", + "foreignField": "_id", + "pipeline": [], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "refs": [10, 20], + "joined": [ + {"_id": 10, "val": "a"}, + {"_id": 20, "val": "b"}, + ], + } + ], + msg=( + "$lookup concise syntax with array localField should match" + " each element against scalar foreignField" + ), + ), ] diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_composition.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_composition.py new file mode 100644 index 000000000..4647b86d0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_composition.py @@ -0,0 +1,266 @@ +"""Tests for $lookup correlated subquery — sub-pipeline composition with other stages. + +Verifies let variables remain accessible when the sub-pipeline contains +various aggregation stages in sequence ($unwind, $group, $sort, $limit, $facet). +""" + +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 [Correlated Subquery — Stage Composition]: let variables remain +# accessible when the sub-pipeline contains other aggregation stages between +# the let binding and the $match or $addFields usage. +LOOKUP_COMPOSITION_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "let_var_after_unwind", + docs=[{"_id": 1, "wantType": "A"}], + foreign_docs=[ + {"_id": 10, "items": [{"type": "A", "v": 1}, {"type": "B", "v": 2}]}, + ], + 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 sub-pipeline", + ), + LookupTestCase( + "let_var_after_group", + docs=[{"_id": 1, "minTotal": 100}], + foreign_docs=[ + {"_id": 10, "cat": "A", "amount": 60}, + {"_id": 11, "cat": "A", "amount": 50}, + {"_id": 12, "cat": "B", "amount": 30}, + ], + 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 sub-pipeline", + ), + LookupTestCase( + "let_var_in_addFields_then_sort", + docs=[{"_id": 1, "target": 70}], + foreign_docs=[ + {"_id": 10, "score": 60}, + {"_id": 11, "score": 80}, + {"_id": 12, "score": 75}, + ], + 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 producing diff then $sort" + " should order by proximity to let var target" + ), + ), + LookupTestCase( + "let_var_top_n_pattern", + docs=[{"_id": 1, "cat": "A"}], + 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}, + ], + 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 correlated top-N pattern: $match with let var," + " $sort, $limit should return top N matching docs" + ), + ), + LookupTestCase( + "let_var_reused_in_multiple_stages", + docs=[{"_id": 1, "threshold": 50}], + foreign_docs=[ + {"_id": 10, "score": 80}, + {"_id": 11, "score": 30}, + ], + 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 same let var used in $match and $addFields should" + " be accessible in both stages" + ), + ), + LookupTestCase( + "three_different_let_vars_in_sequence", + docs=[{"_id": 1, "minScore": 50, "label": "high", "multiplier": 2}], + foreign_docs=[ + {"_id": 10, "score": 80}, + {"_id": 11, "score": 30}, + ], + 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 pipeline using 3 different let vars in sequential" + " stages should access all correctly" + ), + ), + LookupTestCase( + "let_var_after_project", + docs=[{"_id": 1, "suffix": "_done"}], + foreign_docs=[{"_id": 10, "name": "task", "extra": "ignored"}], + 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 sub-pipeline", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(LOOKUP_COMPOSITION_TESTS)) +def test_lookup_correlated_composition(collection, test_case: LookupTestCase): + """Test $lookup correlated subquery sub-pipeline composition with other stages.""" + 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/test_lookup_correlated_edge_cases.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_edge_cases.py new file mode 100644 index 000000000..12022487f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_edge_cases.py @@ -0,0 +1,341 @@ +"""Tests for $lookup correlated subquery — edge cases, errors, and boundary behavior. + +Covers let expression errors, undefined variable references, naming edge cases, +numeric equivalence, special numeric values, $$REMOVE semantics, and +let variable path traversal. +""" + +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +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, assertSuccessNaN +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + LET_UNDEFINED_VARIABLE_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Correlated Subquery — Edge Cases]: let expression error propagation, +# undefined variable behavior, numeric equivalence in $expr, special numeric +# values, $$REMOVE semantics, and path traversal on let variables. + + +LOOKUP_EDGE_ERROR_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "let_expr_type_mismatch_error", + docs=[{"_id": 1, "num": 5}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"bad": {"$add": ["$num", "not_a_number"]}}, + "pipeline": [], + "as": "joined", + } + } + ], + error_code=TYPE_MISMATCH_ERROR, + msg="$lookup let with $add type mismatch should propagate error", + ), + LookupTestCase( + "let_expr_error_for_some_docs_fails_all", + docs=[ + {"_id": 1, "x": 10}, + {"_id": 2, "x": 0}, + ], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"inv": {"$divide": [1, "$x"]}}, + "pipeline": [{"$addFields": {"val": "$$inv"}}], + "as": "joined", + } + } + ], + error_code=BAD_VALUE_ERROR, + msg=( + "$lookup let expression that errors for one outer doc" + " should fail the entire aggregate" + ), + ), +] + + +LOOKUP_EDGE_UNDEFINED_VAR_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "undefined_var_in_addFields_errors", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$_id"}, + "pipeline": [{"$addFields": {"val": "$$undefined_var"}}], + "as": "joined", + } + } + ], + error_code=LET_UNDEFINED_VARIABLE_ERROR, + msg="$lookup referencing undefined variable in $addFields should error", + ), +] + + +LOOKUP_EDGE_NUMERIC_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "numeric_equivalence_int_matches_double", + docs=[{"_id": 1, "val": 5}], + foreign_docs=[ + {"_id": 10, "field": 5.0}, + {"_id": 11, "field": 6.0}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$val"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$field", "$$x"]}}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "val": 5, "joined": [{"_id": 10, "field": 5.0}]}], + msg="$lookup $expr $eq with int let var should match double of same numeric value", + ), + LookupTestCase( + "numeric_equivalence_long_matches_decimal128", + docs=[{"_id": 1, "val": Int64(5)}], + foreign_docs=[ + {"_id": 10, "field": Decimal128("5")}, + {"_id": 11, "field": Decimal128("6")}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$val"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$field", "$$x"]}}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "val": Int64(5), "joined": [{"_id": 10, "field": Decimal128("5")}]}], + msg="$lookup $expr $eq with Int64 let var should match Decimal128 of same value", + ), + LookupTestCase( + "type_distinction_int_does_not_match_string", + docs=[{"_id": 1, "val": 5}], + foreign_docs=[ + {"_id": 10, "field": "5"}, + {"_id": 11, "field": 5}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$val"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$field", "$$x"]}}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "val": 5, "joined": [{"_id": 11, "field": 5}]}], + msg="$lookup $expr $eq with int let var should NOT match string of same characters", + ), +] + + +LOOKUP_EDGE_SPECIAL_VALUES_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "negative_zero_matches_positive_zero", + docs=[{"_id": 1, "val": -0.0}], + foreign_docs=[ + {"_id": 10, "field": 0.0}, + {"_id": 11, "field": 1.0}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$val"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$field", "$$x"]}}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "val": -0.0, "joined": [{"_id": 10, "field": 0.0}]}], + msg="$lookup $expr $eq with -0.0 let var should match 0.0 (IEEE 754 equality)", + ), +] + + +LOOKUP_EDGE_REMOVE_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "remove_in_addFields_omits_field", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10, "keep": "yes"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"removed": "$$REMOVE"}, + "pipeline": [{"$addFields": {"x": "$$removed", "y": "present"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "joined": [{"_id": 10, "keep": "yes", "y": "present"}]}], + msg=( + "$lookup let with $$REMOVE value should omit the field" + " when used in $addFields (field not present in output)" + ), + ), + LookupTestCase( + "remove_type_is_missing", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"removed": "$$REMOVE"}, + "pipeline": [{"$addFields": {"t": {"$type": "$$removed"}}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "joined": [{"_id": 10, "t": "missing"}]}], + msg="$lookup let with $$REMOVE should report $type as 'missing'", + ), +] + + +LOOKUP_EDGE_PATH_TRAVERSAL_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "path_traversal_on_let_var_object", + docs=[{"_id": 1, "obj": {"nested": {"field": "deep_value"}}}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"doc": "$obj"}, + "pipeline": [{"$addFields": {"val": "$$doc.nested.field"}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "obj": {"nested": {"field": "deep_value"}}, + "joined": [{"_id": 10, "val": "deep_value"}], + } + ], + msg="$lookup let var path traversal '$$doc.nested.field' should resolve sub-path", + ), + LookupTestCase( + "path_traversal_on_let_var_nonexistent_path", + docs=[{"_id": 1, "obj": {"a": 1}}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"doc": "$obj"}, + "pipeline": [ + { + "$addFields": { + "val": "$$doc.missing.path", + "t": {"$type": "$$doc.missing.path"}, + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "obj": {"a": 1}, + "joined": [{"_id": 10, "t": "missing"}], + } + ], + msg=( + "$lookup let var path traversal to non-existent sub-path" + " should resolve to missing (field omitted from output)" + ), + ), +] + + +# --- Combine all tests --- +LOOKUP_CORRELATED_EDGE_CASES_ALL: list[LookupTestCase] = ( + LOOKUP_EDGE_ERROR_TESTS + + LOOKUP_EDGE_UNDEFINED_VAR_TESTS + + LOOKUP_EDGE_NUMERIC_TESTS + + LOOKUP_EDGE_SPECIAL_VALUES_TESTS + + LOOKUP_EDGE_REMOVE_TESTS + + LOOKUP_EDGE_PATH_TRAVERSAL_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(LOOKUP_CORRELATED_EDGE_CASES_ALL)) +def test_lookup_correlated_edge_cases(collection, test_case: LookupTestCase): + """Test $lookup correlated subquery edge cases, errors, and boundary behavior.""" + 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, + ) + + +_NAN_TEST = LookupTestCase( + "nan_matches_nan_in_expr_eq", + docs=[{"_id": 1, "val": float("nan")}], + foreign_docs=[ + {"_id": 10, "field": float("nan")}, + {"_id": 11, "field": 0}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$val"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$field", "$$x"]}}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "val": float("nan"), "joined": [{"_id": 10, "field": float("nan")}]}], + msg="$lookup $expr $eq with NaN let var matches NaN foreign field (NaN == NaN in $expr)", +) + + +@pytest.mark.aggregate +def test_lookup_correlated_nan_matches_nan(collection): + """Test $lookup $expr $eq with NaN let var matches NaN (uses NaN-aware assertion).""" + with setup_lookup(collection, _NAN_TEST) as foreign_name: + command = build_lookup_command(collection, _NAN_TEST, foreign_name) + result = execute_command(collection, command) + assertSuccessNaN(result, _NAN_TEST.expected, msg=_NAN_TEST.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_let_forms.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_let_forms.py new file mode 100644 index 000000000..031acd253 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_let_forms.py @@ -0,0 +1,874 @@ +"""Tests for $lookup correlated subquery — let variable forms and type preservation. + +Covers comprehensive let variable value forms beyond what exists in +test_lookup_correlated_subquery.py: additional BSON types (ObjectId, Decimal128, +BinData, Timestamp, Regex, MinKey, MaxKey), field path traversal patterns, +complex aggregation expressions, and mixed multi-variable let specifications. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey + +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 +from documentdb_tests.framework.test_constants import ( + BSON_TYPE_SAMPLES, + DATE_Y2K, + OID_EPOCH, + TS_EPOCH, + BsonType, +) + +# Property [Correlated Subquery — Let Forms]: let variables accept any BSON +# type and aggregation expression as value; type and precision are preserved +# through the sub-pipeline. + + +LOOKUP_LET_CONSTANT_LITERAL_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "let_constant_int64", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": Int64(9999999999)}, + "pipeline": [{"$addFields": {"val": "$$x", "t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "joined": [{"_id": 10, "val": Int64(9999999999), "t": "long"}], + } + ], + msg="$lookup let with Int64 constant should preserve long type", + ), + LookupTestCase( + "let_constant_decimal128", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": Decimal128("123.456")}, + "pipeline": [{"$addFields": {"val": "$$x", "t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "joined": [{"_id": 10, "val": Decimal128("123.456"), "t": "decimal"}], + } + ], + msg="$lookup let with Decimal128 constant should preserve decimal type", + ), + LookupTestCase( + "let_constant_objectid", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": OID_EPOCH}, + "pipeline": [{"$addFields": {"val": "$$x", "t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "joined": [{"_id": 10, "val": OID_EPOCH, "t": "objectId"}], + } + ], + msg="$lookup let with ObjectId constant should preserve objectId type", + ), + LookupTestCase( + "let_constant_date", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": DATE_Y2K}, + "pipeline": [{"$addFields": {"val": "$$x", "t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "joined": [{"_id": 10, "val": DATE_Y2K, "t": "date"}], + } + ], + msg="$lookup let with ISODate constant should preserve date type", + ), + LookupTestCase( + "let_constant_binary", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": Binary(b"\x00\x01\x02", 128)}, + "pipeline": [{"$addFields": {"val": "$$x", "t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "joined": [{"_id": 10, "val": Binary(b"\x00\x01\x02", 128), "t": "binData"}], + } + ], + msg="$lookup let with BinData constant should preserve binData type", + ), + LookupTestCase( + "let_constant_timestamp", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": TS_EPOCH}, + "pipeline": [{"$addFields": {"val": "$$x", "t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "joined": [{"_id": 10, "val": TS_EPOCH, "t": "timestamp"}], + } + ], + msg="$lookup let with Timestamp constant should preserve timestamp type", + ), + LookupTestCase( + "let_constant_regex", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": BSON_TYPE_SAMPLES[BsonType.REGEX]}, + "pipeline": [{"$addFields": {"t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "joined": [{"_id": 10, "t": "regex"}], + } + ], + msg="$lookup let with Regex constant should preserve regex type", + ), + LookupTestCase( + "let_constant_minkey", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": MinKey()}, + "pipeline": [{"$addFields": {"t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "joined": [{"_id": 10, "t": "minKey"}], + } + ], + msg="$lookup let with MinKey constant should preserve minKey type", + ), + LookupTestCase( + "let_constant_maxkey", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": MaxKey()}, + "pipeline": [{"$addFields": {"t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "joined": [{"_id": 10, "t": "maxKey"}], + } + ], + msg="$lookup let with MaxKey constant should preserve maxKey type", + ), +] + + +LOOKUP_LET_FIELD_REFERENCE_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "let_field_ref_nested_path", + docs=[{"_id": 1, "nested": {"field": 42}}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$nested.field"}, + "pipeline": [{"$addFields": {"val": "$$x"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "nested": {"field": 42}, "joined": [{"_id": 10, "val": 42}]}], + msg="$lookup let with nested field path should resolve nested document field", + ), + LookupTestCase( + "let_field_ref_deeply_nested", + docs=[{"_id": 1, "a": {"b": {"c": {"d": "deep"}}}}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$a.b.c.d"}, + "pipeline": [{"$addFields": {"val": "$$x"}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "a": {"b": {"c": {"d": "deep"}}}, + "joined": [{"_id": 10, "val": "deep"}], + } + ], + msg="$lookup let with deeply nested field path should resolve through multiple levels", + ), + LookupTestCase( + "let_field_ref_array_field", + docs=[{"_id": 1, "arr": [10, 20, 30]}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$arr"}, + "pipeline": [{"$addFields": {"val": "$$x"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "arr": [10, 20, 30], "joined": [{"_id": 10, "val": [10, 20, 30]}]}], + msg="$lookup let with field path to array should pass entire array as variable value", + ), + LookupTestCase( + "let_field_ref_dotted_through_array_of_objects", + docs=[{"_id": 1, "items": [{"name": "a"}, {"name": "b"}]}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$items.name"}, + "pipeline": [{"$addFields": {"val": "$$x"}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "items": [{"name": "a"}, {"name": "b"}], + "joined": [{"_id": 10, "val": ["a", "b"]}], + } + ], + msg=( + "$lookup let with dotted path through array-of-objects should" + " resolve to array of matched values" + ), + ), + LookupTestCase( + "let_field_ref_null_field", + docs=[{"_id": 1, "nullField": None}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$nullField"}, + "pipeline": [{"$addFields": {"val": "$$x", "t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "nullField": None, + "joined": [{"_id": 10, "val": None, "t": "null"}], + } + ], + msg="$lookup let with field path to null field should resolve to null (not missing)", + ), + LookupTestCase( + "let_field_ref_arrayElemAt_expression", + docs=[{"_id": 1, "arr": [10, 20, 30]}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": {"$arrayElemAt": ["$arr", 0]}}, + "pipeline": [{"$addFields": {"val": "$$x"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "arr": [10, 20, 30], "joined": [{"_id": 10, "val": 10}]}], + msg="$lookup let with $arrayElemAt expression should extract single array element", + ), +] + + +LOOKUP_LET_EXPRESSION_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "let_expr_cond_true_branch", + docs=[{"_id": 1, "val": 10}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": {"$cond": [{"$gt": ["$val", 5]}, "high", "low"]}}, + "pipeline": [{"$addFields": {"label": "$$x"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "val": 10, "joined": [{"_id": 10, "label": "high"}]}], + msg="$lookup let with $cond expression should evaluate true branch correctly", + ), + LookupTestCase( + "let_expr_cond_false_branch", + docs=[{"_id": 1, "val": 2}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": {"$cond": [{"$gt": ["$val", 5]}, "high", "low"]}}, + "pipeline": [{"$addFields": {"label": "$$x"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "val": 2, "joined": [{"_id": 10, "label": "low"}]}], + msg="$lookup let with $cond expression should evaluate false branch correctly", + ), + LookupTestCase( + "let_expr_ifNull_with_null_field", + docs=[{"_id": 1, "maybe": None}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": {"$ifNull": ["$maybe", "default"]}}, + "pipeline": [{"$addFields": {"val": "$$x"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "maybe": None, "joined": [{"_id": 10, "val": "default"}]}], + msg="$lookup let with $ifNull should use default when field is null", + ), + LookupTestCase( + "let_expr_size", + docs=[{"_id": 1, "arr": [1, 2, 3]}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": {"$size": "$arr"}}, + "pipeline": [{"$addFields": {"count": "$$x"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "arr": [1, 2, 3], "joined": [{"_id": 10, "count": 3}]}], + msg="$lookup let with $size expression should compute array length", + ), + LookupTestCase( + "let_expr_toUpper", + docs=[{"_id": 1, "name": "hello"}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": {"$toUpper": "$name"}}, + "pipeline": [{"$addFields": {"upper": "$$x"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "name": "hello", "joined": [{"_id": 10, "upper": "HELLO"}]}], + msg="$lookup let with $toUpper expression should transform string to uppercase", + ), + LookupTestCase( + "let_expr_nested_arithmetic", + docs=[{"_id": 1, "a": 3, "b": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": {"$add": [{"$multiply": ["$a", 2]}, "$b"]}}, + "pipeline": [{"$addFields": {"val": "$$x"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "a": 3, "b": 1, "joined": [{"_id": 10, "val": 7}]}], + msg="$lookup let with nested arithmetic expression should compute correctly", + ), + LookupTestCase( + "let_expr_type_operator", + docs=[{"_id": 1, "field": 42}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": {"$type": "$field"}}, + "pipeline": [{"$addFields": {"typename": "$$x"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "field": 42, "joined": [{"_id": 10, "typename": "int"}]}], + msg="$lookup let with $type expression should produce type name string", + ), + LookupTestCase( + "let_expr_dateToString", + docs=[{"_id": 1, "d": DATE_Y2K}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": {"$dateToString": {"format": "%Y", "date": "$d"}}}, + "pipeline": [{"$addFields": {"year": "$$x"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "d": DATE_Y2K, "joined": [{"_id": 10, "year": "2000"}]}], + msg="$lookup let with $dateToString expression should format date", + ), +] + +# multiple expressions referencing same source field + +LOOKUP_LET_MIXED_FORMS_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "let_mixed_constant_field_expression", + docs=[{"_id": 1, "score": 85}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": { + "threshold": 70, + "val": "$score", + "doubled": {"$multiply": ["$score", 2]}, + }, + "pipeline": [ + { + "$addFields": { + "t": "$$threshold", + "v": "$$val", + "d": "$$doubled", + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "score": 85, + "joined": [{"_id": 10, "t": 70, "v": 85, "d": 170}], + } + ], + msg=( + "$lookup let with constant, field ref, and expression should" + " resolve all three independently" + ), + ), + LookupTestCase( + "let_mixed_system_var_and_field_ref", + docs=[{"_id": 1, "specific": "value"}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"doc": "$$ROOT", "field_val": "$specific"}, + "pipeline": [ + { + "$addFields": { + "full_doc": "$$doc", + "single_field": "$$field_val", + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "specific": "value", + "joined": [ + { + "_id": 10, + "full_doc": {"_id": 1, "specific": "value"}, + "single_field": "value", + } + ], + } + ], + msg=( + "$lookup let with $$ROOT and field ref should resolve" + " system variable and field path independently" + ), + ), + LookupTestCase( + "let_mixed_multiple_expressions_same_source", + docs=[{"_id": 1, "x": 10}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": { + "doubled": {"$multiply": ["$x", 2]}, + "halved": {"$divide": ["$x", 2]}, + "original": "$x", + }, + "pipeline": [ + { + "$addFields": { + "d": "$$doubled", + "h": "$$halved", + "o": "$$original", + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "x": 10, + "joined": [{"_id": 10, "d": 20, "h": 5.0, "o": 10}], + } + ], + msg=( + "$lookup let with multiple expressions referencing same source" + " field should evaluate each independently" + ), + ), + LookupTestCase( + "let_many_variables", + docs=[{"_id": 1, "a": 1, "b": 2, "c": 3, "d": 4, "e": 5}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": { + "v1": "$a", + "v2": "$b", + "v3": "$c", + "v4": "$d", + "v5": "$e", + "s1": {"$add": ["$a", "$b"]}, + "s2": {"$multiply": ["$c", "$d"]}, + "const": "static", + }, + "pipeline": [ + { + "$addFields": { + "r1": "$$v1", + "r2": "$$v2", + "r3": "$$v3", + "r4": "$$v4", + "r5": "$$v5", + "rs1": "$$s1", + "rs2": "$$s2", + "rc": "$$const", + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "a": 1, + "b": 2, + "c": 3, + "d": 4, + "e": 5, + "joined": [ + { + "_id": 10, + "r1": 1, + "r2": 2, + "r3": 3, + "r4": 4, + "r5": 5, + "rs1": 3, + "rs2": 12, + "rc": "static", + } + ], + } + ], + msg="$lookup let with many variables (8) should all be accessible in sub-pipeline", + ), + LookupTestCase( + "let_variable_with_underscore_name", + docs=[{"_id": 1, "x": "val"}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"my_var": "$x"}, + "pipeline": [{"$addFields": {"result": "$$my_var"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "x": "val", "joined": [{"_id": 10, "result": "val"}]}], + msg="$lookup let variable with underscore in name should be valid and accessible", + ), + LookupTestCase( + "let_variable_name_is_prefix_of_another", + docs=[{"_id": 1, "a": "first", "b": "second"}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"item": "$a", "itemId": "$b"}, + "pipeline": [{"$addFields": {"r1": "$$item", "r2": "$$itemId"}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "a": "first", + "b": "second", + "joined": [{"_id": 10, "r1": "first", "r2": "second"}], + } + ], + msg=( + "$lookup let with variable name that is prefix of another" + " should resolve both without ambiguity" + ), + ), +] + + +LOOKUP_LET_ADDITIONAL_FORMS_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "literal_array_constant", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"carr": [1, 2, 3]}, + "pipeline": [{"$project": {"_id": 0, "ra": "$$carr"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "joined": [{"ra": [1, 2, 3]}]}], + msg="$lookup let should accept a literal array of constants as a value", + ), + LookupTestCase( + "literal_object_via_dollar_literal", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"cobj": {"$literal": {"k": 1}}}, + "pipeline": [{"$project": {"_id": 0, "robj": "$$cobj"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "joined": [{"robj": {"k": 1}}]}], + msg="$lookup let should accept an object forced via $literal as a value", + ), + LookupTestCase( + "expr_date_add_value", + docs=[{"_id": 1, "dt": datetime(2020, 1, 1, tzinfo=timezone.utc)}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": { + "d": { + "$dateAdd": { + "startDate": "$dt", + "unit": "day", + "amount": 1, + } + } + }, + "pipeline": [{"$project": {"_id": 0, "rd": "$$d"}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "dt": datetime(2020, 1, 1, tzinfo=timezone.utc), + "joined": [{"rd": datetime(2020, 1, 2, tzinfo=timezone.utc)}], + } + ], + msg=( + "$lookup let should accept a $dateAdd expression value" + " evaluated against the input document" + ), + ), + LookupTestCase( + "expr_map_value", + docs=[{"_id": 1, "vals": [1, 2, 3]}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": { + "arr": { + "$map": { + "input": "$vals", + "as": "e", + "in": {"$multiply": ["$$e", 2]}, + } + } + }, + "pipeline": [{"$project": {"_id": 0, "ra": "$$arr"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "vals": [1, 2, 3], "joined": [{"ra": [2, 4, 6]}]}], + msg=( + "$lookup let should accept a $map expression value" + " evaluated against the input document" + ), + ), + LookupTestCase( + "mixed_form_missing_field_and_literal_no_cross_contamination", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"c": 42, "m": "$absent"}, + "pipeline": [{"$project": {"_id": 0, "rc": "$$c", "rm": "$$m"}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "joined": [{"rc": 42}]}], + msg=( + "$lookup mixed-form let with a missing field and a literal" + " should keep the literal and omit the missing field" + ), + ), +] + + +# --- Combine all tests --- +LOOKUP_CORRELATED_LET_FORMS_ALL: list[LookupTestCase] = ( + LOOKUP_LET_CONSTANT_LITERAL_TESTS + + LOOKUP_LET_FIELD_REFERENCE_TESTS + + LOOKUP_LET_EXPRESSION_TESTS + + LOOKUP_LET_MIXED_FORMS_TESTS + + LOOKUP_LET_ADDITIONAL_FORMS_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(LOOKUP_CORRELATED_LET_FORMS_ALL)) +def test_lookup_correlated_let_forms(collection, test_case: LookupTestCase): + """Test $lookup correlated subquery let variable forms and type preservation.""" + 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/test_lookup_correlated_match.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_match.py new file mode 100644 index 000000000..912b8da37 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_match.py @@ -0,0 +1,1007 @@ +"""Tests for $lookup correlated subquery — $match with $expr using let variables. + +Covers the primary use case for correlated $lookup: filtering foreign documents +using let variables inside $match with $expr. Includes comparison operators, +logical operators, arithmetic, string/array operations, date comparisons, +null/missing handling, and literal behavior without $expr. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +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 [Correlated Subquery — $match/$expr]: let variables are usable inside +# $match with $expr for all comparison, logical, arithmetic, array, and date +# operators; without $expr the variable reference is a literal string. + + +LOOKUP_MATCH_COMPARISON_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "match_expr_ne", + docs=[{"_id": 1, "exclude": "inactive"}], + foreign_docs=[ + {"_id": 10, "status": "active"}, + {"_id": 11, "status": "inactive"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"excl": "$exclude"}, + "pipeline": [{"$match": {"$expr": {"$ne": ["$status", "$$excl"]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "exclude": "inactive", + "joined": [{"_id": 10, "status": "active"}], + } + ], + msg="$lookup $match $expr $ne should match foreign docs where field differs from let var", + ), + LookupTestCase( + "match_expr_gte", + docs=[{"_id": 1, "minScore": 70}], + foreign_docs=[ + {"_id": 10, "score": 80}, + {"_id": 11, "score": 60}, + {"_id": 12, "score": 70}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"min": "$minScore"}, + "pipeline": [{"$match": {"$expr": {"$gte": ["$score", "$$min"]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "minScore": 70, + "joined": [{"_id": 10, "score": 80}, {"_id": 12, "score": 70}], + } + ], + msg="$lookup $match $expr $gte should match foreign docs where field >= let var", + ), + LookupTestCase( + "match_expr_lt", + docs=[{"_id": 1, "maxPrice": 50}], + foreign_docs=[ + {"_id": 10, "price": 30}, + {"_id": 11, "price": 70}, + {"_id": 12, "price": 50}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"maxP": "$maxPrice"}, + "pipeline": [{"$match": {"$expr": {"$lt": ["$price", "$$maxP"]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "maxPrice": 50, + "joined": [{"_id": 10, "price": 30}], + } + ], + msg="$lookup $match $expr $lt should match foreign docs where field < let var", + ), + LookupTestCase( + "match_expr_lte", + docs=[{"_id": 1, "maxPrice": 50}], + foreign_docs=[ + {"_id": 10, "price": 30}, + {"_id": 11, "price": 70}, + {"_id": 12, "price": 50}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"maxP": "$maxPrice"}, + "pipeline": [{"$match": {"$expr": {"$lte": ["$price", "$$maxP"]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "maxPrice": 50, + "joined": [{"_id": 10, "price": 30}, {"_id": 12, "price": 50}], + } + ], + msg="$lookup $match $expr $lte should match foreign docs where field <= let var", + ), +] + + +LOOKUP_MATCH_LOGICAL_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "match_expr_and_two_let_vars", + docs=[{"_id": 1, "wantType": "A", "minScore": 50}], + foreign_docs=[ + {"_id": 10, "type": "A", "score": 80}, + {"_id": 11, "type": "A", "score": 30}, + {"_id": 12, "type": "B", "score": 90}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"t": "$wantType", "min": "$minScore"}, + "pipeline": [ + { + "$match": { + "$expr": { + "$and": [ + {"$eq": ["$type", "$$t"]}, + {"$gte": ["$score", "$$min"]}, + ] + } + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "wantType": "A", + "minScore": 50, + "joined": [{"_id": 10, "type": "A", "score": 80}], + } + ], + msg="$lookup $match $expr $and should apply both let var conditions", + ), + LookupTestCase( + "match_expr_or_two_let_vars", + docs=[{"_id": 1, "cat1": "electronics", "cat2": "books"}], + foreign_docs=[ + {"_id": 10, "category": "electronics"}, + {"_id": 11, "category": "clothing"}, + {"_id": 12, "category": "books"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"c1": "$cat1", "c2": "$cat2"}, + "pipeline": [ + { + "$match": { + "$expr": { + "$or": [ + {"$eq": ["$category", "$$c1"]}, + {"$eq": ["$category", "$$c2"]}, + ] + } + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "cat1": "electronics", + "cat2": "books", + "joined": [ + {"_id": 10, "category": "electronics"}, + {"_id": 12, "category": "books"}, + ], + } + ], + msg="$lookup $match $expr $or should match when either let var condition is met", + ), + LookupTestCase( + "match_expr_not_with_let_var", + docs=[{"_id": 1, "excludeStatus": "deleted"}], + foreign_docs=[ + {"_id": 10, "status": "active"}, + {"_id": 11, "status": "deleted"}, + {"_id": 12, "status": "pending"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"excl": "$excludeStatus"}, + "pipeline": [{"$match": {"$expr": {"$not": [{"$eq": ["$status", "$$excl"]}]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "excludeStatus": "deleted", + "joined": [ + {"_id": 10, "status": "active"}, + {"_id": 12, "status": "pending"}, + ], + } + ], + msg="$lookup $match $expr $not should exclude docs matching the let var", + ), +] + + +LOOKUP_MATCH_ARITHMETIC_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "match_expr_add_let_var", + docs=[{"_id": 1, "base": 40}], + foreign_docs=[ + {"_id": 10, "price": 45}, + {"_id": 11, "price": 55}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"b": "$base"}, + "pipeline": [{"$match": {"$expr": {"$lt": ["$price", {"$add": ["$$b", 10]}]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "base": 40, + "joined": [{"_id": 10, "price": 45}], + } + ], + msg="$lookup $match $expr with $add on let var should compute threshold correctly", + ), + LookupTestCase( + "match_expr_subtract_let_var", + docs=[{"_id": 1, "target": 100}], + foreign_docs=[ + {"_id": 10, "qty": 90}, + {"_id": 11, "qty": 96}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"tgt": "$target"}, + "pipeline": [ + {"$match": {"$expr": {"$gte": ["$qty", {"$subtract": ["$$tgt", 5]}]}}} + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "target": 100, + "joined": [{"_id": 11, "qty": 96}], + } + ], + msg="$lookup $match $expr with $subtract on let var should compute threshold correctly", + ), +] + + +LOOKUP_MATCH_ARRAY_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "match_expr_in_let_var_is_element", + docs=[{"_id": 1, "wantedTag": "python"}], + foreign_docs=[ + {"_id": 10, "tags": ["python", "java"]}, + {"_id": 11, "tags": ["rust", "go"]}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"tag": "$wantedTag"}, + "pipeline": [{"$match": {"$expr": {"$in": ["$$tag", "$tags"]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "wantedTag": "python", + "joined": [{"_id": 10, "tags": ["python", "java"]}], + } + ], + msg="$lookup $match $expr $in with let var as element should find it in foreign array", + ), + LookupTestCase( + "match_expr_in_let_var_is_array", + docs=[{"_id": 1, "allowed": ["python", "rust"]}], + foreign_docs=[ + {"_id": 10, "lang": "python"}, + {"_id": 11, "lang": "java"}, + {"_id": 12, "lang": "rust"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"allowedTags": "$allowed"}, + "pipeline": [{"$match": {"$expr": {"$in": ["$lang", "$$allowedTags"]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "allowed": ["python", "rust"], + "joined": [ + {"_id": 10, "lang": "python"}, + {"_id": 12, "lang": "rust"}, + ], + } + ], + msg="$lookup $match $expr $in with let var as array should match foreign elements in it", + ), + LookupTestCase( + "match_expr_setIntersection_overlap", + docs=[{"_id": 1, "wanted": ["A", "B"]}], + foreign_docs=[ + {"_id": 10, "cats": ["A", "C"]}, + {"_id": 11, "cats": ["D", "E"]}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"w": "$wanted"}, + "pipeline": [ + { + "$match": { + "$expr": { + "$gt": [ + {"$size": {"$setIntersection": ["$cats", "$$w"]}}, + 0, + ] + } + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "wanted": ["A", "B"], + "joined": [{"_id": 10, "cats": ["A", "C"]}], + } + ], + msg="$lookup $match $expr with $setIntersection should find foreign docs with overlap", + ), +] + + +LOOKUP_MATCH_NULL_MISSING_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "match_expr_ifNull_wrapping_let_var", + docs=[{"_id": 1, "maybe": None}], + foreign_docs=[ + {"_id": 10, "field": "fallback"}, + {"_id": 11, "field": "other"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$maybe"}, + "pipeline": [ + {"$match": {"$expr": {"$eq": ["$field", {"$ifNull": ["$$x", "fallback"]}]}}} + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "maybe": None, + "joined": [{"_id": 10, "field": "fallback"}], + } + ], + msg="$lookup $match $expr with $ifNull wrapping null let var should use default value", + ), +] + + +LOOKUP_MATCH_WITHOUT_EXPR_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "match_without_expr_gt_treats_var_as_string", + docs=[{"_id": 1, "minAge": 25}], + foreign_docs=[ + {"_id": 10, "age": 30}, + {"_id": 11, "age": "$$minAge"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"minAge": "$minAge"}, + "pipeline": [{"$match": {"age": {"$gt": "$$minAge"}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "minAge": 25, + "joined": [], + } + ], + msg=( + "$lookup $match without $expr should treat $$var as literal" + " string in $gt operator (no numeric comparison)" + ), + ), + LookupTestCase( + "match_mixing_plain_and_expr", + docs=[{"_id": 1, "localType": "premium"}], + foreign_docs=[ + {"_id": 10, "status": "active", "type": "premium"}, + {"_id": 11, "status": "active", "type": "basic"}, + {"_id": 12, "status": "inactive", "type": "premium"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"t": "$localType"}, + "pipeline": [ + { + "$match": { + "status": "active", + "$expr": {"$eq": ["$type", "$$t"]}, + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "localType": "premium", + "joined": [{"_id": 10, "status": "active", "type": "premium"}], + } + ], + msg=( + "$lookup $match mixing plain query with $expr should apply" + " both conditions — plain filter and let-variable comparison" + ), + ), + LookupTestCase( + "match_plain_query_no_let_interaction", + docs=[{"_id": 1, "status": "archived"}], + foreign_docs=[ + {"_id": 10, "status": "active"}, + {"_id": 11, "status": "archived"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"s": "$status"}, + "pipeline": [{"$match": {"status": "active"}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "status": "archived", + "joined": [{"_id": 10, "status": "active"}], + } + ], + msg=( + "$lookup $match with plain query should filter foreign docs" + " independently of let vars (matches foreign status, not outer)" + ), + ), +] + + +LOOKUP_MATCH_MULTIPLE_STAGES_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "match_two_stages_cumulative_filter", + docs=[{"_id": 1, "minScore": 50, "maxPrice": 100}], + foreign_docs=[ + {"_id": 10, "score": 80, "price": 90}, + {"_id": 11, "score": 80, "price": 150}, + {"_id": 12, "score": 30, "price": 50}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"min": "$minScore", "maxP": "$maxPrice"}, + "pipeline": [ + {"$match": {"$expr": {"$gte": ["$score", "$$min"]}}}, + {"$match": {"$expr": {"$lte": ["$price", "$$maxP"]}}}, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "minScore": 50, + "maxPrice": 100, + "joined": [{"_id": 10, "score": 80, "price": 90}], + } + ], + msg=( + "$lookup with two $match $expr stages should apply both" + " filters cumulatively using different let variables" + ), + ), + LookupTestCase( + "match_expr_then_plain_match", + docs=[{"_id": 1, "wantType": "A"}], + foreign_docs=[ + {"_id": 10, "type": "A", "active": True}, + {"_id": 11, "type": "A", "active": False}, + {"_id": 12, "type": "B", "active": True}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"t": "$wantType"}, + "pipeline": [ + {"$match": {"$expr": {"$eq": ["$type", "$$t"]}}}, + {"$match": {"active": True}}, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "wantType": "A", + "joined": [{"_id": 10, "type": "A", "active": True}], + } + ], + msg=( + "$lookup with $match $expr followed by plain $match should" + " apply both correlated and non-correlated filters" + ), + ), +] + + +_DATE_CUTOFF = datetime(2024, 6, 1, tzinfo=timezone.utc) +_DATE_BEFORE = datetime(2024, 5, 15, tzinfo=timezone.utc) +_DATE_AFTER = datetime(2024, 7, 10, tzinfo=timezone.utc) + +LOOKUP_MATCH_DATE_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "match_expr_date_gt_let_var", + docs=[{"_id": 1, "cutoff": _DATE_CUTOFF}], + foreign_docs=[ + {"_id": 10, "eventDate": _DATE_AFTER}, + {"_id": 11, "eventDate": _DATE_BEFORE}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"cutoffDate": "$cutoff"}, + "pipeline": [{"$match": {"$expr": {"$gt": ["$eventDate", "$$cutoffDate"]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "cutoff": _DATE_CUTOFF, + "joined": [{"_id": 10, "eventDate": _DATE_AFTER}], + } + ], + msg="$lookup $match $expr $gt with date let var should find events after cutoff", + ), + LookupTestCase( + "match_expr_date_lt_let_var", + docs=[{"_id": 1, "deadline": _DATE_CUTOFF}], + foreign_docs=[ + {"_id": 10, "createdAt": _DATE_BEFORE}, + {"_id": 11, "createdAt": _DATE_AFTER}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"dl": "$deadline"}, + "pipeline": [{"$match": {"$expr": {"$lt": ["$createdAt", "$$dl"]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "deadline": _DATE_CUTOFF, + "joined": [{"_id": 10, "createdAt": _DATE_BEFORE}], + } + ], + msg="$lookup $match $expr $lt with date let var should find docs before deadline", + ), +] + + +LOOKUP_MATCH_FIELD_ACCESS_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "match_plain_filter_resolves_against_foreign", + docs=[{"_id": 1, "status": "outer_active"}], + foreign_docs=[ + {"_id": 10, "status": "active"}, + {"_id": 11, "status": "inactive"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$status"}, + "pipeline": [{"$match": {"status": "active"}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "status": "outer_active", + "joined": [{"_id": 10, "status": "active"}], + } + ], + msg=( + "$lookup sub-pipeline $match without $expr resolves field" + " values against foreign collection, not outer document" + ), + ), +] + + +LOOKUP_MATCH_ADDITIONAL_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "expr_gt_against_let_var", + docs=[{"_id": 1, "val": 5}], + foreign_docs=[ + {"_id": 10, "ff": 4}, + {"_id": 11, "ff": 5}, + {"_id": 12, "ff": 6}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$val"}, + "pipeline": [{"$match": {"$expr": {"$gt": ["$ff", "$$x"]}}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "val": 5, "joined": [{"_id": 12, "ff": 6}]}], + msg=( + "$lookup sub-pipeline $match $expr $gt should return" + " foreign docs greater than the let var" + ), + ), + LookupTestCase( + "expr_range_with_two_let_vars_on_both_sides", + docs=[{"_id": 1, "lo": 2, "hi": 8}], + foreign_docs=[ + {"_id": 10, "ff": 1}, + {"_id": 11, "ff": 5}, + {"_id": 12, "ff": 9}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"lo": "$lo", "hi": "$hi"}, + "pipeline": [ + { + "$match": { + "$expr": { + "$and": [ + {"$lt": ["$$lo", "$ff"]}, + {"$lt": ["$ff", "$$hi"]}, + ] + } + } + } + ], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "lo": 2, "hi": 8, "joined": [{"_id": 11, "ff": 5}]}], + msg="$lookup sub-pipeline $expr with let vars on both sides should filter a strict range", + ), + LookupTestCase( + "expr_not_in_membership_with_let_array", + docs=[{"_id": 1, "la": [1, 2, 3]}], + foreign_docs=[ + {"_id": 10, "ff": 2}, + {"_id": 11, "ff": 5}, + {"_id": 12, "ff": 1}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"arr": "$la"}, + "pipeline": [{"$match": {"$expr": {"$not": [{"$in": ["$ff", "$$arr"]}]}}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "la": [1, 2, 3], "joined": [{"_id": 11, "ff": 5}]}], + msg=( + "$lookup sub-pipeline $expr $not $in should return" + " the complement of the let array membership" + ), + ), + LookupTestCase( + "expr_size_gate_all_or_nothing_per_input_doc", + docs=[ + {"_id": 1, "la": [1, 2, 3]}, + {"_id": 2, "la": [1, 2]}, + ], + foreign_docs=[ + {"_id": 10}, + {"_id": 11}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"arr": "$la"}, + "pipeline": [{"$match": {"$expr": {"$eq": [{"$size": "$$arr"}, 3]}}}], + "as": "joined", + } + } + ], + expected=[ + {"_id": 1, "la": [1, 2, 3], "joined": [{"_id": 10}, {"_id": 11}]}, + {"_id": 2, "la": [1, 2], "joined": []}, + ], + msg=( + "$lookup sub-pipeline $expr $size gate should include" + " all or no foreign docs per input document" + ), + ), + LookupTestCase( + "expr_set_is_subset_with_let_array", + docs=[{"_id": 1, "la": [1, 2]}], + foreign_docs=[ + {"_id": 10, "farr": [1, 2, 3]}, + {"_id": 11, "farr": [1, 4]}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"arr": "$la"}, + "pipeline": [{"$match": {"$expr": {"$setIsSubset": ["$$arr", "$farr"]}}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "la": [1, 2], "joined": [{"_id": 10, "farr": [1, 2, 3]}]}], + msg=( + "$lookup sub-pipeline $expr $setIsSubset should return foreign docs" + " whose array is a superset of the let array" + ), + ), + LookupTestCase( + "expr_cond_selects_field_by_let_flag", + docs=[ + {"_id": 1, "flag": True, "target": "m"}, + {"_id": 2, "flag": False, "target": "m"}, + ], + foreign_docs=[ + {"_id": 10, "f1": "m", "f2": "n"}, + {"_id": 11, "f1": "x", "f2": "m"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"flag": "$flag", "target": "$target"}, + "pipeline": [ + { + "$match": { + "$expr": { + "$eq": [ + { + "$cond": [ + "$$flag", + "$f1", + "$f2", + ] + }, + "$$target", + ] + } + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "flag": True, + "target": "m", + "joined": [{"_id": 10, "f1": "m", "f2": "n"}], + }, + { + "_id": 2, + "flag": False, + "target": "m", + "joined": [{"_id": 11, "f1": "x", "f2": "m"}], + }, + ], + msg=( + "$lookup sub-pipeline $expr $cond should select the" + " compared field per input-doc let flag" + ), + ), + LookupTestCase( + "expr_switch_branches_keyed_on_let_var", + docs=[ + {"_id": 1, "sel": "hi"}, + {"_id": 2, "sel": "lo"}, + {"_id": 3, "sel": "other"}, + ], + foreign_docs=[ + {"_id": 10, "fnum": 15}, + {"_id": 11, "fnum": 5}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"sel": "$sel"}, + "pipeline": [ + { + "$match": { + "$expr": { + "$switch": { + "branches": [ + { + "case": {"$eq": ["$$sel", "hi"]}, + "then": {"$gt": ["$fnum", 10]}, + }, + { + "case": {"$eq": ["$$sel", "lo"]}, + "then": {"$lt": ["$fnum", 10]}, + }, + ], + "default": False, + } + } + } + } + ], + "as": "joined", + } + } + ], + expected=[ + {"_id": 1, "sel": "hi", "joined": [{"_id": 10, "fnum": 15}]}, + {"_id": 2, "sel": "lo", "joined": [{"_id": 11, "fnum": 5}]}, + {"_id": 3, "sel": "other", "joined": []}, + ], + msg=( + "$lookup sub-pipeline $expr $switch should pick the branch matching" + " the let var and fall through to default" + ), + ), + LookupTestCase( + "expr_array_elem_at_on_let_array", + docs=[{"_id": 1, "la": [7, 8]}], + foreign_docs=[ + {"_id": 10, "ff": 7}, + {"_id": 11, "ff": 8}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"arr": "$la"}, + "pipeline": [ + { + "$match": { + "$expr": { + "$eq": [ + {"$arrayElemAt": ["$$arr", 0]}, + "$ff", + ] + } + } + } + ], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "la": [7, 8], "joined": [{"_id": 10, "ff": 7}]}], + msg="$lookup sub-pipeline $expr $arrayElemAt should access an element of a let-bound array", + ), +] + + +# --- Combine all tests --- +LOOKUP_CORRELATED_MATCH_ALL: list[LookupTestCase] = ( + LOOKUP_MATCH_COMPARISON_TESTS + + LOOKUP_MATCH_LOGICAL_TESTS + + LOOKUP_MATCH_ARITHMETIC_TESTS + + LOOKUP_MATCH_ARRAY_TESTS + + LOOKUP_MATCH_NULL_MISSING_TESTS + + LOOKUP_MATCH_WITHOUT_EXPR_TESTS + + LOOKUP_MATCH_MULTIPLE_STAGES_TESTS + + LOOKUP_MATCH_DATE_TESTS + + LOOKUP_MATCH_FIELD_ACCESS_TESTS + + LOOKUP_MATCH_ADDITIONAL_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(LOOKUP_CORRELATED_MATCH_ALL)) +def test_lookup_correlated_match(collection, test_case: LookupTestCase): + """Test $lookup correlated subquery $match with $expr using 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/test_lookup_correlated_nested.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_nested.py new file mode 100644 index 000000000..3ae6cb800 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_nested.py @@ -0,0 +1,803 @@ +"""Tests for $lookup correlated subquery — nested $lookup scoping and propagation. + +Covers let variable behavior across nested $lookup stages: multi-level +propagation, shadowing semantics, correlated $match in inner lookups, +concise syntax interaction, and structure verification. +""" + +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 [Correlated Subquery — Nested Scoping]: let variables propagate into +# nested $lookup sub-pipelines and are correctly shadowed when an inner let +# declares the same name; shadows do not leak to the outer scope. + + +LOOKUP_NESTED_PROPAGATION_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "outer_let_accessible_in_doubly_nested", + docs=[{"_id": 1, "val": "from_outer"}], + foreign_docs=[{"_id": 10}], + 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 (L3)", + ), + LookupTestCase( + "outer_and_inner_let_both_accessible", + docs=[{"_id": 1, "a": "outer_a"}], + foreign_docs=[{"_id": 10, "b": "inner_b"}], + 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 allow both outer $$x and inner" + " $$y to be accessible in the innermost pipeline" + ), + ), + LookupTestCase( + "outer_let_in_inner_match_expr", + docs=[{"_id": 1, "ref": "target"}], + foreign_docs=[ + {"_id": 10, "tag": "target"}, + {"_id": 11, "tag": "other"}, + ], + 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 $match $expr" + " inside a nested $lookup sub-pipeline" + ), + ), + LookupTestCase( + "three_levels_each_with_own_let", + docs=[{"_id": 1, "a": "L1"}], + foreign_docs=[{"_id": 10, "b": "L2", "c": "L3"}], + 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 make all three variables accessible at the deepest level" + ), + ), +] + +# New: partial shadow, shadow doesn't leak up, progressive shadow, type change in shadow + +LOOKUP_NESTED_SHADOW_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "partial_shadow_inner_redefines_one_of_two", + docs=[{"_id": 1, "a": "outer_a", "b": "outer_b"}], + foreign_docs=[{"_id": 10, "c": "inner_c"}], + 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( + "shadow_does_not_leak_up", + docs=[{"_id": 1, "val": "outer_val"}], + foreign_docs=[{"_id": 10, "inner_val": "shadowed"}], + 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 — after inner" + " lookup exits, outer scope sees original $$x value" + ), + ), + LookupTestCase( + "progressive_shadow_three_levels", + docs=[{"_id": 1, "v": "L1"}], + foreign_docs=[{"_id": 10, "v": "L2"}], + 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: each level redefines $$x," + " deepest sees its own value" + ), + ), + LookupTestCase( + "shadow_with_different_type", + docs=[{"_id": 1, "x": 42}], + foreign_docs=[{"_id": 10, "x": "hello"}], + 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 the type of a variable" + " — outer int shadowed by inner string" + ), + ), +] + + +LOOKUP_NESTED_STRUCTURE_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "nested_result_is_array_of_docs_with_arrays", + docs=[{"_id": 1, "ref": "a"}], + foreign_docs=[ + {"_id": 10, "tag": "a", "sub_ref": "x"}, + {"_id": 11, "tag": "a", "sub_ref": "y"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"r": "$ref"}, + "pipeline": [ + {"$match": {"$expr": {"$eq": ["$tag", "$$r"]}}}, + { + "$lookup": { + "from": FOREIGN, + "let": {"sr": "$sub_ref"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$tag", "$$sr"]}}}], + "as": "deep", + } + }, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "ref": "a", + "joined": [ + {"_id": 10, "tag": "a", "sub_ref": "x", "deep": []}, + {"_id": 11, "tag": "a", "sub_ref": "y", "deep": []}, + ], + } + ], + msg=( + "$lookup nested result should be array of docs each containing" + " their own inner 'deep' array field" + ), + ), + LookupTestCase( + "inner_as_same_name_as_outer_no_conflict", + docs=[{"_id": 1, "v": "outer"}], + foreign_docs=[{"_id": 10, "v": "mid"}], + 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 same name as outer 'as' should" + " not conflict — they exist in different document contexts" + ), + ), + LookupTestCase( + "inner_let_references_field_from_addFields", + docs=[{"_id": 1, "base": 10}], + foreign_docs=[{"_id": 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 in the same sub-pipeline" + ), + ), + LookupTestCase( + "self_join_at_depth", + docs=[ + {"_id": 1, "name": "root", "parent": None}, + {"_id": 2, "name": "child", "parent": 1}, + ], + foreign_docs=[ + {"_id": 1, "name": "root", "parent": None}, + {"_id": 2, "name": "child", "parent": 1}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"pid": "$_id"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$parent", "$$pid"]}}}], + "as": "children", + } + } + ], + expected=[ + { + "_id": 1, + "name": "root", + "parent": None, + "children": [{"_id": 2, "name": "child", "parent": 1}], + }, + {"_id": 2, "name": "child", "parent": 1, "children": []}, + ], + msg=( + "$lookup correlated self-join should find children for each" + " document where foreign parent == outer _id" + ), + ), +] + + +LOOKUP_NESTED_ADDITIONAL_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "sibling_nested_lookups_isolate_same_named_var", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10, "tag": "p"}, {"_id": 11, "tag": "q"}], + 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( + "outer_correlated_inner_concise_references_outer_var", + docs=[{"_id": 1, "oval": "a"}], + foreign_docs=[ + {"_id": 10, "lf": "a", "fval": "a"}, + {"_id": 11, "lf": "a", "fval": "b"}, + {"_id": 12, "lf": "c", "fval": "a"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"a": "$oval"}, + "pipeline": [ + {"$match": {"_id": 10}}, + { + "$lookup": { + "from": FOREIGN, + "localField": "lf", + "foreignField": "lf", + "let": {"outer_a": "$$a"}, + "pipeline": [ + {"$match": {"$expr": {"$eq": ["$fval", "$$outer_a"]}}} + ], + "as": "inner_joined", + } + }, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "oval": "a", + "joined": [ + { + "_id": 10, + "lf": "a", + "fval": "a", + "inner_joined": [{"_id": 10, "lf": "a", "fval": "a"}], + } + ], + } + ], + msg=( + "$lookup outer correlated form should compose with an inner concise" + " form referencing the outer let variable" + ), + ), + LookupTestCase( + "missing_var_at_one_level_errors", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + 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 (undefined variable)" + ), + ), +] + + +# --- Combine all tests --- +LOOKUP_CORRELATED_NESTED_ALL: list[LookupTestCase] = ( + LOOKUP_NESTED_PROPAGATION_TESTS + + LOOKUP_NESTED_SHADOW_TESTS + + LOOKUP_NESTED_STRUCTURE_TESTS + + LOOKUP_NESTED_ADDITIONAL_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(LOOKUP_CORRELATED_NESTED_ALL)) +def test_lookup_correlated_nested(collection, test_case: LookupTestCase): + """Test $lookup correlated subquery nested lookup scoping and propagation.""" + 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/test_lookup_correlated_project.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_project.py new file mode 100644 index 000000000..63c12b3ab --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_project.py @@ -0,0 +1,694 @@ +"""Tests for $lookup correlated subquery — let variables in $project/$addFields and other stages. + +Covers let variable direct access in non-$match stages: $addFields, $project, +$group, $redact, $replaceRoot. Includes conditional logic, array/string +manipulation, and type preservation through sub-pipeline stages. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Decimal128, Int64, ObjectId + +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 [Correlated Subquery — Non-Match Stages]: let variables are directly +# accessible in $addFields, $project, $group, $redact, and $replaceRoot without +# requiring $expr wrapping. + + +LOOKUP_PROJECT_BASIC_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "project_let_var_in_computed_field", + docs=[{"_id": 1, "label": "outer_label"}], + foreign_docs=[{"_id": 10, "name": "item_a"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"lbl": "$label"}, + "pipeline": [{"$project": {"_id": 1, "name": 1, "outerLabel": "$$lbl"}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "label": "outer_label", + "joined": [{"_id": 10, "name": "item_a", "outerLabel": "outer_label"}], + } + ], + msg="$lookup $project should access let variable directly as computed field value", + ), + LookupTestCase( + "project_concat_with_let_var", + docs=[{"_id": 1, "label": "PREFIX"}], + foreign_docs=[{"_id": 10, "name": "item"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"lbl": "$label"}, + "pipeline": [ + { + "$project": { + "_id": 1, + "combined": {"$concat": ["$$lbl", " - ", "$name"]}, + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "label": "PREFIX", + "joined": [{"_id": 10, "combined": "PREFIX - item"}], + } + ], + msg="$lookup $project with $concat should combine let var with foreign field", + ), + LookupTestCase( + "project_arithmetic_with_let_var", + docs=[{"_id": 1, "unitPrice": 5}], + foreign_docs=[{"_id": 10, "qty": 3}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"price": "$unitPrice"}, + "pipeline": [ + { + "$project": { + "_id": 0, + "total": {"$multiply": ["$qty", "$$price"]}, + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "unitPrice": 5, + "joined": [{"total": 15}], + } + ], + msg="$lookup $project with $multiply should compute arithmetic using let var", + ), +] + + +LOOKUP_PROJECT_CONDITIONAL_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "addFields_cond_with_let_var", + docs=[{"_id": 1, "threshold": 60}], + foreign_docs=[ + {"_id": 10, "score": 80}, + {"_id": 11, "score": 40}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"thr": "$threshold"}, + "pipeline": [ + { + "$addFields": { + "label": { + "$cond": [ + {"$gte": ["$score", "$$thr"]}, + "pass", + "fail", + ] + } + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "threshold": 60, + "joined": [ + {"_id": 10, "score": 80, "label": "pass"}, + {"_id": 11, "score": 40, "label": "fail"}, + ], + } + ], + msg="$lookup $addFields with $cond should classify based on let var threshold", + ), + LookupTestCase( + "addFields_switch_with_let_var", + docs=[{"_id": 1, "highMark": 80}], + foreign_docs=[ + {"_id": 10, "amount": 90}, + {"_id": 11, "amount": 50}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"high": "$highMark"}, + "pipeline": [ + { + "$addFields": { + "tier": { + "$switch": { + "branches": [ + { + "case": {"$gte": ["$amount", "$$high"]}, + "then": "premium", + } + ], + "default": "standard", + } + } + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "highMark": 80, + "joined": [ + {"_id": 10, "amount": 90, "tier": "premium"}, + {"_id": 11, "amount": 50, "tier": "standard"}, + ], + } + ], + msg="$lookup $addFields with $switch should branch using let var threshold", + ), + LookupTestCase( + "addFields_ifNull_let_var_as_default", + docs=[{"_id": 1, "defaultName": "Anonymous"}], + foreign_docs=[ + {"_id": 10, "name": "Alice"}, + {"_id": 11}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"dflt": "$defaultName"}, + "pipeline": [{"$addFields": {"displayName": {"$ifNull": ["$name", "$$dflt"]}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "defaultName": "Anonymous", + "joined": [ + {"_id": 10, "name": "Alice", "displayName": "Alice"}, + {"_id": 11, "displayName": "Anonymous"}, + ], + } + ], + msg="$lookup $addFields with $ifNull should use let var as fallback when field missing", + ), +] + + +LOOKUP_PROJECT_ARRAY_STRING_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "addFields_concatArrays_with_let_var", + docs=[{"_id": 1, "extra": ["x", "y"]}], + foreign_docs=[{"_id": 10, "tags": ["a", "b"]}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"extras": "$extra"}, + "pipeline": [ + {"$addFields": {"allTags": {"$concatArrays": ["$tags", "$$extras"]}}} + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "extra": ["x", "y"], + "joined": [{"_id": 10, "tags": ["a", "b"], "allTags": ["a", "b", "x", "y"]}], + } + ], + msg="$lookup $addFields with $concatArrays should append let var array to foreign array", + ), + LookupTestCase( + "addFields_string_concat_path_with_let_var", + docs=[{"_id": 1, "basePath": "/products"}], + foreign_docs=[{"_id": 10, "slug": "widget"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"base": "$basePath"}, + "pipeline": [ + {"$addFields": {"fullPath": {"$concat": ["$$base", "/", "$slug"]}}} + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "basePath": "/products", + "joined": [{"_id": 10, "slug": "widget", "fullPath": "/products/widget"}], + } + ], + msg="$lookup $addFields with $concat should build path from let var and foreign field", + ), + LookupTestCase( + "addFields_filter_threshold_from_let_var", + docs=[{"_id": 1, "minScore": 70}], + foreign_docs=[{"_id": 10, "scores": [50, 75, 90, 60]}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"min": "$minScore"}, + "pipeline": [ + { + "$addFields": { + "highScores": { + "$filter": { + "input": "$scores", + "cond": {"$gte": ["$$this", "$$min"]}, + } + } + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "minScore": 70, + "joined": [{"_id": 10, "scores": [50, 75, 90, 60], "highScores": [75, 90]}], + } + ], + msg="$lookup $addFields with $filter should use let var as threshold for array filtering", + ), + LookupTestCase( + "addFields_map_with_let_var_multiplier", + docs=[{"_id": 1, "rate": 1.1}], + foreign_docs=[{"_id": 10, "prices": [10, 20, 30]}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"r": "$rate"}, + "pipeline": [ + { + "$addFields": { + "adjusted": { + "$map": { + "input": "$prices", + "in": {"$multiply": ["$$this", "$$r"]}, + } + } + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "rate": 1.1, + "joined": [ + { + "_id": 10, + "prices": [10, 20, 30], + "adjusted": [11.0, 22.0, 33.0], + } + ], + } + ], + msg="$lookup $addFields with $map should scale array values using let var multiplier", + ), +] + + +LOOKUP_PROJECT_GROUP_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "group_let_var_as_id", + docs=[{"_id": 1, "cat": "electronics"}], + foreign_docs=[ + {"_id": 10, "item": "phone"}, + {"_id": 11, "item": "laptop"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"category": "$cat"}, + "pipeline": [{"$group": {"_id": "$$category", "count": {"$sum": 1}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "cat": "electronics", + "joined": [{"_id": "electronics", "count": 2}], + } + ], + msg="$lookup $group with let var as _id should group all foreign docs under that value", + ), + LookupTestCase( + "group_conditional_count_with_let_var", + docs=[{"_id": 1, "minVal": 50}], + foreign_docs=[ + {"_id": 10, "val": 80}, + {"_id": 11, "val": 30}, + {"_id": 12, "val": 60}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"min": "$minVal"}, + "pipeline": [ + { + "$group": { + "_id": None, + "above": {"$sum": {"$cond": [{"$gte": ["$val", "$$min"]}, 1, 0]}}, + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "minVal": 50, + "joined": [{"_id": None, "above": 2}], + } + ], + msg="$lookup $group with $cond using let var should count docs meeting threshold", + ), +] + + +LOOKUP_PROJECT_REDACT_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "redact_with_let_var", + docs=[{"_id": 1, "accessLevel": 2}], + foreign_docs=[ + {"_id": 10, "level": 1, "data": "public"}, + {"_id": 11, "level": 2, "data": "restricted"}, + {"_id": 12, "level": 3, "data": "secret"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"access": "$accessLevel"}, + "pipeline": [ + { + "$redact": { + "$cond": [ + {"$lte": ["$level", "$$access"]}, + "$$KEEP", + "$$PRUNE", + ] + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "accessLevel": 2, + "joined": [ + {"_id": 10, "level": 1, "data": "public"}, + {"_id": 11, "level": 2, "data": "restricted"}, + ], + } + ], + msg="$lookup $redact with let var should keep/prune docs based on access level", + ), +] + + +LOOKUP_PROJECT_REPLACEROOT_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "replaceRoot_with_let_var", + docs=[{"_id": 1, "context": "outer_ctx"}], + foreign_docs=[{"_id": 10, "data": "foreign_data"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"ctx": "$context"}, + "pipeline": [ + { + "$replaceRoot": { + "newRoot": { + "originalData": "$data", + "fromOuter": "$$ctx", + } + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "context": "outer_ctx", + "joined": [{"originalData": "foreign_data", "fromOuter": "outer_ctx"}], + } + ], + msg="$lookup $replaceRoot with let var should include it in the new root document", + ), +] + + +_OID = ObjectId("bbbbbbbbbbbbbbbbbbbbbbbb") +_DATE = datetime(2024, 3, 15, 12, 0, 0, tzinfo=timezone.utc) + +LOOKUP_PROJECT_TYPE_PRESERVATION_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "type_preservation_int_through_addFields", + docs=[{"_id": 1, "v": 42}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$v"}, + "pipeline": [{"$addFields": {"val": "$$x", "t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "v": 42, "joined": [{"_id": 10, "val": 42, "t": "int"}]}], + msg="$lookup let var int type should be preserved through $addFields in sub-pipeline", + ), + LookupTestCase( + "type_preservation_decimal128_through_addFields", + docs=[{"_id": 1, "v": Decimal128("99.99")}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$v"}, + "pipeline": [{"$addFields": {"val": "$$x", "t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "v": Decimal128("99.99"), + "joined": [{"_id": 10, "val": Decimal128("99.99"), "t": "decimal"}], + } + ], + msg="$lookup let var Decimal128 type should be preserved through $addFields", + ), + LookupTestCase( + "type_preservation_date_through_addFields", + docs=[{"_id": 1, "v": _DATE}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$v"}, + "pipeline": [{"$addFields": {"val": "$$x", "t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "v": _DATE, "joined": [{"_id": 10, "val": _DATE, "t": "date"}]}], + msg="$lookup let var date type should be preserved through $addFields", + ), + LookupTestCase( + "type_preservation_objectid_through_addFields", + docs=[{"_id": 1, "v": _OID}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$v"}, + "pipeline": [{"$addFields": {"val": "$$x", "t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "v": _OID, "joined": [{"_id": 10, "val": _OID, "t": "objectId"}]}], + msg="$lookup let var ObjectId type should be preserved through $addFields", + ), + LookupTestCase( + "type_preservation_long_through_addFields", + docs=[{"_id": 1, "v": Int64(123456789012)}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$v"}, + "pipeline": [{"$addFields": {"val": "$$x", "t": {"$type": "$$x"}}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "v": Int64(123456789012), + "joined": [{"_id": 10, "val": Int64(123456789012), "t": "long"}], + } + ], + msg="$lookup let var Int64 type should be preserved through $addFields", + ), +] + + +LOOKUP_PROJECT_ADDITIONAL_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "addfields_let_var_flows_through_project", + docs=[{"_id": 1, "x": "outer"}], + foreign_docs=[{"_id": 10, "ff": "keep"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"x": "$x"}, + "pipeline": [ + {"$addFields": {"fromOuter": "$$x"}}, + {"$project": {"_id": 1, "fromOuter": 1}}, + ], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "x": "outer", "joined": [{"_id": 10, "fromOuter": "outer"}]}], + msg=( + "$lookup sub-pipeline $addFields of a let variable" + " should flow through a following $project" + ), + ), + LookupTestCase( + "project_reduce_seeded_from_let_var", + docs=[{"_id": 1, "n": 100}], + foreign_docs=[{"_id": 10, "farr": [1, 2, 3]}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"n": "$n"}, + "pipeline": [ + { + "$project": { + "_id": 0, + "reduced": { + "$reduce": { + "input": "$farr", + "initialValue": "$$n", + "in": {"$add": ["$$value", "$$this"]}, + } + }, + } + } + ], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "n": 100, "joined": [{"reduced": 106}]}], + msg="$lookup sub-pipeline $reduce should seed its accumulator from a let variable", + ), +] + + +# --- Combine all tests --- +LOOKUP_CORRELATED_PROJECT_ALL: list[LookupTestCase] = ( + LOOKUP_PROJECT_BASIC_TESTS + + LOOKUP_PROJECT_CONDITIONAL_TESTS + + LOOKUP_PROJECT_ARRAY_STRING_TESTS + + LOOKUP_PROJECT_GROUP_TESTS + + LOOKUP_PROJECT_REDACT_TESTS + + LOOKUP_PROJECT_REPLACEROOT_TESTS + + LOOKUP_PROJECT_TYPE_PRESERVATION_TESTS + + LOOKUP_PROJECT_ADDITIONAL_TESTS +) + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(LOOKUP_CORRELATED_PROJECT_ALL)) +def test_lookup_correlated_project(collection, test_case: LookupTestCase): + """Test $lookup correlated subquery let variables in $project/$addFields and other stages.""" + 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/test_lookup_correlated_result_semantics.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_result_semantics.py new file mode 100644 index 000000000..716d2a196 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_result_semantics.py @@ -0,0 +1,279 @@ +"""Tests for $lookup correlated subquery — per-document variation and result cardinality. + +Covers the fundamental correlated behavior: let variable changes per outer +document, producing different join results. Also covers result array properties +(empty, single, many, ordering, $sort/$limit/$skip in sub-pipeline). +""" + +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 [Correlated Subquery — Result Semantics]: the sub-pipeline re-runs +# per outer document producing independent join results; result array cardinality +# and order are controlled by sub-pipeline stages ($sort, $limit, $skip, $project). +LOOKUP_RESULT_SEMANTICS_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "per_doc_variation_distinct_let_values", + docs=[ + {"_id": 1, "cat": "A"}, + {"_id": 2, "cat": "B"}, + {"_id": 3, "cat": "C"}, + ], + foreign_docs=[ + {"_id": 10, "type": "A", "val": 1}, + {"_id": 11, "type": "B", "val": 2}, + {"_id": 12, "type": "B", "val": 3}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"c": "$cat"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$type", "$$c"]}}}], + "as": "joined", + } + } + ], + expected=[ + {"_id": 1, "cat": "A", "joined": [{"_id": 10, "type": "A", "val": 1}]}, + { + "_id": 2, + "cat": "B", + "joined": [{"_id": 11, "type": "B", "val": 2}, {"_id": 12, "type": "B", "val": 3}], + }, + {"_id": 3, "cat": "C", "joined": []}, + ], + msg=( + "$lookup correlated join should produce different results" + " per outer document based on each doc's let var value" + ), + ), + LookupTestCase( + "per_doc_duplicate_let_values_same_result", + docs=[ + {"_id": 1, "cat": "A"}, + {"_id": 2, "cat": "A"}, + ], + foreign_docs=[ + {"_id": 10, "type": "A"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"c": "$cat"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$type", "$$c"]}}}], + "as": "joined", + } + } + ], + expected=[ + {"_id": 1, "cat": "A", "joined": [{"_id": 10, "type": "A"}]}, + {"_id": 2, "cat": "A", "joined": [{"_id": 10, "type": "A"}]}, + ], + msg=( + "$lookup correlated with duplicate let values should produce" + " identical joined results for both outer docs" + ), + ), + LookupTestCase( + "result_empty_array_no_matches", + docs=[{"_id": 1, "cat": "nonexistent"}], + foreign_docs=[{"_id": 10, "type": "A"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"c": "$cat"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$type", "$$c"]}}}], + "as": "joined", + } + } + ], + expected=[ + {"_id": 1, "cat": "nonexistent", "joined": []}, + ], + msg="$lookup correlated with no matching foreign docs should produce empty array", + ), + LookupTestCase( + "result_sort_in_sub_pipeline", + docs=[{"_id": 1, "cat": "A"}], + foreign_docs=[ + {"_id": 12, "type": "A", "score": 50}, + {"_id": 10, "type": "A", "score": 90}, + {"_id": 11, "type": "A", "score": 70}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"c": "$cat"}, + "pipeline": [ + {"$match": {"$expr": {"$eq": ["$type", "$$c"]}}}, + {"$sort": {"score": -1}}, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "cat": "A", + "joined": [ + {"_id": 10, "type": "A", "score": 90}, + {"_id": 11, "type": "A", "score": 70}, + {"_id": 12, "type": "A", "score": 50}, + ], + } + ], + msg="$lookup sub-pipeline $sort should determine order within joined array", + ), + LookupTestCase( + "result_limit_in_sub_pipeline", + docs=[{"_id": 1, "cat": "A"}], + foreign_docs=[ + {"_id": 10, "type": "A", "score": 90}, + {"_id": 11, "type": "A", "score": 70}, + {"_id": 12, "type": "A", "score": 50}, + ], + 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": 10, "type": "A", "score": 90}, + {"_id": 11, "type": "A", "score": 70}, + ], + } + ], + msg="$lookup sub-pipeline $limit should restrict joined array to N docs", + ), + LookupTestCase( + "result_skip_in_sub_pipeline", + docs=[{"_id": 1, "cat": "A"}], + foreign_docs=[ + {"_id": 10, "type": "A", "score": 90}, + {"_id": 11, "type": "A", "score": 70}, + {"_id": 12, "type": "A", "score": 50}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"c": "$cat"}, + "pipeline": [ + {"$match": {"$expr": {"$eq": ["$type", "$$c"]}}}, + {"$sort": {"score": -1}}, + {"$skip": 1}, + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "cat": "A", + "joined": [ + {"_id": 11, "type": "A", "score": 70}, + {"_id": 12, "type": "A", "score": 50}, + ], + } + ], + msg="$lookup sub-pipeline $skip should skip first N joined results", + ), + LookupTestCase( + "result_project_shapes_joined_docs", + docs=[{"_id": 1, "cat": "A"}], + foreign_docs=[ + {"_id": 10, "type": "A", "name": "Widget", "secret": "hidden"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"c": "$cat"}, + "pipeline": [ + {"$match": {"$expr": {"$eq": ["$type", "$$c"]}}}, + {"$project": {"name": 1, "_id": 0}}, + ], + "as": "joined", + } + } + ], + expected=[ + {"_id": 1, "cat": "A", "joined": [{"name": "Widget"}]}, + ], + msg="$lookup sub-pipeline $project should reshape docs in joined array", + ), + LookupTestCase( + "outer_document_order_preserved", + docs=[ + {"_id": 3, "cat": "C"}, + {"_id": 1, "cat": "A"}, + {"_id": 2, "cat": "B"}, + ], + foreign_docs=[ + {"_id": 10, "type": "A"}, + {"_id": 11, "type": "B"}, + {"_id": 12, "type": "C"}, + ], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"c": "$cat"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$type", "$$c"]}}}], + "as": "joined", + } + } + ], + expected=[ + {"_id": 3, "cat": "C", "joined": [{"_id": 12, "type": "C"}]}, + {"_id": 1, "cat": "A", "joined": [{"_id": 10, "type": "A"}]}, + {"_id": 2, "cat": "B", "joined": [{"_id": 11, "type": "B"}]}, + ], + msg="$lookup should preserve outer document order regardless of join results", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(LOOKUP_RESULT_SEMANTICS_TESTS)) +def test_lookup_correlated_result_semantics(collection, test_case: LookupTestCase): + """Test $lookup correlated subquery per-document variation and result cardinality.""" + 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/test_lookup_correlated_subquery.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_subquery.py index fcb2d3d5b..c6cd57ffe 100644 --- a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_subquery.py +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_subquery.py @@ -11,7 +11,6 @@ setup_lookup, ) from documentdb_tests.framework.assertions import assertResult -from documentdb_tests.framework.error_codes import BAD_VALUE_ERROR from documentdb_tests.framework.executor import execute_command from documentdb_tests.framework.parametrize import pytest_params @@ -542,65 +541,9 @@ expected=[{"_id": 1, "joined": [{"_id": 10, "now_type": "date"}]}], msg="$lookup should accept system variable $$NOW as a let value producing a date type", ), - LookupTestCase( - "remove_as_let_value_treats_variable_as_missing", - docs=[{"_id": 1}], - foreign_docs=[{"_id": 10}], - pipeline=[ - { - "$lookup": { - "from": FOREIGN, - "let": {"removed": "$$REMOVE"}, - "pipeline": [ - { - "$addFields": { - "removed_val": "$$removed", - "type_result": {"$type": "$$removed"}, - } - } - ], - "as": "joined", - } - } - ], - expected=[ - { - "_id": 1, - "joined": [{"_id": 10, "type_result": "missing"}], - }, - ], - msg=( - "$lookup with $$REMOVE as a let value should cause the" - " variable to be treated as a removed/missing field" - ), - ), -] - -# Property [Correlated Subquery Expression Error]: expression evaluation -# errors in let values propagate as errors. -LOOKUP_CORRELATED_SUBQUERY_ERROR_TESTS: list[LookupTestCase] = [ - LookupTestCase( - "let_expression_error_propagates", - docs=[{"_id": 1}], - foreign_docs=[{"_id": 10}], - pipeline=[ - { - "$lookup": { - "from": FOREIGN, - "let": {"bad": {"$divide": [1, 0]}}, - "pipeline": [], - "as": "joined", - } - } - ], - error_code=BAD_VALUE_ERROR, - msg="$lookup should propagate expression evaluation errors in let values", - ), ] -LOOKUP_CORRELATED_SUBQUERY_ALL_TESTS: list[LookupTestCase] = ( - LOOKUP_CORRELATED_SUBQUERY_TESTS + LOOKUP_CORRELATED_SUBQUERY_ERROR_TESTS -) +LOOKUP_CORRELATED_SUBQUERY_ALL_TESTS: list[LookupTestCase] = LOOKUP_CORRELATED_SUBQUERY_TESTS @pytest.mark.aggregate diff --git a/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_system_vars.py b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_system_vars.py new file mode 100644 index 000000000..48da144cf --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/stages/lookup/test_lookup_correlated_system_vars.py @@ -0,0 +1,194 @@ +"""Tests for $lookup correlated subquery — $$ROOT/$$CURRENT context inside sub-pipeline. + +Inside the sub-pipeline, $$ROOT and $$CURRENT refer to the FOREIGN document +context, not the outer document. Outer context must be captured via let. +""" + +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 [Correlated Subquery — System Variables]: inside the sub-pipeline +# $$ROOT and $$CURRENT refer to the foreign document context; outer context must +# be captured via let before the sub-pipeline executes. +LOOKUP_SYSTEM_VARS_TESTS: list[LookupTestCase] = [ + LookupTestCase( + "ROOT_inside_sub_pipeline_refers_to_foreign", + docs=[{"_id": 1, "name": "outer_doc"}], + foreign_docs=[{"_id": 10, "name": "foreign_doc"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "pipeline": [{"$addFields": {"rootName": "$$ROOT.name"}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "name": "outer_doc", + "joined": [{"_id": 10, "name": "foreign_doc", "rootName": "foreign_doc"}], + } + ], + msg="$$ROOT inside sub-pipeline should refer to the foreign document, not outer", + ), + LookupTestCase( + "CURRENT_inside_sub_pipeline_refers_to_foreign", + docs=[{"_id": 1, "name": "outer_doc"}], + foreign_docs=[{"_id": 10, "name": "foreign_doc"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "pipeline": [{"$addFields": {"currentName": "$$CURRENT.name"}}], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "name": "outer_doc", + "joined": [{"_id": 10, "name": "foreign_doc", "currentName": "foreign_doc"}], + } + ], + msg="$$CURRENT inside sub-pipeline should refer to the foreign document, not outer", + ), + LookupTestCase( + "let_captures_outer_ROOT_vs_inner_ROOT", + docs=[{"_id": 1, "name": "outer"}], + foreign_docs=[{"_id": 10, "name": "foreign"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"outerRoot": "$$ROOT"}, + "pipeline": [ + { + "$addFields": { + "outerName": "$$outerRoot.name", + "innerName": "$$ROOT.name", + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "name": "outer", + "joined": [ + { + "_id": 10, + "name": "foreign", + "outerName": "outer", + "innerName": "foreign", + } + ], + } + ], + msg=( + "let capturing $$ROOT should preserve outer doc context while" + " $$ROOT inside pipeline refers to foreign doc" + ), + ), + LookupTestCase( + "nested_ROOT_refers_to_innermost_foreign", + docs=[{"_id": 1, "name": "L0"}], + foreign_docs=[{"_id": 10, "name": "L1"}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "let": {"outerRoot": "$$ROOT"}, + "pipeline": [ + { + "$lookup": { + "from": FOREIGN, + "pipeline": [ + { + "$addFields": { + "deepRoot": "$$ROOT.name", + "fromOuter": "$$outerRoot.name", + } + } + ], + "as": "inner", + } + } + ], + "as": "joined", + } + } + ], + expected=[ + { + "_id": 1, + "name": "L0", + "joined": [ + { + "_id": 10, + "name": "L1", + "inner": [ + { + "_id": 10, + "name": "L1", + "deepRoot": "L1", + "fromOuter": "L0", + } + ], + } + ], + } + ], + msg=( + "In nested $lookup, $$ROOT in innermost pipeline refers to" + " that level's foreign doc while outer let preserves L0 context" + ), + ), + LookupTestCase( + "NOW_accessible_inside_sub_pipeline", + docs=[{"_id": 1}], + foreign_docs=[{"_id": 10}], + pipeline=[ + { + "$lookup": { + "from": FOREIGN, + "pipeline": [{"$addFields": {"nowType": {"$type": "$$NOW"}}}], + "as": "joined", + } + } + ], + expected=[{"_id": 1, "joined": [{"_id": 10, "nowType": "date"}]}], + msg="$$NOW should be accessible inside $lookup sub-pipeline as date type", + ), +] + + +@pytest.mark.aggregate +@pytest.mark.parametrize("test_case", pytest_params(LOOKUP_SYSTEM_VARS_TESTS)) +def test_lookup_correlated_system_vars(collection, test_case: LookupTestCase): + """Test $lookup correlated subquery $$ROOT/$$CURRENT/$$NOW context semantics.""" + 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, + )