diff --git a/documentdb_tests/compatibility/tests/core/operator/query/misc/expr/__init__.py b/documentdb_tests/compatibility/tests/core/operator/query/misc/expr/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/documentdb_tests/compatibility/tests/core/operator/query/misc/expr/test_expr_argument_handling.py b/documentdb_tests/compatibility/tests/core/operator/query/misc/expr/test_expr_argument_handling.py new file mode 100644 index 00000000..f9603bd9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/misc/expr/test_expr_argument_handling.py @@ -0,0 +1,111 @@ +""" +Tests for $expr argument shapes and expression evaluation. + +Covers valid argument forms (field references, comparison expressions, +deeply nested operators), system variables ($$ROOT, $$CURRENT, $literal), +computed result truthiness, and constant expression evaluation. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.query.utils.query_test_case import ( + QueryTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +ALL_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="field_ref_truthy", + doc=[{"_id": 1, "a": 5}], + filter={"$expr": "$a"}, + expected=[{"_id": 1, "a": 5}], + msg="non-zero field value is truthy", + ), + QueryTestCase( + id="field_ref_falsy", + doc=[{"_id": 1, "a": 0}], + filter={"$expr": "$a"}, + expected=[], + msg="zero field value is falsy", + ), + QueryTestCase( + id="field_ref_missing", + doc=[{"_id": 1, "b": 1}], + filter={"$expr": "$a"}, + expected=[], + msg="missing field is falsy", + ), + QueryTestCase( + id="comparison_two_fields", + doc=[{"_id": 1, "a": 5, "b": 3}, {"_id": 2, "a": 1, "b": 3}], + filter={"$expr": {"$gt": ["$a", "$b"]}}, + expected=[{"_id": 1, "a": 5, "b": 3}], + msg="$expr with comparison expression", + ), + QueryTestCase( + id="deeply_nested_operators", + doc=[{"_id": 1, "a": 4.5}, {"_id": 2, "a": 1.2}], + filter={"$expr": {"$gt": [{"$add": [1, {"$abs": {"$ceil": "$a"}}]}, 5]}}, + expected=[{"_id": 1, "a": 4.5}], + msg="$expr with deeply nested expression operators", + ), + QueryTestCase( + id="system_var_root", + doc=[{"_id": 1, "a": 1}], + filter={"$expr": {"$eq": [{"$type": "$$ROOT"}, "object"]}}, + expected=[{"_id": 1, "a": 1}], + msg="$expr with $$ROOT system variable", + ), + QueryTestCase( + id="system_var_current", + doc=[{"_id": 1, "a": 5}, {"_id": 2, "a": -1}], + filter={"$expr": {"$gt": ["$$CURRENT.a", 0]}}, + expected=[{"_id": 1, "a": 5}], + msg="$expr with $$CURRENT system variable", + ), + QueryTestCase( + id="literal_dollar_string", + doc=[{"_id": 1, "a": "$hello"}, {"_id": 2, "a": "world"}], + filter={"$expr": {"$eq": [{"$literal": "$hello"}, "$a"]}}, + expected=[{"_id": 1, "a": "$hello"}], + msg="$expr with $literal preserving dollar-prefixed string", + ), + QueryTestCase( + id="computed_zero_falsy", + doc=[{"_id": 1, "arr": [0]}], + filter={"$expr": {"$arrayElemAt": ["$arr", 0]}}, + expected=[], + msg="computed zero from $arrayElemAt is falsy", + ), + QueryTestCase( + id="computed_missing_falsy", + doc=[{"_id": 1, "arr": []}], + filter={"$expr": {"$arrayElemAt": ["$arr", 0]}}, + expected=[], + msg="$arrayElemAt on empty array returns missing (falsy)", + ), + QueryTestCase( + id="constant_eq_true", + doc=[{"_id": 1, "a": 1}], + filter={"$expr": {"$eq": [1, 1]}}, + expected=[{"_id": 1, "a": 1}], + msg="$expr evaluates constant expression — always true", + ), + QueryTestCase( + id="constant_add_truthy", + doc=[{"_id": 1, "a": 1}], + filter={"$expr": {"$add": [1, 2]}}, + expected=[{"_id": 1, "a": 1}], + msg="$expr truthiness on computed non-zero result", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_expr_argument_handling(collection, test): + """Test $expr argument shapes and expression evaluation.""" + collection.insert_many(test.doc) + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertResult(result, expected=test.expected, error_code=test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/misc/expr/test_expr_complex_expressions.py b/documentdb_tests/compatibility/tests/core/operator/query/misc/expr/test_expr_complex_expressions.py new file mode 100644 index 00000000..9defae4f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/misc/expr/test_expr_complex_expressions.py @@ -0,0 +1,72 @@ +""" +Tests for $expr edge cases and unusual expression forms. + +Covers empty object/array as $expr argument, $or mixing regular query +and $expr, field path through array of objects, and $$REMOVE behavior. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.query.utils.query_test_case import ( + QueryTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +ALL_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="expr_empty_object", + doc=[{"_id": 1}], + filter={"$expr": {}}, + expected=[{"_id": 1}], + msg="$expr with empty object {} — truthy, matches all", + ), + QueryTestCase( + id="expr_empty_array", + doc=[{"_id": 1}], + filter={"$expr": []}, + expected=[{"_id": 1}], + msg="$expr with empty array [] — truthy, matches all", + ), + QueryTestCase( + id="or_regular_and_expr", + doc=[ + {"_id": 1, "a": 1, "b": 5, "c": 3}, + {"_id": 2, "a": 2, "b": 1, "c": 5}, + {"_id": 3, "a": 3, "b": 1, "c": 1}, + ], + filter={"$or": [{"a": 1}, {"$expr": {"$gt": ["$b", "$c"]}}]}, + expected=[{"_id": 1, "a": 1, "b": 5, "c": 3}], + msg="$or mixing regular query and $expr", + ), + QueryTestCase( + id="field_path_through_array", + doc=[{"_id": 1, "items": [{"price": 50}, {"price": 150}]}], + filter={"$expr": {"$in": [150, "$items.price"]}}, + expected=[{"_id": 1, "items": [{"price": 50}, {"price": 150}]}], + msg="$expr with field path through array of objects", + ), + QueryTestCase( + id="cond_with_remove", + doc=[{"_id": 1, "a": 5}, {"_id": 2, "a": -1}], + filter={"$expr": {"$cond": [{"$gt": ["$a", 0]}, "$a", "$$REMOVE"]}}, + expected=[{"_id": 1, "a": 5}], + msg="$expr with $$REMOVE in $cond false branch — falsy", + ), + QueryTestCase( + id="bare_remove_falsy", + doc=[{"_id": 1}], + filter={"$expr": "$$REMOVE"}, + expected=[], + msg="$expr with bare $$REMOVE — falsy, no documents match", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_expr_complex(collection, test): + """Test $expr edge cases and unusual expressions.""" + collection.insert_many(test.doc) + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertResult(result, expected=test.expected, error_code=test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/misc/expr/test_expr_data_types_and_comparisons.py b/documentdb_tests/compatibility/tests/core/operator/query/misc/expr/test_expr_data_types_and_comparisons.py new file mode 100644 index 00000000..c5c84efe --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/misc/expr/test_expr_data_types_and_comparisons.py @@ -0,0 +1,447 @@ +""" +Tests for $expr data type coverage, BSON truthiness, numeric equivalence, +type distinction, and comparison operators. + +Covers truthiness by BSON type, field-to-field comparison with $gt/$gte/$lte, +cross-type numeric equivalence, BSON type distinction, BSON comparison order +boundaries, and literal expression truthiness. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp +from bson.codec_options import CodecOptions + +from documentdb_tests.compatibility.tests.core.operator.query.utils.query_test_case import ( + QueryTestCase, +) +from documentdb_tests.framework.assertions import assertResult, assertSuccessNaN +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + INT64_ZERO, +) + +ALL_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="truthiness_true", + doc=[{"_id": 1, "a": True}], + filter={"$expr": "$a"}, + expected=[{"_id": 1, "a": True}], + msg="true is truthy", + ), + QueryTestCase( + id="truthiness_false", + doc=[{"_id": 1, "a": False}], + filter={"$expr": "$a"}, + expected=[], + msg="false is falsy", + ), + QueryTestCase( + id="truthiness_int_1", + doc=[{"_id": 1, "a": 1}], + filter={"$expr": "$a"}, + expected=[{"_id": 1, "a": 1}], + msg="non-zero int is truthy", + ), + QueryTestCase( + id="truthiness_int_0", + doc=[{"_id": 1, "a": 0}], + filter={"$expr": "$a"}, + expected=[], + msg="zero int is falsy", + ), + QueryTestCase( + id="truthiness_long_1", + doc=[{"_id": 1, "a": Int64(1)}], + filter={"$expr": "$a"}, + expected=[{"_id": 1, "a": Int64(1)}], + msg="non-zero long is truthy", + ), + QueryTestCase( + id="truthiness_long_0", + doc=[{"_id": 1, "a": INT64_ZERO}], + filter={"$expr": "$a"}, + expected=[], + msg="zero long is falsy", + ), + QueryTestCase( + id="truthiness_double_1", + doc=[{"_id": 1, "a": 1.0}], + filter={"$expr": "$a"}, + expected=[{"_id": 1, "a": 1.0}], + msg="non-zero double is truthy", + ), + QueryTestCase( + id="truthiness_double_0", + doc=[{"_id": 1, "a": 0.0}], + filter={"$expr": "$a"}, + expected=[], + msg="zero double is falsy", + ), + QueryTestCase( + id="truthiness_decimal_1", + doc=[{"_id": 1, "a": Decimal128("1")}], + filter={"$expr": "$a"}, + expected=[{"_id": 1, "a": Decimal128("1")}], + msg="non-zero Decimal128 is truthy", + ), + QueryTestCase( + id="truthiness_decimal_0", + doc=[{"_id": 1, "a": DECIMAL128_ZERO}], + filter={"$expr": "$a"}, + expected=[], + msg="zero Decimal128 is falsy", + ), + QueryTestCase( + id="truthiness_null", + doc=[{"_id": 1, "a": None}], + filter={"$expr": "$a"}, + expected=[], + msg="null is falsy", + ), + QueryTestCase( + id="truthiness_string_nonempty", + doc=[{"_id": 1, "a": "hello"}], + filter={"$expr": "$a"}, + expected=[{"_id": 1, "a": "hello"}], + msg="non-empty string is truthy", + ), + QueryTestCase( + id="truthiness_string_empty", + doc=[{"_id": 1, "a": ""}], + filter={"$expr": "$a"}, + expected=[{"_id": 1, "a": ""}], + msg="empty string is truthy in BSON", + ), + QueryTestCase( + id="truthiness_infinity", + doc=[{"_id": 1, "a": FLOAT_INFINITY}], + filter={"$expr": "$a"}, + expected=[{"_id": 1, "a": FLOAT_INFINITY}], + msg="Infinity is truthy", + ), + QueryTestCase( + id="truthiness_neg_zero_double", + doc=[{"_id": 1, "a": DOUBLE_NEGATIVE_ZERO}], + filter={"$expr": "$a"}, + expected=[], + msg="-0.0 is falsy", + ), + QueryTestCase( + id="truthiness_neg_zero_decimal", + doc=[{"_id": 1, "a": DECIMAL128_NEGATIVE_ZERO}], + filter={"$expr": "$a"}, + expected=[], + msg="Decimal128('-0') is falsy", + ), + QueryTestCase( + id="truthiness_objectid", + doc=[{"_id": 1, "a": ObjectId("000000000000000000000001")}], + filter={"$expr": "$a"}, + expected=[{"_id": 1, "a": ObjectId("000000000000000000000001")}], + msg="ObjectId is truthy", + ), + QueryTestCase( + id="truthiness_empty_obj", + doc=[{"_id": 1, "a": {}}], + filter={"$expr": "$a"}, + expected=[{"_id": 1, "a": {}}], + msg="empty object is truthy", + ), + QueryTestCase( + id="truthiness_empty_arr", + doc=[{"_id": 1, "a": []}], + filter={"$expr": "$a"}, + expected=[{"_id": 1, "a": []}], + msg="empty array is truthy", + ), + QueryTestCase( + id="truthiness_nonempty_arr", + doc=[{"_id": 1, "a": [1, 2]}], + filter={"$expr": "$a"}, + expected=[{"_id": 1, "a": [1, 2]}], + msg="non-empty array is truthy", + ), + QueryTestCase( + id="truthiness_bindata_subtype_0", + doc=[{"_id": 1, "a": Binary(b"abc", 0)}], + filter={"$expr": "$a"}, + # driver automatically converts subtype 0 to raw bytes, + # so expected is in raw form instead of Binary(b"abc", 0) + expected=[{"_id": 1, "a": b"abc"}], + msg="BinData subtype 0 (generic) is truthy", + ), + QueryTestCase( + id="truthiness_bindata_subtype_1", + doc=[{"_id": 1, "a": Binary(b"abc", 1)}], + filter={"$expr": "$a"}, + expected=[{"_id": 1, "a": Binary(b"abc", 1)}], + msg="BinData subtype 1 (function) is truthy", + ), + QueryTestCase( + id="truthiness_bindata_subtype_4", + doc=[ + { + "_id": 1, + "a": Binary(b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10", 4), + } + ], + filter={"$expr": "$a"}, + expected=[ + { + "_id": 1, + "a": Binary(b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10", 4), + } + ], + msg="BinData subtype 4 (UUID) is truthy", + ), + QueryTestCase( + id="truthiness_bindata_subtype_128", + doc=[{"_id": 1, "a": Binary(b"abc", 128)}], + filter={"$expr": "$a"}, + expected=[{"_id": 1, "a": Binary(b"abc", 128)}], + msg="BinData subtype 128 (user-defined) is truthy", + ), + QueryTestCase( + id="truthiness_regex", + doc=[{"_id": 1, "a": Regex("pattern")}], + filter={"$expr": "$a"}, + expected=[{"_id": 1, "a": Regex("pattern")}], + msg="regex is truthy", + ), + QueryTestCase( + id="truthiness_minkey", + doc=[{"_id": 1, "a": MinKey()}], + filter={"$expr": "$a"}, + expected=[{"_id": 1, "a": MinKey()}], + msg="MinKey is truthy", + ), + QueryTestCase( + id="truthiness_maxkey", + doc=[{"_id": 1, "a": MaxKey()}], + filter={"$expr": "$a"}, + expected=[{"_id": 1, "a": MaxKey()}], + msg="MaxKey is truthy", + ), + QueryTestCase( + id="truthiness_timestamp", + doc=[{"_id": 1, "a": Timestamp(1, 1)}], + filter={"$expr": "$a"}, + expected=[{"_id": 1, "a": Timestamp(1, 1)}], + msg="Timestamp is truthy", + ), + QueryTestCase( + id="truthiness_missing", + doc=[{"_id": 1, "b": 1}], + filter={"$expr": "$a"}, + expected=[], + msg="missing field is falsy", + ), + QueryTestCase( + id="gt_field_to_field", + doc=[{"_id": 1, "a": 10, "b": 5}, {"_id": 2, "a": 5, "b": 10}], + filter={"$expr": {"$gt": ["$a", "$b"]}}, + expected=[{"_id": 1, "a": 10, "b": 5}], + msg="field-to-field comparison", + ), + QueryTestCase( + id="gte_equal", + doc=[{"_id": 1, "a": 5, "b": 5}, {"_id": 2, "a": 3, "b": 5}], + filter={"$expr": {"$gte": ["$a", "$b"]}}, + expected=[{"_id": 1, "a": 5, "b": 5}], + msg="$gte matches when fields are equal", + ), + QueryTestCase( + id="gte_greater", + doc=[{"_id": 1, "a": 10, "b": 5}, {"_id": 2, "a": 3, "b": 5}], + filter={"$expr": {"$gte": ["$a", "$b"]}}, + expected=[{"_id": 1, "a": 10, "b": 5}], + msg="$gte matches when first field is greater", + ), + QueryTestCase( + id="lte_equal", + doc=[{"_id": 1, "a": 5, "b": 5}, {"_id": 2, "a": 8, "b": 5}], + filter={"$expr": {"$lte": ["$a", "$b"]}}, + expected=[{"_id": 1, "a": 5, "b": 5}], + msg="$lte matches when fields are equal", + ), + QueryTestCase( + id="lte_less", + doc=[{"_id": 1, "a": 3, "b": 5}, {"_id": 2, "a": 8, "b": 5}], + filter={"$expr": {"$lte": ["$a", "$b"]}}, + expected=[{"_id": 1, "a": 3, "b": 5}], + msg="$lte matches when first field is less", + ), + QueryTestCase( + id="numeric_eq_int_double", + doc=[{"_id": 1, "a": 1, "b": 1.0}], + filter={"$expr": {"$eq": ["$a", "$b"]}}, + expected=[{"_id": 1, "a": 1, "b": 1.0}], + msg="int == double cross-type equivalence", + ), + QueryTestCase( + id="numeric_eq_int_zero_double_zero", + doc=[{"_id": 1, "a": 0, "b": 0.0}], + filter={"$expr": {"$eq": ["$a", "$b"]}}, + expected=[{"_id": 1, "a": 0, "b": 0.0}], + msg="int 0 == double 0.0", + ), + QueryTestCase( + id="numeric_eq_neg_zero_double", + doc=[{"_id": 1, "a": DOUBLE_NEGATIVE_ZERO, "b": 0.0}], + filter={"$expr": {"$eq": ["$a", "$b"]}}, + expected=[{"_id": 1, "a": DOUBLE_NEGATIVE_ZERO, "b": 0.0}], + msg="negative zero == positive zero", + ), + QueryTestCase( + id="numeric_eq_neg_zero_decimal", + doc=[{"_id": 1, "a": DECIMAL128_NEGATIVE_ZERO, "b": DECIMAL128_ZERO}], + filter={"$expr": {"$eq": ["$a", "$b"]}}, + expected=[{"_id": 1, "a": DECIMAL128_NEGATIVE_ZERO, "b": DECIMAL128_ZERO}], + msg="Decimal128 -0 == 0", + ), + QueryTestCase( + id="numeric_neq_int_string", + doc=[{"_id": 1, "a": 1, "b": "1"}], + filter={"$expr": {"$eq": ["$a", "$b"]}}, + expected=[], + msg="int != string (different BSON types)", + ), + QueryTestCase( + id="type_distinction_false_vs_zero", + doc=[{"_id": 1, "a": False, "b": 0}], + filter={"$expr": {"$eq": ["$a", "$b"]}}, + expected=[], + msg="false != 0 (bool vs int)", + ), + QueryTestCase( + id="type_distinction_true_vs_one", + doc=[{"_id": 1, "a": True, "b": 1}], + filter={"$expr": {"$eq": ["$a", "$b"]}}, + expected=[], + msg="true != 1 (bool vs int)", + ), + QueryTestCase( + id="type_distinction_null_vs_zero", + doc=[{"_id": 1, "a": None, "b": 0}], + filter={"$expr": {"$eq": ["$a", "$b"]}}, + expected=[], + msg="null != 0 (null vs int)", + ), + QueryTestCase( + id="type_distinction_empty_str_vs_null", + doc=[{"_id": 1, "a": "", "b": None}], + filter={"$expr": {"$eq": ["$a", "$b"]}}, + expected=[], + msg="empty string != null", + ), + QueryTestCase( + id="type_distinction_null_vs_missing", + doc=[{"_id": 1, "a": None}], + filter={"$expr": {"$eq": ["$a", "$b"]}}, + expected=[], + msg="null != missing field in $expr", + ), + QueryTestCase( + id="bson_order_minkey_lt_null", + doc=[{"_id": 1, "a": MinKey(), "b": None}], + filter={"$expr": {"$lt": ["$a", "$b"]}}, + expected=[{"_id": 1, "a": MinKey(), "b": None}], + msg="MinKey < null", + ), + QueryTestCase( + id="maxkey_gt_all", + doc=[{"_id": 1, "a": MaxKey(), "b": Regex("pattern")}], + filter={"$expr": {"$gt": ["$a", "$b"]}}, + expected=[{"_id": 1, "a": MaxKey(), "b": Regex("pattern")}], + msg="MaxKey > any other type", + ), + QueryTestCase( + id="literal_false", + doc=[{"_id": 1, "a": 1}], + filter={"$expr": False}, + expected=[], + msg="literal false is falsy — no documents match", + ), + QueryTestCase( + id="literal_null", + doc=[{"_id": 1, "a": 1}], + filter={"$expr": None}, + expected=[], + msg="literal null is falsy — no documents match", + ), + QueryTestCase( + id="literal_string", + doc=[{"_id": 1, "a": 1}], + filter={"$expr": "hello"}, + expected=[{"_id": 1, "a": 1}], + msg="literal non-empty string is truthy — all documents match", + ), + QueryTestCase( + id="gt_nested_dotted_fields", + doc=[ + {"_id": 1, "stats": {"score": 90, "threshold": 80}}, + {"_id": 2, "stats": {"score": 50, "threshold": 80}}, + ], + filter={"$expr": {"$gt": ["$stats.score", "$stats.threshold"]}}, + expected=[{"_id": 1, "stats": {"score": 90, "threshold": 80}}], + msg="$expr comparing nested dotted fields from same document", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_expr_data_types(collection, test): + """Test $expr data type coverage, BSON comparison order, and numeric equivalence.""" + collection.insert_many(test.doc) + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertResult(result, expected=test.expected, error_code=test.error_code, msg=test.msg) + + +def test_expr_nan_eq_nan(collection): + """Test $expr NaN == NaN is true""" + collection.insert_one({"_id": 1, "a": FLOAT_NAN, "b": FLOAT_NAN}) + result = execute_command( + collection, + { + "find": collection.name, + "filter": {"$expr": {"$eq": ["$a", "$b"]}}, + }, + ) + assertSuccessNaN(result, [{"_id": 1, "a": FLOAT_NAN, "b": FLOAT_NAN}]) + + +def test_expr_truthiness_nan_float(collection): + """Test $expr truthiness — float NaN field is truthy.""" + collection.insert_one({"_id": 1, "a": FLOAT_NAN}) + result = execute_command(collection, {"find": collection.name, "filter": {"$expr": "$a"}}) + assertSuccessNaN(result, [{"_id": 1, "a": FLOAT_NAN}]) + + +def test_expr_truthiness_decimal_nan(collection): + """Test $expr truthiness — Decimal128 NaN field is truthy.""" + collection.insert_one({"_id": 1, "a": DECIMAL128_NAN}) + result = execute_command(collection, {"find": collection.name, "filter": {"$expr": "$a"}}) + assertSuccessNaN(result, [{"_id": 1, "a": DECIMAL128_NAN}]) + + +def test_expr_truthiness_date(collection): + """Test $expr truthiness — UTC datetime field is truthy.""" + collection.insert_one({"_id": 1, "a": datetime(2024, 1, 1, tzinfo=timezone.utc)}) + codec = CodecOptions(tz_aware=True, tzinfo=timezone.utc) + result = execute_command( + collection, {"find": collection.name, "filter": {"$expr": "$a"}}, codec_options=codec + ) + assertResult( + result, + expected=[{"_id": 1, "a": datetime(2024, 1, 1, tzinfo=timezone.utc)}], + msg="date is truthy", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/misc/expr/test_expr_errors.py b/documentdb_tests/compatibility/tests/core/operator/query/misc/expr/test_expr_errors.py new file mode 100644 index 00000000..412ee23b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/misc/expr/test_expr_errors.py @@ -0,0 +1,145 @@ +""" +Tests for $expr error handling. + +Covers invalid expressions, dollar sign parsing errors, undefined variable +references, $expr in arrayFilters, $expr in $elemMatch query and projection, +query operators rejected inside $expr, and runtime error propagation. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.query.utils.query_test_case import ( + QueryTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode, assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + EXPR_IN_ARRAY_FILTERS_ERROR, + EXPRESSION_IN_NOT_ARRAY_ERROR, + EXPRESSION_TYPE_MISMATCH_ERROR, + FAILED_TO_PARSE_ERROR, + INVALID_DOLLAR_FIELD_PATH, + LET_UNDEFINED_VARIABLE_ERROR, + UNRECOGNIZED_EXPRESSION_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +ALL_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="invalid_operator", + doc=[{"_id": 1}], + filter={"$expr": {"$invalidOp": 1}}, + error_code=UNRECOGNIZED_EXPRESSION_ERROR, + msg="$expr with invalid aggregation operator", + ), + QueryTestCase( + id="bare_dollar", + doc=[{"_id": 1}], + filter={"$expr": {"$eq": ["$", 1]}}, + error_code=INVALID_DOLLAR_FIELD_PATH, + msg="$expr with bare '$' — invalid field path", + ), + QueryTestCase( + id="bare_double_dollar", + doc=[{"_id": 1}], + filter={"$expr": {"$eq": ["$$", 1]}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$expr with bare '$$' — empty variable name", + ), + QueryTestCase( + id="undefined_variable", + doc=[{"_id": 1}], + filter={"$expr": {"$eq": ["$$undefined_var", 1]}}, + error_code=LET_UNDEFINED_VARIABLE_ERROR, + msg="$expr referencing undefined variable", + ), + QueryTestCase( + id="rejects_query_gt", + doc=[{"_id": 1, "a": 5}], + filter={"$expr": {"a": {"$gt": 5}}}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="query-style $gt rejected inside $expr", + ), + QueryTestCase( + id="rejects_query_in", + doc=[{"_id": 1, "a": 5}], + filter={"$expr": {"a": {"$in": [1, 2]}}}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="query-style $in rejected inside $expr", + ), + QueryTestCase( + id="rejects_query_exists", + doc=[{"_id": 1, "a": 5}], + filter={"$expr": {"a": {"$exists": True}}}, + error_code=UNRECOGNIZED_EXPRESSION_ERROR, + msg="query-style $exists rejected inside $expr", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_expr_errors(collection, test): + """Test $expr error handling.""" + collection.insert_many(test.doc) + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertResult(result, expected=test.expected, error_code=test.error_code, msg=test.msg) + + +def test_expr_in_array_filters(collection): + """Test $expr used inside arrayFilters — should fail.""" + collection.insert_one({"_id": 1, "arr": [1, 2, 3]}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$set": {"arr.$[elem]": 0}}, + "arrayFilters": [{"$expr": {"$gt": ["$elem", 1]}}], + } + ], + }, + ) + assertFailureCode(result, EXPR_IN_ARRAY_FILTERS_ERROR) + + +def test_expr_in_elemmatch_query(collection): + """Test $expr inside $elemMatch in query position — should fail.""" + collection.insert_one({"_id": 1, "arr": [{"x": 1}, {"x": 5}]}) + result = execute_command( + collection, + { + "find": collection.name, + "filter": {"arr": {"$elemMatch": {"$expr": {"$gt": ["$x", 3]}}}}, + }, + ) + assertFailureCode(result, BAD_VALUE_ERROR) + + +def test_expr_in_elemmatch_projection(collection): + """Test $expr inside $elemMatch in projection position — should fail.""" + collection.insert_one({"_id": 1, "arr": [{"x": 1}, {"x": 5}]}) + result = execute_command( + collection, + { + "find": collection.name, + "filter": {"_id": 1}, + "projection": {"arr": {"$elemMatch": {"$expr": {"$gt": ["$x", 3]}}}}, + }, + ) + assertFailureCode(result, BAD_VALUE_ERROR) + + +def test_expr_runtime_error_when_document_matches(collection): + """Test $expr evaluates expression on matching documents — runtime error propagates.""" + collection.insert_one({"_id": 1, "a": 10, "b": 0}) + result = execute_command( + collection, + { + "find": collection.name, + "filter": {"a": 10, "$expr": {"$gt": [{"$divide": ["$a", "$b"]}, 0]}}, + }, + ) + assertFailureCode(result, BAD_VALUE_ERROR) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/misc/expr/test_expr_field_resolution_and_null.py b/documentdb_tests/compatibility/tests/core/operator/query/misc/expr/test_expr_field_resolution_and_null.py new file mode 100644 index 00000000..1812c48b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/misc/expr/test_expr_field_resolution_and_null.py @@ -0,0 +1,89 @@ +""" +Tests for $expr field resolution, null handling, and missing field behavior. + +Covers null vs missing distinction, null comparison ordering, +nested field paths, and composite array paths. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.query.utils.query_test_case import ( + QueryTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +ALL_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="null_eq_null", + doc=[{"_id": 1, "a": None}], + filter={"$expr": {"$eq": ["$a", None]}}, + expected=[{"_id": 1, "a": None}], + msg="$expr {$eq: ['$a', null]} where a is null", + ), + QueryTestCase( + id="missing_eq_null", + doc=[{"_id": 1, "b": 1}], + filter={"$expr": {"$eq": ["$a", None]}}, + expected=[], + msg="$expr {$eq: ['$a', null]} where a is missing — missing != null in $expr", + ), + QueryTestCase( + id="missing_field_gt", + doc=[{"_id": 1, "b": 1}], + filter={"$expr": {"$gt": ["$missing_field", 0]}}, + expected=[], + msg="$expr {$gt: ['$missing', 0]} — missing resolves to null, null < 0", + ), + QueryTestCase( + id="null_lt_number", + doc=[{"_id": 1, "a": None}], + filter={"$expr": {"$lt": ["$a", 0]}}, + expected=[{"_id": 1, "a": None}], + msg="$expr {$lt: ['$a', 0]} where a is null — null < numbers in BSON", + ), + QueryTestCase( + id="nested_dotted_field", + doc=[{"_id": 1, "a": {"b": 10}}, {"_id": 2, "a": {"b": 1}}], + filter={"$expr": {"$gt": ["$a.b", 5]}}, + expected=[{"_id": 1, "a": {"b": 10}}], + msg="$expr with nested field path '$a.b'", + ), + QueryTestCase( + id="deep_dotted_field", + doc=[{"_id": 1, "a": {"b": {"c": 1}}}, {"_id": 2, "a": {"b": {"c": 0}}}], + filter={"$expr": {"$eq": ["$a.b.c", 1]}}, + expected=[{"_id": 1, "a": {"b": {"c": 1}}}], + msg="$expr with deep nested field path '$a.b.c'", + ), + QueryTestCase( + id="nonexistent_dotted_field", + doc=[{"_id": 1, "a": {"b": 1}}], + filter={"$expr": {"$eq": ["$a.b.c", None]}}, + expected=[], + msg="$expr with nonexistent nested field — missing != null in $expr", + ), + QueryTestCase( + id="composite_array_path", + doc=[{"_id": 1, "a": [{"b": 1}, {"b": 2}]}], + filter={"$expr": {"$eq": [{"$size": "$a.b"}, 2]}}, + expected=[{"_id": 1, "a": [{"b": 1}, {"b": 2}]}], + msg="$expr with composite array path '$a.b' on array of objects", + ), + QueryTestCase( + id="dotted_field_explicit_null", + doc=[{"_id": 1, "a": {"b": None}}, {"_id": 2, "a": {"b": 5}}, {"_id": 3, "a": {}}], + filter={"$expr": {"$eq": ["$a.b", None]}}, + expected=[{"_id": 1, "a": {"b": None}}], + msg="$expr with $eq comparing nested dotted field to null — only matches explicit null", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_expr_field_resolution(collection, test): + """Test $expr field resolution, null handling, and missing field behavior.""" + collection.insert_many(test.doc) + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertResult(result, expected=test.expected, error_code=test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/misc/expr/test_expr_other_commands.py b/documentdb_tests/compatibility/tests/core/operator/query/misc/expr/test_expr_other_commands.py new file mode 100644 index 00000000..5b038109 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/misc/expr/test_expr_other_commands.py @@ -0,0 +1,657 @@ +""" +Tests for $expr in non-find command contexts. + +Covers aggregate $match, update, delete, findAndModify, count, distinct, +let variables in commands, collation, listDatabases, $lookup subpipeline, +and pipeline stage interaction. +""" + +from documentdb_tests.framework.assertions import ( + assertFailureCode, + assertSuccess, +) +from documentdb_tests.framework.error_codes import ( + LET_UNDEFINED_VARIABLE_ERROR, + UNRECOGNIZED_EXPRESSION_ERROR, +) +from documentdb_tests.framework.executor import execute_admin_command, execute_command + +BASIC_DOCS = [ + {"_id": 1, "a": 5, "b": 3}, + {"_id": 2, "a": 1, "b": 10}, + {"_id": 3, "a": -1, "b": 0}, +] + + +def test_expr_aggregate_match(collection): + """Test $expr in aggregate $match — same results as find.""" + collection.insert_many(BASIC_DOCS) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [{"$match": {"$expr": {"$gt": ["$a", "$b"]}}}], + "cursor": {}, + }, + ) + assertSuccess(result, [{"_id": 1, "a": 5, "b": 3}]) + + +def test_expr_match_combined_with_regular_query(collection): + """Test $expr combined with regular query operator in $match.""" + collection.insert_many(BASIC_DOCS) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [{"$match": {"$expr": {"$gt": ["$a", 0]}, "b": {"$lt": 10}}}], + "cursor": {}, + }, + ) + assertSuccess(result, [{"_id": 1, "a": 5, "b": 3}]) + + +def test_expr_match_with_and(collection): + """Test $match with $and containing two $expr clauses.""" + collection.insert_many(BASIC_DOCS) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$match": { + "$and": [ + {"$expr": {"$gt": ["$a", 0]}}, + {"$expr": {"$lt": ["$b", 10]}}, + ] + } + } + ], + "cursor": {}, + }, + ) + assertSuccess(result, [{"_id": 1, "a": 5, "b": 3}]) + + +def test_expr_match_truthiness(collection): + """Test $expr truthiness in $match — literal true matches all.""" + collection.insert_many(BASIC_DOCS) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [{"$match": {"$expr": True}}, {"$sort": {"_id": 1}}], + "cursor": {}, + }, + ) + assertSuccess(result, BASIC_DOCS) + + +def test_expr_match_error(collection): + """Test $expr with invalid operator in $match — returns error.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [{"$match": {"$expr": {"$invalidOp": 1}}}], + "cursor": {}, + }, + ) + assertFailureCode(result, UNRECOGNIZED_EXPRESSION_ERROR) + + +def test_expr_match_array_no_implicit(collection): + """Test $expr in $match does NOT do implicit array element matching.""" + collection.insert_one({"_id": 1, "a": [1, 5, 10, 15]}) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [{"$match": {"$expr": {"$gt": ["$a", 12]}}}], + "cursor": {}, + }, + ) + assertSuccess(result, [{"_id": 1, "a": [1, 5, 10, 15]}]) + + +def test_expr_match_after_group(collection): + """Test $expr in $match after $group — references grouped fields.""" + collection.insert_many( + [ + {"_id": 1, "cat": "A", "val": 10}, + {"_id": 2, "cat": "A", "val": 20}, + {"_id": 3, "cat": "B", "val": 5}, + ] + ) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$group": {"_id": "$cat", "total": {"$sum": "$val"}}}, + {"$match": {"$expr": {"$gt": ["$total", 10]}}}, + ], + "cursor": {}, + }, + ) + assertSuccess(result, [{"_id": "A", "total": 30}]) + + +def test_expr_match_after_addfields(collection): + """Test $expr in $match after $addFields references computed field.""" + collection.insert_many([{"_id": 1, "price": 80, "tax": 15}, {"_id": 2, "price": 50, "tax": 5}]) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$addFields": {"total": {"$add": ["$price", "$tax"]}}}, + {"$match": {"$expr": {"$gt": ["$total", 90]}}}, + {"$project": {"_id": 1, "total": 1}}, + ], + "cursor": {}, + }, + ) + assertSuccess(result, [{"_id": 1, "total": 95}]) + + +def test_expr_match_after_unwind(collection): + """Test $expr in $match after $unwind references unwound field.""" + collection.insert_one({"_id": 1, "items": [{"v": 10}, {"v": 3}]}) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$unwind": "$items"}, + {"$match": {"$expr": {"$gt": ["$items.v", 5]}}}, + {"$project": {"_id": 1, "v": "$items.v"}}, + ], + "cursor": {}, + }, + ) + assertSuccess(result, [{"_id": 1, "v": 10}]) + + +def test_expr_in_update_many(collection): + """Test $expr in updateMany filter.""" + collection.insert_many(BASIC_DOCS) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"$expr": {"$gt": ["$a", "$b"]}}, + "u": {"$set": {"flag": True}}, + "multi": True, + } + ], + }, + ) + assertSuccess(result, {"n": 1, "nModified": 1, "ok": 1.0}, raw_res=True) + + +def test_expr_let_in_update(collection): + """Test $expr with let variable in update command.""" + collection.insert_many(BASIC_DOCS) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"$expr": {"$eq": ["$a", "$$target"]}}, + "u": {"$set": {"found": True}}, + "multi": True, + } + ], + "let": {"target": 1}, + }, + ) + assertSuccess(result, {"n": 1, "nModified": 1, "ok": 1.0}, raw_res=True) + + +def test_expr_now_in_update_with_let(collection): + """Test $expr with let variable in filter, $$NOW in pipeline update.""" + collection.insert_many(BASIC_DOCS) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"$expr": {"$eq": ["$a", "$$target"]}}, + "u": [{"$set": {"updated_at": "$$NOW"}}], + "multi": True, + } + ], + "let": {"target": 5}, + }, + ) + assertSuccess(result, {"n": 1, "nModified": 1, "ok": 1.0}, raw_res=True) + + +def test_expr_in_delete_many(collection): + """Test $expr in deleteMany filter.""" + collection.insert_many(BASIC_DOCS) + result = execute_command( + collection, + { + "delete": collection.name, + "deletes": [{"q": {"$expr": {"$lt": ["$a", 0]}}, "limit": 0}], + }, + ) + assertSuccess(result, {"n": 1, "ok": 1.0}, raw_res=True) + + +def test_expr_let_in_delete(collection): + """Test $expr with let variable in delete command.""" + collection.insert_many(BASIC_DOCS) + result = execute_command( + collection, + { + "delete": collection.name, + "deletes": [{"q": {"$expr": {"$eq": ["$a", "$$target"]}}, "limit": 0}], + "let": {"target": -1}, + }, + ) + assertSuccess(result, {"n": 1, "ok": 1.0}, raw_res=True) + + +def test_expr_delete_with_in(collection): + """Test $expr in deleteMany with $in on array field.""" + collection.insert_many([{"_id": 1, "tags": [True, False]}, {"_id": 2, "tags": [False]}]) + result = execute_command( + collection, + { + "delete": collection.name, + "deletes": [{"q": {"$expr": {"$in": [True, "$tags"]}}, "limit": 0}], + }, + ) + assertSuccess(result, {"n": 1, "ok": 1.0}, raw_res=True) + + +def test_expr_delete_with_cond(collection): + """Test $expr in deleteMany with $cond expression.""" + collection.insert_many( + [ + {"_id": 1, "price": 100, "discount": 0.2}, + {"_id": 2, "price": 50, "discount": 0.1}, + ] + ) + result = execute_command( + collection, + { + "delete": collection.name, + "deletes": [ + { + "q": { + "$expr": { + "$lt": [ + { + "$cond": [ + {"$gt": ["$discount", 0]}, + {"$multiply": ["$price", {"$subtract": [1, "$discount"]}]}, + "$price", + ] + }, + 60, + ] + } + }, + "limit": 0, + } + ], + }, + ) + assertSuccess(result, {"n": 1, "ok": 1.0}, raw_res=True) + + +def test_expr_in_find_and_modify(collection): + """Test $expr in findAndModify query.""" + collection.insert_many(BASIC_DOCS) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"$expr": {"$gt": ["$a", "$b"]}}, + "update": {"$set": {"modified": True}}, + "sort": {"_id": 1}, + }, + ) + assertSuccess( + result, {"_id": 1, "a": 5, "b": 3}, raw_res=True, transform=lambda r: r.get("value") + ) + + +def test_expr_findandmodify_literal_true(collection): + """Test $expr with literal true in findAndModify — matches all, returns first by sort.""" + collection.insert_many(BASIC_DOCS) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"$expr": True}, + "update": {"$set": {"touched": True}}, + "sort": {"_id": 1}, + }, + ) + assertSuccess( + result, {"_id": 1, "a": 5, "b": 3}, raw_res=True, transform=lambda r: r.get("value") + ) + + +def test_expr_in_count(collection): + """Test $expr in count command.""" + collection.insert_many(BASIC_DOCS) + result = execute_command( + collection, + { + "count": collection.name, + "query": {"$expr": {"$gt": ["$a", 0]}}, + }, + ) + assertSuccess(result, 2, raw_res=True, transform=lambda r: r.get("n")) + + +def test_expr_in_distinct(collection): + """Test $expr in distinct command.""" + collection.insert_many( + [ + {"_id": 1, "cat": "A", "val": 10}, + {"_id": 2, "cat": "B", "val": 5}, + {"_id": 3, "cat": "A", "val": 3}, + ] + ) + result = execute_command( + collection, + { + "distinct": collection.name, + "key": "cat", + "query": {"$expr": {"$gt": ["$val", 4]}}, + }, + ) + assertSuccess( + result, sorted(["A", "B"]), raw_res=True, transform=lambda r: sorted(r.get("values", [])) + ) + + +def test_expr_let_in_find(collection): + """Test $expr with let variable in find command.""" + collection.insert_many(BASIC_DOCS) + result = execute_command( + collection, + { + "find": collection.name, + "filter": {"$expr": {"$eq": ["$a", "$$target"]}}, + "let": {"target": 5}, + }, + ) + assertSuccess(result, [{"_id": 1, "a": 5, "b": 3}]) + + +def test_expr_let_in_aggregate(collection): + """Test $expr with let variable in aggregate $match.""" + collection.insert_many(BASIC_DOCS) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [{"$match": {"$expr": {"$eq": ["$a", "$$target"]}}}], + "cursor": {}, + "let": {"target": 5}, + }, + ) + assertSuccess(result, [{"_id": 1, "a": 5, "b": 3}]) + + +def test_expr_with_collation(collection): + """Test $expr with collation — string comparison respects collation rules.""" + collection.insert_many([{"_id": 1, "name": "apple"}, {"_id": 2, "name": "Banana"}]) + result = execute_command( + collection, + { + "find": collection.name, + "filter": {"$expr": {"$gt": ["$name", "banana"]}}, + "collation": {"locale": "en", "strength": 2}, + }, + ) + assertSuccess(result, []) + + +def test_expr_in_list_databases(collection): + """Test $expr in listDatabases filter.""" + collection.insert_one({"_id": 1}) + db_name = collection.database.name + result = execute_admin_command( + collection, + { + "listDatabases": 1, + "filter": {"$expr": {"$eq": ["$name", db_name]}}, + }, + ) + assertSuccess( + result, + True, + raw_res=True, + transform=lambda r: db_name in [d["name"] for d in r.get("databases", [])], + ) + + +def test_expr_lookup_basic_eq(database_client): + """Test $lookup with $expr $eq joining on let variable.""" + orders = database_client.create_collection("orders_test") + customers = database_client.create_collection("customers_test") + customers.insert_many([{"_id": 1, "name": "Alice"}, {"_id": 2, "name": "Bob"}]) + orders.insert_many( + [ + {"_id": 10, "customer_id": 1, "item": "A"}, + {"_id": 11, "customer_id": 1, "item": "B"}, + {"_id": 12, "customer_id": 2, "item": "C"}, + ] + ) + result = execute_command( + customers, + { + "aggregate": customers.name, + "pipeline": [ + { + "$lookup": { + "from": orders.name, + "let": {"cust_id": "$_id"}, + "pipeline": [ + {"$match": {"$expr": {"$eq": ["$customer_id", "$$cust_id"]}}}, + {"$project": {"_id": 0, "item": 1}}, + ], + "as": "orders", + } + }, + {"$sort": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + assertSuccess( + result, + [ + {"_id": 1, "name": "Alice", "orders": [{"item": "A"}, {"item": "B"}]}, + {"_id": 2, "name": "Bob", "orders": [{"item": "C"}]}, + ], + ) + + +def test_expr_lookup_range_gt(database_client): + """Test $lookup with $expr $gt for range join.""" + items = database_client.create_collection("items_test") + thresholds = database_client.create_collection("thresholds_test") + thresholds.insert_one({"_id": 1, "min_qty": 5}) + items.insert_many([{"_id": 10, "qty": 10}, {"_id": 11, "qty": 3}, {"_id": 12, "qty": 7}]) + result = execute_command( + thresholds, + { + "aggregate": thresholds.name, + "pipeline": [ + { + "$lookup": { + "from": items.name, + "let": {"min": "$min_qty"}, + "pipeline": [ + {"$match": {"$expr": {"$gt": ["$qty", "$$min"]}}}, + {"$project": {"_id": 0, "qty": 1}}, + {"$sort": {"qty": 1}}, + ], + "as": "above_min", + } + } + ], + "cursor": {}, + }, + ) + assertSuccess(result, [{"_id": 1, "min_qty": 5, "above_min": [{"qty": 7}, {"qty": 10}]}]) + + +def test_expr_lookup_arithmetic(database_client): + """Test $lookup with $expr using arithmetic on let variable.""" + orders = database_client.create_collection("orders_arith") + limits = database_client.create_collection("limits_arith") + limits.insert_one({"_id": 1, "base_limit": 50}) + orders.insert_many([{"_id": 10, "amount": 120}, {"_id": 11, "amount": 80}]) + result = execute_command( + limits, + { + "aggregate": limits.name, + "pipeline": [ + { + "$lookup": { + "from": orders.name, + "let": {"limit": "$base_limit"}, + "pipeline": [ + { + "$match": { + "$expr": {"$gt": ["$amount", {"$multiply": ["$$limit", 2]}]} + } + }, + {"$project": {"_id": 0, "amount": 1}}, + ], + "as": "over_double", + } + } + ], + "cursor": {}, + }, + ) + assertSuccess(result, [{"_id": 1, "base_limit": 50, "over_double": [{"amount": 120}]}]) + + +def test_expr_lookup_let_null(database_client): + """Test $lookup with let variable resolving to null.""" + inner = database_client.create_collection("inner_null") + outer = database_client.create_collection("outer_null") + outer.insert_one({"_id": 1, "val": None}) + inner.insert_many([{"_id": 10, "x": None}, {"_id": 11, "x": 1}]) + result = execute_command( + outer, + { + "aggregate": outer.name, + "pipeline": [ + { + "$lookup": { + "from": inner.name, + "let": {"v": "$val"}, + "pipeline": [ + {"$match": {"$expr": {"$eq": ["$x", "$$v"]}}}, + {"$project": {"_id": 1}}, + ], + "as": "matched", + } + } + ], + "cursor": {}, + }, + ) + assertSuccess(result, [{"_id": 1, "val": None, "matched": [{"_id": 10}]}]) + + +def test_expr_lookup_let_missing_field(database_client): + """Test $lookup with let variable from missing field — resolves to missing, not null.""" + inner = database_client.create_collection("inner_miss") + outer = database_client.create_collection("outer_miss") + outer.insert_one({"_id": 1}) + inner.insert_many([{"_id": 10, "x": None}, {"_id": 11, "x": 1}]) + result = execute_command( + outer, + { + "aggregate": outer.name, + "pipeline": [ + { + "$lookup": { + "from": inner.name, + "let": {"v": "$val"}, + "pipeline": [ + {"$match": {"$expr": {"$eq": ["$x", "$$v"]}}}, + {"$project": {"_id": 1}}, + ], + "as": "matched", + } + } + ], + "cursor": {}, + }, + ) + # Missing field in let resolves to missing, which doesn't match null + assertSuccess(result, [{"_id": 1, "matched": []}]) + + +def test_expr_lookup_no_match(database_client): + """Test $lookup with $expr where no inner docs match — as field is empty array.""" + inner = database_client.create_collection("inner_nomatch") + outer = database_client.create_collection("outer_nomatch") + outer.insert_one({"_id": 1, "val": 999}) + inner.insert_many([{"_id": 10, "x": 1}, {"_id": 11, "x": 2}]) + result = execute_command( + outer, + { + "aggregate": outer.name, + "pipeline": [ + { + "$lookup": { + "from": inner.name, + "let": {"v": "$val"}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$x", "$$v"]}}}], + "as": "matched", + } + } + ], + "cursor": {}, + }, + ) + assertSuccess(result, [{"_id": 1, "val": 999, "matched": []}]) + + +def test_expr_lookup_undefined_variable(database_client): + """Test $lookup with $expr referencing undefined let variable — error.""" + inner = database_client.create_collection("inner_undef") + outer = database_client.create_collection("outer_undef") + outer.insert_one({"_id": 1}) + inner.insert_one({"_id": 10}) + result = execute_command( + outer, + { + "aggregate": outer.name, + "pipeline": [ + { + "$lookup": { + "from": inner.name, + "let": {}, + "pipeline": [{"$match": {"$expr": {"$eq": ["$x", "$$undefined_var"]}}}], + "as": "matched", + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, LET_UNDEFINED_VARIABLE_ERROR) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/misc/expr/test_expr_query_contexts.py b/documentdb_tests/compatibility/tests/core/operator/query/misc/expr/test_expr_query_contexts.py new file mode 100644 index 00000000..58eee972 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/misc/expr/test_expr_query_contexts.py @@ -0,0 +1,143 @@ +""" +Tests for $expr in find query contexts. + +Covers find with logical operators, cursor operations, array field behavior, +and $expr-specific semantics. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.query.utils.query_test_case import ( + QueryTestCase, +) +from documentdb_tests.framework.assertions import assertResult, assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +BASIC_DOCS = [ + {"_id": 1, "a": 5, "b": 3}, + {"_id": 2, "a": 1, "b": 10}, + {"_id": 3, "a": -1, "b": 0}, +] + + +FIND_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="combined_with_standard_query", + doc=BASIC_DOCS, + filter={"$expr": {"$gt": ["$a", 0]}, "b": {"$lt": 10}}, + expected=[{"_id": 1, "a": 5, "b": 3}], + msg="$expr combined with standard query operator", + ), + QueryTestCase( + id="or_two_exprs", + doc=BASIC_DOCS, + filter={"$or": [{"$expr": {"$gt": ["$a", 100]}}, {"$expr": {"$lt": ["$a", 0]}}]}, + expected=[{"_id": 3, "a": -1, "b": 0}], + msg="$or with two $expr clauses", + ), + QueryTestCase( + id="and_two_exprs", + doc=BASIC_DOCS, + filter={"$and": [{"$expr": {"$gt": ["$a", 0]}}, {"$expr": {"$lt": ["$b", 10]}}]}, + expected=[{"_id": 1, "a": 5, "b": 3}], + msg="$and with two $expr clauses", + ), + QueryTestCase( + id="nor_single_expr", + doc=BASIC_DOCS, + filter={"$nor": [{"$expr": {"$gt": ["$a", 0]}}]}, + expected=[{"_id": 3, "a": -1, "b": 0}], + msg="$nor with $expr — matches docs where expression is false", + ), + QueryTestCase( + id="nor_two_exprs", + doc=BASIC_DOCS, + filter={"$nor": [{"$expr": {"$gt": ["$a", 0]}}, {"$expr": {"$lt": ["$a", 0]}}]}, + expected=[], + msg="$nor with two $expr clauses — excludes docs matching either", + ), + QueryTestCase( + id="array_no_implicit_matching", + doc=[{"_id": 1, "a": [1, 5, 10, 15]}], + filter={"$expr": {"$gt": ["$a", 12]}}, + expected=[{"_id": 1, "a": [1, 5, 10, 15]}], + msg="$expr does NOT do implicit array element matching", + ), + QueryTestCase( + id="result_not_projected", + doc=[{"_id": 1, "a": 5, "b": 8}], + filter={"$expr": {"$gt": [{"$add": ["$a", "$b"]}, 10]}}, + expected=[{"_id": 1, "a": 5, "b": 8}], + msg="$expr only filters — computed values don't appear in output", + ), +] + +ALL_TESTS = FIND_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_expr_query_contexts(collection, test): + """Test $expr in find with various query patterns.""" + if test.doc: + collection.insert_many(test.doc) + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertResult(result, expected=test.expected, error_code=test.error_code, msg=test.msg) + + +def test_expr_with_sort(collection): + """Test find with $expr + sort.""" + collection.insert_many(BASIC_DOCS) + result = execute_command( + collection, + { + "find": collection.name, + "filter": {"$expr": {"$gt": ["$a", 0]}}, + "sort": {"a": -1}, + }, + ) + assertSuccess(result, [{"_id": 1, "a": 5, "b": 3}, {"_id": 2, "a": 1, "b": 10}]) + + +def test_expr_with_limit(collection): + """Test find with $expr + limit.""" + collection.insert_many(BASIC_DOCS) + result = execute_command( + collection, + { + "find": collection.name, + "filter": {"$expr": {"$gt": ["$a", 0]}}, + "sort": {"_id": 1}, + "limit": 1, + }, + ) + assertSuccess(result, [{"_id": 1, "a": 5, "b": 3}]) + + +def test_expr_with_projection(collection): + """Test find with $expr + projection.""" + collection.insert_many(BASIC_DOCS) + result = execute_command( + collection, + { + "find": collection.name, + "filter": {"$expr": {"$gt": ["$a", 0]}}, + "projection": {"a": 1, "_id": 0}, + }, + ) + assertSuccess(result, [{"a": 5}, {"a": 1}], ignore_doc_order=True) + + +def test_expr_with_skip(collection): + """Test find with $expr + skip.""" + collection.insert_many(BASIC_DOCS) + result = execute_command( + collection, + { + "find": collection.name, + "filter": {"$expr": {"$gt": ["$a", 0]}}, + "sort": {"_id": 1}, + "skip": 1, + }, + ) + assertSuccess(result, [{"_id": 2, "a": 1, "b": 10}]) diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 67a764f6..7fdbd6a1 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -8,6 +8,7 @@ TYPE_MISMATCH_ERROR = 14 OVERFLOW_ERROR = 15 UNRECOGNIZED_EXPRESSION_ERROR = 168 +EXPR_IN_ARRAY_FILTERS_ERROR = 224 CONVERSION_FAILURE_ERROR = 241 EXPRESSION_NOT_OBJECT_ERROR = 10065 SORT_COMPOUND_KEY_LIMIT_ERROR = 13103 @@ -18,6 +19,7 @@ SORT_ORDER_RANGE_ERROR = 15975 SORT_EMPTY_SPEC_ERROR = 15976 EXPRESSION_OBJECT_MULTIPLE_FIELDS_ERROR = 15983 +EXPRESSION_ARITY_MISMATCH_ERROR = 15994 FIELD_PATH_EMPTY_COMPONENT_ERROR = 15998 INVALID_TYPE_ERROR = 16004 TYPE_MISMATCH_DATE_ERROR = 16006 @@ -28,10 +30,13 @@ FIELD_PATH_DOLLAR_PREFIX_ERROR = 16410 FIELD_PATH_NULL_BYTE_ERROR = 16411 STRING_SIZE_LIMIT_ERROR = 16493 +ADD_TYPE_MISMATCH_ERROR = 16554 +DIVIDE_BY_ZERO_ERROR = 16608 MODULO_ZERO_REMAINDER_ERROR = 16610 MODULO_NON_NUMERIC_ERROR = 16611 MORE_THAN_ONE_DATE_ERROR = 16612 CONCAT_TYPE_ERROR = 16702 +EMPTY_VARIABLE_NAME_ERROR = 16867 INVALID_DOLLAR_FIELD_PATH = 16872 LET_NON_OBJECT_ARGUMENT_ERROR = 16874 LET_UNKNOWN_FIELD_ERROR = 16875