diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_as_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_as_errors.py new file mode 100644 index 000000000..5a0861764 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_as_errors.py @@ -0,0 +1,132 @@ +""" +Error tests for $filter 'as' parameter. + +Tests invalid 'as' types. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.error_codes import FAILED_TO_PARSE_ERROR +from documentdb_tests.framework.parametrize import pytest_params + +INVALID_AS_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="type_int", + expression={"$filter": {"input": [1, 2, 3], "as": 1, "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="as=int should error", + ), + ExpressionTestCase( + id="type_long", + expression={"$filter": {"input": [1, 2, 3], "as": Int64(1), "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="as=Int64 should error", + ), + ExpressionTestCase( + id="type_object", + expression={"$filter": {"input": [1, 2, 3], "as": {"a": 1}, "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="as=object should error", + ), + ExpressionTestCase( + id="type_array", + expression={"$filter": {"input": [1, 2, 3], "as": [1], "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="as=array should error", + ), + ExpressionTestCase( + id="type_minkey", + expression={"$filter": {"input": [1, 2, 3], "as": MinKey(), "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="as=MinKey should error", + ), + ExpressionTestCase( + id="type_maxkey", + expression={"$filter": {"input": [1, 2, 3], "as": MaxKey(), "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="as=MaxKey should error", + ), + ExpressionTestCase( + id="type_bindata", + expression={"$filter": {"input": [1, 2, 3], "as": Binary(b"\x00", 0), "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="as=Binary should error", + ), + ExpressionTestCase( + id="type_objectid", + expression={"$filter": {"input": [1, 2, 3], "as": ObjectId(), "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="as=ObjectId should error", + ), + ExpressionTestCase( + id="type_date", + expression={ + "$filter": { + "input": [1, 2, 3], + "as": datetime(2024, 1, 1, tzinfo=timezone.utc), + "cond": True, + } + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="as=datetime should error", + ), + ExpressionTestCase( + id="type_timestamp", + expression={"$filter": {"input": [1, 2, 3], "as": Timestamp(0, 0), "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="as=Timestamp should error", + ), + ExpressionTestCase( + id="type_regex", + expression={"$filter": {"input": [1, 2, 3], "as": Regex("pattern"), "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="as=Regex should error", + ), + ExpressionTestCase( + id="type_bool_true", + expression={"$filter": {"input": [1, 2, 3], "as": True, "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="as=True should error", + ), + ExpressionTestCase( + id="type_null", + expression={"$filter": {"input": [1, 2, 3], "as": None, "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="as=None should error", + ), + ExpressionTestCase( + id="type_empty_string", + expression={"$filter": {"input": [1, 2, 3], "as": "", "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="as='' should error", + ), + ExpressionTestCase( + id="type_nan", + expression={"$filter": {"input": [1, 2, 3], "as": float("nan"), "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="as=NaN should error", + ), + ExpressionTestCase( + id="type_infinity", + expression={"$filter": {"input": [1, 2, 3], "as": float("inf"), "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="as=inf should error", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(INVALID_AS_TYPE_TESTS)) +def test_filter_invalid_as(collection, test): + """Test $filter with invalid 'as' parameter values.""" + result = execute_expression(collection, test.expression) + assert_expression_result(result, error_code=test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_bson_types.py new file mode 100644 index 000000000..187396bfe --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_bson_types.py @@ -0,0 +1,317 @@ +""" +BSON type element preservation tests for $filter expression. + +Tests that various BSON types are preserved when filtering arrays +(using a cond that keeps all elements), including special numeric values +and boundary values. +""" + +from datetime import datetime, timezone +from uuid import UUID + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT64_MAX, + INT64_MIN, +) + +# BSON types preserved +BSON_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="int64_values", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [Int64(1), Int64(2), Int64(3)]}, + expected=[Int64(1), Int64(2), Int64(3)], + msg="Should preserve Int64 values", + ), + ExpressionTestCase( + id="decimal128_values", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [Decimal128("1.5"), Decimal128("2.5")]}, + expected=[Decimal128("1.5"), Decimal128("2.5")], + msg="Should preserve Decimal128 values", + ), + ExpressionTestCase( + id="datetime_values", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={ + "arr": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 6, 1, tzinfo=timezone.utc), + ] + }, + expected=[ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 6, 1, tzinfo=timezone.utc), + ], + msg="Should preserve datetime values", + ), + ExpressionTestCase( + id="objectid_values", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [ObjectId("000000000000000000000001"), ObjectId("000000000000000000000002")]}, + expected=[ObjectId("000000000000000000000001"), ObjectId("000000000000000000000002")], + msg="Should preserve ObjectId values", + ), + ExpressionTestCase( + id="binary_values", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [Binary(b"\x01", 0), Binary(b"\x02", 0)]}, + expected=[b"\x01", b"\x02"], + msg="Should preserve Binary values", + ), + ExpressionTestCase( + id="binary_subtype_preservation", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [Binary(b"\x01", 128), Binary(b"\x02", 128)]}, + expected=[Binary(b"\x01", 128), Binary(b"\x02", 128)], + msg="Should preserve Binary subtype", + ), + ExpressionTestCase( + id="regex_values", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [Regex("^a", "i"), Regex("^b", "i")]}, + expected=[Regex("^a", "i"), Regex("^b", "i")], + msg="Should preserve Regex values", + ), + ExpressionTestCase( + id="timestamp_values", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [Timestamp(1, 0), Timestamp(2, 0)]}, + expected=[Timestamp(1, 0), Timestamp(2, 0)], + msg="Should preserve Timestamp values", + ), + ExpressionTestCase( + id="minkey_maxkey", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [MinKey(), MaxKey()]}, + expected=[MinKey(), MaxKey()], + msg="Should preserve MinKey/MaxKey values", + ), + ExpressionTestCase( + id="uuid_values", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={ + "arr": [ + Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), + Binary.from_uuid(UUID("fedcba98-7654-3210-0123-456789abcdef")), + ] + }, + expected=[ + Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), + Binary.from_uuid(UUID("fedcba98-7654-3210-0123-456789abcdef")), + ], + msg="Should preserve UUID binary values", + ), +] + +# Mixed BSON types +MIXED_BSON_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="mixed_bson_types", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [1, "two", Int64(3), Decimal128("4"), True, None, MinKey()]}, + expected=[1, "two", Int64(3), Decimal128("4"), True, None, MinKey()], + msg="Should preserve mixed BSON types", + ), + ExpressionTestCase( + id="mixed_dates_and_ids", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={ + "arr": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + ObjectId("000000000000000000000001"), + Timestamp(1, 0), + ] + }, + expected=[ + datetime(2024, 1, 1, tzinfo=timezone.utc), + ObjectId("000000000000000000000001"), + Timestamp(1, 0), + ], + msg="Should preserve dates, ObjectIds, timestamps", + ), +] + +# Special numeric values as elements +SPECIAL_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="infinity_values", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [FLOAT_INFINITY, FLOAT_NEGATIVE_INFINITY]}, + expected=[FLOAT_INFINITY, FLOAT_NEGATIVE_INFINITY], + msg="Should preserve infinity values", + ), + ExpressionTestCase( + id="decimal128_infinity", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [DECIMAL128_INFINITY, DECIMAL128_NEGATIVE_INFINITY]}, + expected=[DECIMAL128_INFINITY, DECIMAL128_NEGATIVE_INFINITY], + msg="Should preserve Decimal128 infinity values", + ), + ExpressionTestCase( + id="boundary_values", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [INT32_MIN, INT32_MAX, INT64_MIN, INT64_MAX]}, + expected=[INT32_MIN, INT32_MAX, INT64_MIN, INT64_MAX], + msg="Should preserve numeric boundary values", + ), + ExpressionTestCase( + id="negative_zero", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [DOUBLE_NEGATIVE_ZERO, DECIMAL128_NEGATIVE_ZERO]}, + expected=[DOUBLE_NEGATIVE_ZERO, DECIMAL128_NEGATIVE_ZERO], + msg="Should preserve negative zero values", + ), +] + +# Decimal128 precision preservation +DECIMAL128_PRECISION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="decimal128_trailing_zeros", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [Decimal128("1.0"), Decimal128("1.00"), Decimal128("1.000")]}, + expected=[Decimal128("1.0"), Decimal128("1.00"), Decimal128("1.000")], + msg="Decimal128 trailing zeros preserved", + ), + ExpressionTestCase( + id="decimal128_nan", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [DECIMAL128_NAN, Decimal128("1")]}, + expected=[DECIMAL128_NAN, Decimal128("1")], + msg="Decimal128 NaN preserved", + ), +] + +# BSON type filtering with $eq condition +BSON_FILTER_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="filter_int64", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", Int64(2)]}}}, + doc={"arr": [Int64(1), Int64(2), Int64(3)]}, + expected=[Int64(2)], + msg="Should filter and preserve Int64", + ), + ExpressionTestCase( + id="filter_decimal128", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", Decimal128("2.5")]}}}, + doc={"arr": [Decimal128("1.5"), Decimal128("2.5")]}, + expected=[Decimal128("2.5")], + msg="Should filter and preserve Decimal128", + ), + ExpressionTestCase( + id="filter_datetime", + expression={ + "$filter": { + "input": "$arr", + "cond": {"$eq": ["$$this", datetime(2024, 6, 1, tzinfo=timezone.utc)]}, + } + }, + doc={ + "arr": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 6, 1, tzinfo=timezone.utc), + ] + }, + expected=[datetime(2024, 6, 1, tzinfo=timezone.utc)], + msg="Should filter and preserve datetime", + ), + ExpressionTestCase( + id="filter_objectid", + expression={ + "$filter": { + "input": "$arr", + "cond": {"$eq": ["$$this", ObjectId("000000000000000000000001")]}, + } + }, + doc={"arr": [ObjectId("000000000000000000000001"), ObjectId("000000000000000000000002")]}, + expected=[ObjectId("000000000000000000000001")], + msg="Should filter and preserve ObjectId", + ), + ExpressionTestCase( + id="filter_binary_subtype", + expression={ + "$filter": {"input": "$arr", "cond": {"$eq": ["$$this", Binary(b"\x02", 128)]}} + }, + doc={"arr": [Binary(b"\x01", 128), Binary(b"\x02", 128)]}, + expected=[Binary(b"\x02", 128)], + msg="Should filter and preserve Binary subtype", + ), + ExpressionTestCase( + id="filter_timestamp", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", Timestamp(2, 0)]}}}, + doc={"arr": [Timestamp(1, 0), Timestamp(2, 0)]}, + expected=[Timestamp(2, 0)], + msg="Should filter and preserve Timestamp", + ), + ExpressionTestCase( + id="filter_regex", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", Regex("^b", "i")]}}}, + doc={"arr": [Regex("^a", "i"), Regex("^b", "i")]}, + expected=[Regex("^b", "i")], + msg="Should filter and preserve Regex", + ), + ExpressionTestCase( + id="filter_minkey", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", MinKey()]}}}, + doc={"arr": [MinKey(), MaxKey()]}, + expected=[MinKey()], + msg="Should filter and preserve MinKey", + ), + ExpressionTestCase( + id="filter_decimal128_nan_not_gte", + expression={"$filter": {"input": "$arr", "cond": {"$gte": ["$$this", 1]}}}, + doc={"arr": [Decimal128("NaN")]}, + expected=[], + msg="Decimal128 NaN not >= 1", + ), + ExpressionTestCase( + id="filter_decimal128_neg_inf_not_gte", + expression={"$filter": {"input": "$arr", "cond": {"$gte": ["$$this", 1]}}}, + doc={"arr": [Decimal128("-Infinity")]}, + expected=[], + msg="Decimal128 -Infinity not >= 1", + ), + ExpressionTestCase( + id="filter_decimal128_inf_gte", + expression={"$filter": {"input": "$arr", "cond": {"$gte": ["$$this", 1]}}}, + doc={"arr": [Decimal128("Infinity")]}, + expected=[Decimal128("Infinity")], + msg="Decimal128 Infinity >= 1", + ), +] + +# Aggregate and test +ALL_BSON_TESTS = ( + BSON_TYPE_TESTS + + MIXED_BSON_TESTS + + SPECIAL_NUMERIC_TESTS + + DECIMAL128_PRECISION_TESTS + + BSON_FILTER_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_BSON_TESTS)) +def test_filter_bson_insert(collection, test): + """Test $filter BSON types with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result(result, expected=test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_core_behavior.py new file mode 100644 index 000000000..0c12e1b99 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_core_behavior.py @@ -0,0 +1,419 @@ +""" +Core behavior tests for $filter expression. + +Tests basic filtering, empty arrays, null propagation, custom 'as' variable, +various condition expressions, nested arrays, limit parameter, objects as +elements, and large arrays. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import INT32_MAX + +# Success: basic filtering +BASIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="gt_filter", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 3]}}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[4, 5], + msg="Should keep elements greater than 3", + ), + ExpressionTestCase( + id="gte_filter", + expression={"$filter": {"input": "$arr", "cond": {"$gte": ["$$this", 3]}}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[3, 4, 5], + msg="Should keep elements >= 3", + ), + ExpressionTestCase( + id="lt_filter", + expression={"$filter": {"input": "$arr", "cond": {"$lt": ["$$this", 3]}}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[1, 2], + msg="Should keep elements less than 3", + ), + ExpressionTestCase( + id="eq_filter", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", 2]}}}, + doc={"arr": [1, 2, 3, 2, 1]}, + expected=[2, 2], + msg="Should keep elements equal to 2", + ), + ExpressionTestCase( + id="ne_filter", + expression={"$filter": {"input": "$arr", "cond": {"$ne": ["$$this", 2]}}}, + doc={"arr": [1, 2, 3, 2, 1]}, + expected=[1, 3, 1], + msg="Should keep elements not equal to 2", + ), + ExpressionTestCase( + id="none_match", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 100]}}}, + doc={"arr": [1, 2, 3]}, + expected=[], + msg="Should return empty when none match", + ), + ExpressionTestCase( + id="string_filter", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", "abcd"]}}}, + doc={"arr": ["abcd", "efgh", "abcd", "zyz"]}, + expected=["abcd", "abcd"], + msg="Should filter strings abcd", + ), + ExpressionTestCase( + id="bool_cond_true", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [1, 2, 3]}, + expected=[1, 2, 3], + msg="Literal true cond should keep all elements", + ), + ExpressionTestCase( + id="bool_cond_false", + expression={"$filter": {"input": "$arr", "cond": False}}, + doc={"arr": [1, 2, 3]}, + expected=[], + msg="Literal false cond should keep no elements", + ), + ExpressionTestCase( + id="empty_array", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 0]}}}, + doc={"arr": []}, + expected=[], + msg="Should return empty array for empty input", + ), + ExpressionTestCase( + id="null_input", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 0]}}}, + doc={"arr": None}, + expected=None, + msg="Should return null when input is null", + ), + ExpressionTestCase( + id="custom_as_var", + expression={"$filter": {"input": "$arr", "as": "item", "cond": {"$gt": ["$$item", 3]}}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[4, 5], + msg="Should use custom 'as' variable name", + ), + ExpressionTestCase( + id="single_element_match", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 0]}}}, + doc={"arr": [42]}, + expected=[42], + msg="Should keep single matching element", + ), + ExpressionTestCase( + id="large_array_1000", + expression={"$filter": {"input": "$arr", "cond": {"$gte": ["$$this", 500]}}}, + doc={"arr": list(range(1000))}, + expected=list(range(500, 1000)), + msg="Should filter large array", + ), +] + +# Success: nested arrays (filter does not recurse) +NESTED_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="nested_arrays_by_size", + expression={"$filter": {"input": "$arr", "cond": {"$gt": [{"$size": "$$this"}, 1]}}}, + doc={"arr": [[1, 2], [3, 4, 5], [], [6]]}, + expected=[[1, 2], [3, 4, 5]], + msg="Should filter subarrays by size", + ), + ExpressionTestCase( + id="nested_arrays_identity", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [[1], [2], [3]]}, + expected=[[1], [2], [3]], + msg="Should preserve nested arrays when all match", + ), +] + +# Success: elements with null +NULL_ELEMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="filter_out_nulls", + expression={"$filter": {"input": "$arr", "cond": {"$ne": ["$$this", None]}}}, + doc={"arr": [1, None, 2, None, 3]}, + expected=[1, 2, 3], + msg="Should filter out null elements", + ), + ExpressionTestCase( + id="keep_only_nulls", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", None]}}}, + doc={"arr": [1, None, 2, None]}, + expected=[None, None], + msg="Should keep only null elements", + ), +] + +# Success: objects as elements +OBJECT_ELEMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="filter_objects_by_nested_field", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this.a.b", 2]}}}, + doc={"arr": [{"a": {"b": 1}}, {"a": {"b": 5}}, {"a": {"b": 3}}]}, + expected=[{"a": {"b": 5}}, {"a": {"b": 3}}], + msg="Should filter objects by nested field", + ), + ExpressionTestCase( + id="duplicate_objects", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this.a", 1]}}}, + doc={"arr": [{"a": 1}, {"a": 1}, {"a": 2}]}, + expected=[{"a": 1}, {"a": 1}], + msg="Should return all matching duplicate objects", + ), + ExpressionTestCase( + id="single_object_no_match", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this.a", 10]}}}, + doc={"arr": [{"a": 1}]}, + expected=[], + msg="Should return empty array when single object does not match", + ), +] + +# Success: limit parameter +LIMIT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="limit_1", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 2]}, "limit": 1}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[3], + msg="Should return at most 1 matching element", + ), + ExpressionTestCase( + id="limit_2", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 2]}, "limit": 2}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[3, 4], + msg="Should return at most 2 matching elements", + ), + ExpressionTestCase( + id="limit_exceeds_matches", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 3]}, "limit": 10}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[4, 5], + msg="Limit > matches should return all matches", + ), + ExpressionTestCase( + id="limit_on_empty", + expression={"$filter": {"input": "$arr", "cond": True, "limit": 5}}, + doc={"arr": []}, + expected=[], + msg="Limit on empty array returns empty", + ), + ExpressionTestCase( + id="limit_with_none_matching", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 100]}, "limit": 2}}, + doc={"arr": [1, 2, 3]}, + expected=[], + msg="Limit with no matches returns empty", + ), + ExpressionTestCase( + id="limit_long", + expression={ + "$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 2]}, "limit": Int64(1)} + }, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[3], + msg="Long limit should work", + ), + ExpressionTestCase( + id="limit_double_whole", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 2]}, "limit": 1.0}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[3], + msg="Whole-number double limit should work", + ), + ExpressionTestCase( + id="limit_decimal128", + expression={ + "$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 2]}, "limit": Decimal128("1")} + }, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[3], + msg="Decimal128 limit should work", + ), + ExpressionTestCase( + id="limit_with_duplicates", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", 2]}, "limit": 2}}, + doc={"arr": [2, 2, 2]}, + expected=[2, 2], + msg="Limit 2 with duplicates returns first 2 matches", + ), + ExpressionTestCase( + id="limit_int32_max", + expression={"$filter": {"input": "$arr", "cond": True, "limit": INT32_MAX}}, + doc={"arr": [1, 2, 3]}, + expected=[1, 2, 3], + msg="INT32_MAX limit should return all matches", + ), + ExpressionTestCase( + id="limit_null", + expression={"$filter": {"input": "$arr", "cond": True, "limit": None}}, + doc={"arr": [1, 2, 3]}, + expected=[1, 2, 3], + msg="Null limit should return all matches", + ), + ExpressionTestCase( + id="limit_missing_field_ref", + expression={"$filter": {"input": "$arr", "cond": True, "limit": "$nonexistent"}}, + doc={"arr": [1, 2, 3]}, + expected=[1, 2, 3], + msg="Missing field reference for limit should return all matches", + ), +] + +# Success: type-based filtering +TYPE_FILTER_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="filter_by_type", + expression={"$filter": {"input": "$arr", "cond": {"$eq": [{"$type": "$$this"}, "int"]}}}, + doc={"arr": [1, "two", True, None, [5], {"a": 1}]}, + expected=[1], + msg="Should filter by BSON type", + ), + ExpressionTestCase( + id="filter_strings_only", + expression={"$filter": {"input": "$arr", "cond": {"$eq": [{"$type": "$$this"}, "string"]}}}, + doc={"arr": [1, "good", 2, "morning", True]}, + expected=["good", "morning"], + msg="Should keep only string elements", + ), +] + +# Success: cond true or false +COND_FALSY_TRUTHY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="cond_nonzero_truthy", + expression={"$filter": {"input": "$arr", "cond": "$$this"}}, + doc={"arr": [0, 1, 2, 0, 3]}, + expected=[1, 2, 3], + msg="Non-zero values are truthy", + ), + ExpressionTestCase( + id="cond_null_falsy", + expression={"$filter": {"input": "$arr", "cond": "$$this"}}, + doc={"arr": [1, None, 2, None]}, + expected=[1, 2], + msg="Null is falsy", + ), + ExpressionTestCase( + id="cond_zero_falsy", + expression={"$filter": {"input": "$arr", "cond": 0}}, + doc={"arr": [1, 2]}, + expected=[], + msg="0 is falsy", + ), + ExpressionTestCase( + id="cond_empty_string_truthy", + expression={"$filter": {"input": "$arr", "cond": ""}}, + doc={"arr": [1, 2]}, + expected=[1, 2], + msg="Empty string is truthy in MongoDB", + ), + ExpressionTestCase( + id="cond_object_truthy", + expression={"$filter": {"input": "$arr", "cond": {"x": 10}}}, + doc={"arr": [1, 2]}, + expected=[1, 2], + msg="Object is truthy", + ), + ExpressionTestCase( + id="cond_empty_object_truthy", + expression={"$filter": {"input": "$arr", "cond": {}}}, + doc={"arr": [1, 2]}, + expected=[1, 2], + msg="Empty object is truthy", + ), + ExpressionTestCase( + id="cond_empty_array_truthy", + expression={"$filter": {"input": "$arr", "cond": []}}, + doc={"arr": [1, 2]}, + expected=[1, 2], + msg="Empty array is truthy", + ), +] + + +# Success: type strict equality +TYPE_STRICT_EQUALITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="false_vs_zero", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", False]}}}, + doc={"arr": [False, 0, 1, True]}, + expected=[False], + msg="$eq false should match only false, not 0", + ), + ExpressionTestCase( + id="true_vs_one", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", True]}}}, + doc={"arr": [True, 1, 0, False]}, + expected=[True], + msg="$eq true should match only true, not 1", + ), + ExpressionTestCase( + id="empty_string_vs_null", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", ""]}}}, + doc={"arr": ["", None, "a"]}, + expected=[""], + msg="$eq '' should match only empty string, not null", + ), + ExpressionTestCase( + id="null_vs_zero_false_empty_string", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", None]}}}, + doc={"arr": [None, 0, False, ""]}, + expected=[None], + msg="$eq null should match only null, not 0, false, or empty string", + ), +] + +# Success: numeric equivalence +NUMERIC_EQUIVALENCE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="numeric_equivalence_one", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", 1]}}}, + doc={"arr": [1, Int64(1), 1.0, Decimal128("1")]}, + expected=[1, Int64(1), 1.0, Decimal128("1")], + msg="All numeric representations of 1 should match", + ), + ExpressionTestCase( + id="numeric_equivalence_zero", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", 0]}}}, + doc={"arr": [0, Int64(0), 0.0, Decimal128("0")]}, + expected=[0, Int64(0), 0.0, Decimal128("0")], + msg="All numeric representations of 0 should match", + ), +] + +# Aggregate and test +ALL_TESTS = ( + BASIC_TESTS + + NESTED_ARRAY_TESTS + + NULL_ELEMENT_TESTS + + OBJECT_ELEMENT_TESTS + + LIMIT_TESTS + + TYPE_FILTER_TESTS + + COND_FALSY_TRUTHY_TESTS + + TYPE_STRICT_EQUALITY_TESTS + + NUMERIC_EQUIVALENCE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_filter_insert(collection, test): + """Test $filter with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_errors.py new file mode 100644 index 000000000..eeb90e8f4 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_errors.py @@ -0,0 +1,443 @@ +""" +Error tests for $filter expression. + +Tests non-array input (all BSON types, special numeric values, boundary values) +and limit parameter validation. +Note: $filter propagates null — null input returns null (tested in core_behavior). +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + FILTER_INPUT_NOT_ARRAY_ERROR, + FILTER_LIMIT_NOT_INTEGRAL_ERROR, + FILTER_LIMIT_NOT_POSITIVE_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_MAX, + DECIMAL128_MIN, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT64_MAX, + INT64_MIN, +) + +# Error: non-array input — standard BSON types +NOT_ARRAY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="string_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": "hello"}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject string input", + ), + ExpressionTestCase( + id="int_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": 42}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject int input", + ), + ExpressionTestCase( + id="negative_int_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": -42}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject negative int input", + ), + ExpressionTestCase( + id="bool_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": True}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject bool input", + ), + ExpressionTestCase( + id="bool_false_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": False}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject bool false input", + ), + ExpressionTestCase( + id="object_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": {"a": 1}}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject object input", + ), + ExpressionTestCase( + id="double_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": 3.14}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject double input", + ), + ExpressionTestCase( + id="negative_double_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": -3.14}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject negative double input", + ), + ExpressionTestCase( + id="decimal128_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": Decimal128("1")}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject decimal128 input", + ), + ExpressionTestCase( + id="int64_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": Int64(1)}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject int64 input", + ), + ExpressionTestCase( + id="objectid_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": ObjectId()}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject objectid input", + ), + ExpressionTestCase( + id="datetime_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject datetime input", + ), + ExpressionTestCase( + id="binary_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": Binary(b"x", 0)}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject binary input", + ), + ExpressionTestCase( + id="regex_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": Regex("x")}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject regex input", + ), + ExpressionTestCase( + id="maxkey_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": MaxKey()}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject maxkey input", + ), + ExpressionTestCase( + id="minkey_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": MinKey()}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject minkey input", + ), + ExpressionTestCase( + id="timestamp_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": Timestamp(0, 0)}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject timestamp input", + ), +] + +# Error: special float/Decimal128 values +SPECIAL_NUMERIC_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="nan_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": FLOAT_NAN}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject NaN input", + ), + ExpressionTestCase( + id="inf_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": FLOAT_INFINITY}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject Infinity input", + ), + ExpressionTestCase( + id="neg_inf_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": FLOAT_NEGATIVE_INFINITY}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject -Infinity input", + ), + ExpressionTestCase( + id="neg_zero_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": DOUBLE_NEGATIVE_ZERO}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject negative zero input", + ), + ExpressionTestCase( + id="decimal128_nan_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": DECIMAL128_NAN}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject Decimal128 NaN input", + ), + ExpressionTestCase( + id="decimal128_neg_nan_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": Decimal128("-NaN")}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject Decimal128 -NaN input", + ), + ExpressionTestCase( + id="decimal128_inf_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": DECIMAL128_INFINITY}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject Decimal128 Infinity input", + ), + ExpressionTestCase( + id="decimal128_neg_inf_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": DECIMAL128_NEGATIVE_INFINITY}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject Decimal128 -Infinity input", + ), + ExpressionTestCase( + id="decimal128_neg_zero_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": DECIMAL128_NEGATIVE_ZERO}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject Decimal128 -0 input", + ), +] + +# Error: numeric boundary values +BOUNDARY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="int32_max_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": INT32_MAX}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject INT32_MAX input", + ), + ExpressionTestCase( + id="int32_min_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": INT32_MIN}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject INT32_MIN input", + ), + ExpressionTestCase( + id="int64_max_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": INT64_MAX}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject INT64_MAX input", + ), + ExpressionTestCase( + id="int64_min_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": INT64_MIN}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject INT64_MIN input", + ), + ExpressionTestCase( + id="decimal128_max_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": DECIMAL128_MAX}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject DECIMAL128_MAX input", + ), + ExpressionTestCase( + id="decimal128_min_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": DECIMAL128_MIN}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="Should reject DECIMAL128_MIN input", + ), +] + +# Error: invalid limit types +INVALID_LIMIT_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="string_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": "1"}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="String limit should error", + ), + ExpressionTestCase( + id="bool_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": True}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="Bool limit should error", + ), + ExpressionTestCase( + id="object_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": {"a": 1}}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="Object limit should error", + ), + ExpressionTestCase( + id="array_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": [1]}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="Array limit should error", + ), +] + +# Error: invalid limit numeric values +INVALID_LIMIT_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="zero_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": 0}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_POSITIVE_ERROR, + msg="limit 0 should error", + ), + ExpressionTestCase( + id="neg_int_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": -1}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_POSITIVE_ERROR, + msg="Negative int should error", + ), + ExpressionTestCase( + id="neg_long_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": Int64(-1)}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_POSITIVE_ERROR, + msg="Negative long should error", + ), + ExpressionTestCase( + id="neg_double_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": -1.0}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_POSITIVE_ERROR, + msg="Negative double should error", + ), + ExpressionTestCase( + id="neg_decimal128_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": Decimal128("-1")}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_POSITIVE_ERROR, + msg="Negative decimal128 should error", + ), + ExpressionTestCase( + id="fractional_1_5_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": 1.5}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="Fractional 1.5 should error", + ), + ExpressionTestCase( + id="fractional_dec_0_5_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": Decimal128("0.5")}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="Fractional decimal128 0.5 should error", + ), + ExpressionTestCase( + id="nan_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": float("nan")}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="NaN should error", + ), + ExpressionTestCase( + id="inf_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": float("inf")}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="Infinity should error", + ), + ExpressionTestCase( + id="neg_inf_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": float("-inf")}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="-Infinity should error", + ), + ExpressionTestCase( + id="decimal128_nan_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": Decimal128("NaN")}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="Decimal128 NaN should error", + ), + ExpressionTestCase( + id="decimal128_inf_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": Decimal128("Infinity")}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="Decimal128 Infinity should error", + ), + ExpressionTestCase( + id="neg_zero_double_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": -0.0}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_POSITIVE_ERROR, + msg="-0.0 limit should error", + ), + ExpressionTestCase( + id="neg_zero_decimal128_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": Decimal128("-0")}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_POSITIVE_ERROR, + msg="Decimal128 -0 limit should error", + ), +] + +# Error: cond evaluation errors +COND_EVALUATION_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="cond_divide_by_zero", + expression={"$filter": {"input": "$arr", "cond": {"$divide": [1, 0]}}}, + doc={"arr": [1, 2]}, + error_code=BAD_VALUE_ERROR, + msg="Division by zero in cond should error", + ), +] + +# Aggregate and test +ALL_TESTS = ( + NOT_ARRAY_ERROR_TESTS + + SPECIAL_NUMERIC_ERROR_TESTS + + BOUNDARY_ERROR_TESTS + + INVALID_LIMIT_TYPE_TESTS + + INVALID_LIMIT_NUMERIC_TESTS + + COND_EVALUATION_ERROR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_filter_error_insert(collection, test): + """Test $filter error with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_expressions.py new file mode 100644 index 000000000..a6334924d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_expressions.py @@ -0,0 +1,170 @@ +""" +Expression and field path tests for $filter expression. + +Tests field path lookups, composite paths, system variables, +null/missing propagation via expressions, nested $filter, and +access to outer document fields in cond. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Field path lookups +FIELD_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="nested_field_path", + expression={"$filter": {"input": "$a.b", "cond": {"$gt": ["$$this", 2]}}}, + doc={"a": {"b": [1, 2, 3, 4]}}, + expected=[3, 4], + msg="Should resolve nested field path", + ), + ExpressionTestCase( + id="deeply_nested_field", + expression={"$filter": {"input": "$a.b.c", "cond": True}}, + doc={"a": {"b": {"c": [10, 20]}}}, + expected=[10, 20], + msg="Should resolve deeply nested field path", + ), + ExpressionTestCase( + id="composite_array_path", + expression={"$filter": {"input": "$a.b", "cond": {"$gt": ["$$this", 1]}}}, + doc={"a": [{"b": 1}, {"b": 2}, {"b": 3}]}, + expected=[2, 3], + msg="Composite array path should resolve to array", + ), + ExpressionTestCase( + id="index_path_on_object_key", + expression={"$filter": {"input": "$a.0.b", "cond": True}}, + doc={"a": {"0": {"b": [1, 2, 3]}}}, + expected=[1, 2, 3], + msg="Object key '0' resolves correctly", + ), + ExpressionTestCase( + id="object_key_zero", + expression={"$filter": {"input": "$a.0", "cond": True}}, + doc={"a": {"0": [1, 2, 3]}}, + expected=[1, 2, 3], + msg="$a.0 resolves as field named '0' on object", + ), + ExpressionTestCase( + id="access_outer_field", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", "$threshold"]}}}, + doc={"arr": [1, 2, 3, 4, 5], "threshold": 3}, + expected=[4, 5], + msg="Should access outer document field in cond", + ), +] + +# $let and system variables +LET_AND_VARIABLE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="let_variable", + expression={ + "$let": { + "vars": {"arr": "$values"}, + "in": {"$filter": {"input": "$$arr", "cond": {"$gt": ["$$this", 2]}}}, + } + }, + doc={"values": [1, 2, 3, 4]}, + expected=[3, 4], + msg="Should work with $let variables", + ), + ExpressionTestCase( + id="root_variable", + expression={"$filter": {"input": "$$ROOT.values", "cond": {"$gt": ["$$this", 2]}}}, + doc={"_id": 1, "values": [1, 2, 3, 4]}, + expected=[3, 4], + msg="Should work with $$ROOT", + ), + ExpressionTestCase( + id="current_variable", + expression={"$filter": {"input": "$$CURRENT.values", "cond": {"$gt": ["$$this", 2]}}}, + doc={"_id": 2, "values": [1, 2, 3, 4]}, + expected=[3, 4], + msg="$$CURRENT should be equivalent to field path", + ), +] + +# Null/missing via expression +NULL_MISSING_EXPR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="missing_field", + expression={"$filter": {"input": "$nonexistent", "cond": True}}, + doc={"other": 1}, + expected=None, + msg="Missing field should return null", + ), + ExpressionTestCase( + id="remove_variable", + expression={"$filter": {"input": "$$REMOVE", "cond": True}}, + doc={"x": 1}, + expected=None, + msg="$$REMOVE propagates null", + ), +] + +# Nested $filter +NESTED_FILTER_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="filter_then_filter", + expression={ + "$filter": { + "input": {"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 1]}}}, + "cond": {"$lt": ["$$this", 5]}, + } + }, + doc={"arr": [1, 2, 3, 4, 5, 6]}, + expected=[2, 3, 4], + msg="Nested $filter should chain conditions", + ), +] + + +# Limit with field reference +LIMIT_EXPR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="limit_from_field", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 0]}, "limit": "$n"}}, + doc={"arr": [1, 2, 3, 4, 5], "n": 2}, + expected=[1, 2], + msg="Limit from field reference", + ), +] + +# Literal array input (not field path) +LITERAL_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="literal_array_input", + expression={"$filter": {"input": [1, 2, 3, 4, 5], "cond": {"$gt": ["$$this", 3]}}}, + doc={"x": 1}, + expected=[4, 5], + msg="Should filter literal array input", + ), +] + +# Aggregate and test +ALL_EXPR_TESTS = ( + FIELD_LOOKUP_TESTS + + LET_AND_VARIABLE_TESTS + + NULL_MISSING_EXPR_TESTS + + NESTED_FILTER_TESTS + + LIMIT_EXPR_TESTS + + LITERAL_INPUT_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_EXPR_TESTS)) +def test_filter_expression(collection, test): + """Test $filter with field paths and expressions.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_structure_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_structure_errors.py new file mode 100644 index 000000000..dbf39baa9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_structure_errors.py @@ -0,0 +1,105 @@ +""" +Structural error tests for $filter expression. + +Tests invalid $filter argument structure: non-object argument, unknown/misspelled fields, +and missing required fields (input, cond). +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.error_codes import ( + FILTER_MISSING_COND_ERROR, + FILTER_MISSING_INPUT_ERROR, + FILTER_NON_OBJECT_ARG_ERROR, + FILTER_UNKNOWN_FIELD_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Error: non-object argument +NON_OBJECT_ARG_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="null_arg", + expression={"$filter": None}, + error_code=FILTER_NON_OBJECT_ARG_ERROR, + msg="Null arg should error", + ), + ExpressionTestCase( + id="int_arg", + expression={"$filter": 1}, + error_code=FILTER_NON_OBJECT_ARG_ERROR, + msg="Int arg should error", + ), + ExpressionTestCase( + id="string_arg", + expression={"$filter": "string"}, + error_code=FILTER_NON_OBJECT_ARG_ERROR, + msg="String arg should error", + ), + ExpressionTestCase( + id="array_arg", + expression={"$filter": []}, + error_code=FILTER_NON_OBJECT_ARG_ERROR, + msg="Array arg should error", + ), + ExpressionTestCase( + id="bool_arg", + expression={"$filter": True}, + error_code=FILTER_NON_OBJECT_ARG_ERROR, + msg="Bool arg should error", + ), +] + +# Error: unknown fields +UNKNOWN_FIELD_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="extra_unknown", + expression={"$filter": {"input": [1], "cond": True, "unknown": 1}}, + error_code=FILTER_UNKNOWN_FIELD_ERROR, + msg="Extra unknown field should error", + ), + ExpressionTestCase( + id="only_unknown", + expression={"$filter": {"dummy": 124}}, + error_code=FILTER_UNKNOWN_FIELD_ERROR, + msg="Only unknown field should error", + ), +] + +# Error: missing required fields +MISSING_REQUIRED_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="missing_input", + expression={"$filter": {"as": "x", "cond": True}}, + error_code=FILTER_MISSING_INPUT_ERROR, + msg="Missing input should error", + ), + ExpressionTestCase( + id="missing_cond", + expression={"$filter": {"input": [1, 2, 3]}}, + error_code=FILTER_MISSING_COND_ERROR, + msg="Missing cond should error", + ), + ExpressionTestCase( + id="empty_object", + expression={"$filter": {}}, + error_code=FILTER_MISSING_INPUT_ERROR, + msg="Empty object should error", + ), +] + +# Aggregate and test +ALL_STRUCTURE_TESTS = NON_OBJECT_ARG_TESTS + UNKNOWN_FIELD_TESTS + MISSING_REQUIRED_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_STRUCTURE_TESTS)) +def test_filter_structure_error(collection, test): + """Test $filter argument structure validation.""" + result = execute_expression(collection, test.expression) + assert_expression_result(result, error_code=test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_smoke_expression_filter.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_smoke_filter.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_smoke_expression_filter.py rename to documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_smoke_filter.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_bson_types.py new file mode 100644 index 000000000..1a6cdb9a3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_bson_types.py @@ -0,0 +1,284 @@ +""" +BSON type and numeric equivalence tests for $in expression. + +Tests searching for various BSON types and cross-type numeric matching. +""" + +from datetime import datetime, timezone +from uuid import UUID + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Success: search for various BSON types +BSON_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="bson_int64", + doc={"val": Int64(99), "arr": [Int64(99), 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find Int64 in array", + ), + ExpressionTestCase( + id="bson_decimal128", + doc={"val": Decimal128("1.5"), "arr": [Decimal128("1.5"), 2]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find Decimal128 in array", + ), + ExpressionTestCase( + id="bson_datetime", + doc={ + "val": datetime(2024, 1, 1, tzinfo=timezone.utc), + "arr": [datetime(2024, 1, 1, tzinfo=timezone.utc), 1], + }, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find datetime in array", + ), + ExpressionTestCase( + id="bson_objectid", + doc={ + "val": ObjectId("000000000000000000000001"), + "arr": [ObjectId("000000000000000000000001"), 1], + }, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find ObjectId in array", + ), + ExpressionTestCase( + id="bson_binary", + doc={"val": Binary(b"\x01\x02", 0), "arr": [Binary(b"\x01\x02", 0), 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find Binary in array", + ), + ExpressionTestCase( + id="bson_regex", + doc={"val": Regex("^abc", "i"), "arr": [Regex("^abc", "i"), 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find Regex in array", + ), + ExpressionTestCase( + id="bson_timestamp", + doc={"val": Timestamp(1, 1), "arr": [Timestamp(1, 1), 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find Timestamp in array", + ), + ExpressionTestCase( + id="bson_minkey", + doc={"val": MinKey(), "arr": [MinKey(), 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find MinKey in array", + ), + ExpressionTestCase( + id="bson_maxkey", + doc={"val": MaxKey(), "arr": [1, MaxKey()]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find MaxKey in array", + ), + ExpressionTestCase( + id="bson_uuid", + doc={ + "val": Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), + "arr": [Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), 1], + }, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find UUID binary in array", + ), + # Special float values + ExpressionTestCase( + id="float_infinity_in_array", + doc={"val": FLOAT_INFINITY, "arr": [FLOAT_INFINITY, 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find Infinity in array", + ), + ExpressionTestCase( + id="float_neg_infinity_in_array", + doc={"val": FLOAT_NEGATIVE_INFINITY, "arr": [FLOAT_NEGATIVE_INFINITY, 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find -Infinity in array", + ), + ExpressionTestCase( + id="float_infinity_not_in_array", + doc={"val": FLOAT_INFINITY, "arr": [1, 2, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="Should not find Infinity in non-Infinity array", + ), + # Special Decimal128 values + ExpressionTestCase( + id="decimal128_infinity_in_array", + doc={"val": DECIMAL128_INFINITY, "arr": [DECIMAL128_INFINITY, 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find Decimal128 Infinity in array", + ), + ExpressionTestCase( + id="decimal128_neg_infinity_in_array", + doc={"val": DECIMAL128_NEGATIVE_INFINITY, "arr": [DECIMAL128_NEGATIVE_INFINITY, 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find Decimal128 -Infinity in array", + ), + # NaN equality: NaN == NaN in BSON comparison (unlike IEEE 754) + ExpressionTestCase( + id="float_nan_found", + doc={"val": FLOAT_NAN, "arr": [FLOAT_NAN, 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find NaN in array (BSON equality)", + ), + ExpressionTestCase( + id="decimal128_nan_found", + doc={"val": DECIMAL128_NAN, "arr": [DECIMAL128_NAN, 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find Decimal128 NaN in array (BSON equality)", + ), + ExpressionTestCase( + id="float_nan_matches_decimal128_nan", + doc={"val": FLOAT_NAN, "arr": [DECIMAL128_NAN, 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="float NaN should match Decimal128 NaN cross-type", + ), + ExpressionTestCase( + id="decimal128_nan_matches_float_nan", + doc={"val": DECIMAL128_NAN, "arr": [FLOAT_NAN, 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Decimal128 NaN should match float NaN cross-type", + ), + # Aggregation $in does NOT pattern-match regex against strings (unlike query $in) + ExpressionTestCase( + id="regex_no_pattern_match", + doc={"val": Regex("^a"), "arr": ["abc", "def"]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="Regex should not pattern-match strings in aggregation $in", + ), +] + +# Success: numeric type equivalence +NUMERIC_EQUIVALENCE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="int_in_doubles", + doc={"val": 1, "arr": [1.0, 2.0]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find int in doubles via numeric equivalence", + ), + ExpressionTestCase( + id="int_in_longs", + doc={"val": 1, "arr": [Int64(1), 2]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find int in longs via numeric equivalence", + ), + ExpressionTestCase( + id="int_in_decimal128s", + doc={"val": 1, "arr": [Decimal128("1"), 2]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find int in decimal128s via numeric equivalence", + ), + ExpressionTestCase( + id="double_in_ints", + doc={"val": 1.0, "arr": [1, 2]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find double in ints via numeric equivalence", + ), + ExpressionTestCase( + id="long_in_ints", + doc={"val": Int64(1), "arr": [1, 2]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find long in ints via numeric equivalence", + ), + ExpressionTestCase( + id="decimal128_in_ints", + doc={"val": Decimal128("1"), "arr": [1, 2]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find decimal128 in ints via numeric equivalence", + ), + ExpressionTestCase( + id="decimal128_in_doubles", + doc={"val": Decimal128("1.5"), "arr": [1.5, 2.5]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find decimal128 in doubles via numeric equivalence", + ), +] + +# Negative zero equivalence with positive zero +NEGATIVE_ZERO_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="double_neg_zero_matches_zero", + doc={"val": DOUBLE_NEGATIVE_ZERO, "arr": [0, 1, 2]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should treat -0.0 as equivalent to 0", + ), + ExpressionTestCase( + id="zero_matches_double_neg_zero_in_array", + doc={"val": 0, "arr": [DOUBLE_NEGATIVE_ZERO, 1, 2]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find 0 in array containing -0.0", + ), + ExpressionTestCase( + id="decimal128_neg_zero_matches_zero", + doc={"val": DECIMAL128_NEGATIVE_ZERO, "arr": [0, 1, 2]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should treat Decimal128 -0 as equivalent to 0", + ), + ExpressionTestCase( + id="zero_matches_decimal128_neg_zero_in_array", + doc={"val": 0, "arr": [DECIMAL128_NEGATIVE_ZERO, 1, 2]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find 0 in array containing Decimal128 -0", + ), +] + +# Aggregate and test +ALL_TESTS = BSON_TYPE_TESTS + NUMERIC_EQUIVALENCE_TESTS + NEGATIVE_ZERO_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_in_bson_insert(collection, test): + """Test $in BSON type matching with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_core_behavior.py new file mode 100644 index 000000000..007927bb6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_core_behavior.py @@ -0,0 +1,265 @@ +""" +Core behavior tests for $in expression. + +Tests value found/not found, mixed types, and large arrays. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Success: value found in array → True +FOUND_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="found_int", + doc={"val": 2, "arr": [1, 2, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find int in array", + ), + ExpressionTestCase( + id="found_first", + doc={"val": 1, "arr": [1, 2, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find first element", + ), + ExpressionTestCase( + id="found_last", + doc={"val": 3, "arr": [1, 2, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find last element", + ), + ExpressionTestCase( + id="found_string", + doc={"val": "b", "arr": ["a", "b", "c"]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find string in array", + ), + ExpressionTestCase( + id="found_bool_true", + doc={"val": True, "arr": [True, False]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find true in array", + ), + ExpressionTestCase( + id="found_bool_false", + doc={"val": False, "arr": [True, False]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find false in array", + ), + ExpressionTestCase( + id="found_null", + doc={"val": None, "arr": [None, 1, 2]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find null in array", + ), + ExpressionTestCase( + id="found_nested_array", + doc={"val": [3, 4], "arr": [[1, 2], [3, 4]]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find nested array", + ), + ExpressionTestCase( + id="found_object", + doc={"val": {"a": 1}, "arr": [{"a": 1}, {"b": 2}]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find object in array", + ), + ExpressionTestCase( + id="found_single_element", + doc={"val": 42, "arr": [42]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find value in single-element array", + ), + ExpressionTestCase( + id="found_duplicate", + doc={"val": 5, "arr": [5, 5, 5]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find value in array of duplicates", + ), +] + +# Success: value not found → False +NOT_FOUND_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="not_found_int", + doc={"val": 4, "arr": [1, 2, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="Should not find absent int", + ), + ExpressionTestCase( + id="not_found_string", + doc={"val": "z", "arr": ["a", "b"]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="Should not find absent string", + ), + ExpressionTestCase( + id="not_found_empty_array", + doc={"val": 1, "arr": []}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="Should not find value in empty array", + ), + ExpressionTestCase( + id="not_found_type_mismatch", + doc={"val": "1", "arr": [1, 2, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="Should not find string '1' in int array", + ), + ExpressionTestCase( + id="not_found_bool_vs_int", + doc={"val": True, "arr": [1, 0]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="Should not find bool in int array", + ), + ExpressionTestCase( + id="not_found_null", + doc={"val": None, "arr": [1, 2, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="Should not find null in non-null array", + ), + ExpressionTestCase( + id="not_found_partial_array", + doc={"val": [1], "arr": [[1, 2], [3, 4]]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="Should not find partial array match", + ), + ExpressionTestCase( + id="not_found_partial_object", + doc={"val": {"a": 1}, "arr": [{"a": 1, "b": 2}]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="Should not find partial object match", + ), +] + +# Success: mixed types in array +MIXED_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="mixed_find_string", + doc={"val": "2", "arr": [1, "2", True, None, [1]]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find string in mixed-type array", + ), + ExpressionTestCase( + id="mixed_find_null", + doc={"val": None, "arr": [1, "2", True, None, [1]]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find null in mixed-type array", + ), + ExpressionTestCase( + id="mixed_find_array", + doc={"val": [1], "arr": [1, "2", True, None, [1]]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find array in mixed-type array", + ), + ExpressionTestCase( + id="mixed_not_found", + doc={"val": "x", "arr": [1, "2", True, None, [1]]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="Should not find absent value in mixed-type array", + ), +] + +# Success: large array + +LARGE_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="large_array_found_first", + doc={"val": 0, "arr": list(range(20_000))}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find first element in large array", + ), + ExpressionTestCase( + id="large_array_found_last", + doc={"val": 19_999, "arr": list(range(20_000))}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find last element in large array", + ), + ExpressionTestCase( + id="large_array_found_middle", + doc={"val": 10_000, "arr": list(range(20_000))}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find middle element in large array", + ), + ExpressionTestCase( + id="large_array_not_found", + doc={"val": -1, "arr": list(range(20_000))}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="Should not find absent value in large array", + ), +] + +# Aggregate and test +# Property [Literal Evaluation]: $in evaluates correctly with inline literal values. +TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_found_int", + doc=None, + expression={"$in": [2, {"$literal": [1, 2, 3]}]}, + expected=True, + msg="$in should find int in literal array", + ), + ExpressionTestCase( + "literal_not_found_int", + doc=None, + expression={"$in": [4, {"$literal": [1, 2, 3]}]}, + expected=False, + msg="$in should not find absent int in literal array", + ), + ExpressionTestCase( + "literal_large_array_found", + doc=None, + expression={"$in": [0, {"$literal": list(range(20_000))}]}, + expected=True, + msg="$in should find value in large literal array", + ), +] + +ALL_TESTS = ( + FOUND_TESTS + NOT_FOUND_TESTS + MIXED_TYPE_TESTS + LARGE_ARRAY_TESTS + TEST_SUBSET_FOR_LITERAL +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_in_insert(collection, test): + """Test $in with values from inserted documents.""" + if test.doc is None: + result = execute_expression(collection, test.expression) + else: + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_errors.py new file mode 100644 index 000000000..90a1acb26 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_errors.py @@ -0,0 +1,182 @@ +""" +Error tests for $in expression. + +Tests non-array second argument and wrong arity errors. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + EXPRESSION_IN_NOT_ARRAY_ERROR, + EXPRESSION_TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import MISSING + +# Error: second argument not an array (runs both literal and insert) +NOT_ARRAY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="string_as_array", + doc={"val": 1, "arr": "hello"}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="Should reject string as array arg", + ), + ExpressionTestCase( + id="int_as_array", + doc={"val": 1, "arr": 42}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="Should reject int as array arg", + ), + ExpressionTestCase( + id="double_as_array", + doc={"val": 1, "arr": 3.14}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="Should reject double as array arg", + ), + ExpressionTestCase( + id="bool_true_as_array", + doc={"val": 1, "arr": True}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="Should reject bool true as array arg", + ), + ExpressionTestCase( + id="bool_false_as_array", + doc={"val": 1, "arr": False}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="Should reject bool false as array arg", + ), + ExpressionTestCase( + id="object_as_array", + doc={"val": 1, "arr": {"a": 1}}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="Should reject object as array arg", + ), + ExpressionTestCase( + id="decimal128_as_array", + doc={"val": 1, "arr": Decimal128("1")}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="Should reject decimal128 as array arg", + ), + ExpressionTestCase( + id="int64_as_array", + doc={"val": 1, "arr": Int64(1)}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="Should reject int64 as array arg", + ), + ExpressionTestCase( + id="binary_as_array", + doc={"val": 1, "arr": Binary(b"x", 0)}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="Should reject binary as array arg", + ), + ExpressionTestCase( + id="datetime_as_array", + doc={"val": 1, "arr": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="Should reject datetime as array arg", + ), + ExpressionTestCase( + id="objectid_as_array", + doc={"val": 1, "arr": ObjectId()}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="Should reject objectid as array arg", + ), + ExpressionTestCase( + id="regex_as_array", + doc={"val": 1, "arr": Regex("x")}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="Should reject regex as array arg", + ), + ExpressionTestCase( + id="maxkey_as_array", + doc={"val": 1, "arr": MaxKey()}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="Should reject maxkey as array arg", + ), + ExpressionTestCase( + id="minkey_as_array", + doc={"val": 1, "arr": MinKey()}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="Should reject minkey as array arg", + ), + ExpressionTestCase( + id="timestamp_as_array", + doc={"val": 1, "arr": Timestamp(0, 0)}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="Should reject timestamp as array arg", + ), + ExpressionTestCase( + id="null_as_array", + doc={"val": 1, "arr": None}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="Should reject null as array arg", + ), +] + +# Error: missing as array (literal only, MISSING is a field ref) +LITERAL_ONLY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="missing_as_array", + doc={"val": 1, "arr": MISSING}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="Should reject missing as array arg", + ), +] + +# Aggregate and test +TEST_SUBSET_FOR_LITERAL = [ + NOT_ARRAY_ERROR_TESTS[0], # string_as_array + NOT_ARRAY_ERROR_TESTS[-1], # null_as_array +] + LITERAL_ONLY_TESTS + + +@pytest.mark.parametrize("test", pytest_params(NOT_ARRAY_ERROR_TESTS)) +def test_in_insert(collection, test): + """Test $in error cases with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +# Error: wrong arity +ARITY_ERROR_TESTS = [ + pytest.param({"$in": []}, id="zero_args"), + pytest.param({"$in": [[1, 2, 3]]}, id="one_arg"), + pytest.param({"$in": [1, [1, 2], 3]}, id="three_args"), +] + + +@pytest.mark.parametrize("expr", ARITY_ERROR_TESTS) +def test_in_arity_error(collection, expr): + """Test $in errors with wrong number of arguments.""" + result = execute_expression(collection, expr) + assert_expression_result(result, error_code=EXPRESSION_TYPE_MISMATCH_ERROR) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_expressions.py new file mode 100644 index 000000000..bdf92b5e6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_expressions.py @@ -0,0 +1,86 @@ +""" +Expression and field path tests for $in expression. + +Tests nested expressions, field path lookups, composite paths, +and non-existent field handling. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import EXPRESSION_IN_NOT_ARRAY_ERROR + + +# Nested expressions +@pytest.mark.parametrize( + "expression,expected", + [ + # Nested $in: result of inner $in used as search value in outer $in + ({"$in": [{"$in": [2, [1, 2, 3]]}, [True, False]]}, True), + ], + ids=["nested_in_in"], +) +def test_in_nested_expression(collection, expression, expected): + """Test $in composed with other expressions.""" + result = execute_expression(collection, expression) + assert_expression_result(result, expected=expected) + + +# Field path lookups +@pytest.mark.parametrize( + "document,value,array_ref,expected", + [ + ({"a": {"b": [10, 20, 30]}}, 20, "$a.b", True), + ({"a": {"b": [10, 20, 30]}}, 99, "$a.b", False), + ({"a": {"b": {"c": [5, 6, 7]}}}, 7, "$a.b.c", True), + ], + ids=["nested_field_found", "nested_field_not_found", "deeply_nested_field"], +) +def test_in_field_lookup(collection, document, value, array_ref, expected): + """Test $in with field path lookups from inserted documents.""" + result = execute_expression_with_insert(collection, {"$in": [value, array_ref]}, document) + assert_expression_result(result, expected=expected) + + +# Non-existent field as array → error (missing resolves to non-array) +def test_in_nonexistent_array_field(collection): + """Test $in where array field does not exist (resolves to missing).""" + result = execute_expression_with_insert(collection, {"$in": [1, "$nonexistent"]}, {"other": 1}) + assert_expression_result(result, error_code=EXPRESSION_IN_NOT_ARRAY_ERROR) + + +# Non-existent field as value (resolves to missing/null) +@pytest.mark.parametrize( + "document,expected", + [ + ({"arr": [1, None, 3]}, False), + ({"arr": [1, 2, 3]}, False), + ], + ids=["array_contains_null", "array_without_null"], +) +def test_in_nonexistent_value_field(collection, document, expected): + """Test $in where value field does not exist (missing vs null).""" + result = execute_expression_with_insert(collection, {"$in": ["$nonexistent", "$arr"]}, document) + assert_expression_result(result, expected=expected) + + +def test_in_composite_array_as_array(collection): + """Test $in with composite array from $x.y as the array argument.""" + result = execute_expression_with_insert( + collection, {"$in": [20, "$x.y"]}, {"x": [{"y": 10}, {"y": 20}, {"y": 30}]} + ) + assert_expression_result(result, expected=True) + + +def test_in_composite_array_as_value(collection): + """Test $in with composite array from $x.y as the search value.""" + result = execute_expression_with_insert( + collection, + {"$in": ["$x.y", [[10, 20, 30], "other"]]}, + {"x": [{"y": 10}, {"y": 20}, {"y": 30}]}, + ) + assert_expression_result(result, expected=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_nested_arrays.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_nested_arrays.py new file mode 100644 index 000000000..c302631d8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_nested_arrays.py @@ -0,0 +1,198 @@ +""" +Nested array search tests for $in expression. + +Tests searching for complex elements in nested mixed arrays and deeply nested structures. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Decimal128, MaxKey, MinKey, ObjectId + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Success: nested mixed arrays as search targets +NESTED_MIXED_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="nested_find_object_in_mixed", + doc={"val": {"a": 1}, "arr": [1, "two", {"a": 1}, [3, 4], True]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find object in nested mixed array", + ), + ExpressionTestCase( + id="nested_find_array_in_mixed", + doc={"val": [3, 4], "arr": [1, "two", {"a": 1}, [3, 4], True]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find array in nested mixed array", + ), + ExpressionTestCase( + id="nested_find_deep_object", + doc={"val": {"a": {"b": 3}}, "arr": [[1, 2], {"a": {"b": 3}}, "x"]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find deep object in array", + ), + ExpressionTestCase( + id="nested_find_array_with_mixed_types", + doc={"val": [None, "a", 2], "arr": [1, [None, "a", 2], "b"]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find mixed-type subarray", + ), + ExpressionTestCase( + id="nested_find_empty_object", + doc={"val": {}, "arr": [1, {}, [2], "a"]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find empty object in array", + ), + ExpressionTestCase( + id="nested_find_empty_array", + doc={"val": [], "arr": [1, {}, [], "a"]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find empty array in array", + ), + ExpressionTestCase( + id="nested_find_subarray_binary_decimal128", + doc={ + "val": [Binary(b"\x01\x02", 0), Decimal128("3.14")], + "arr": [1, [Binary(b"\x01\x02", 0), Decimal128("3.14")], "x", [3]], + }, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find subarray with binary and decimal128", + ), + ExpressionTestCase( + id="nested_find_subarray_object_array", + doc={"val": [{"k": 1}, [2, 3]], "arr": ["a", [{"k": 1}, [2, 3]], None, 4]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find subarray with object and array", + ), + ExpressionTestCase( + id="nested_find_subarray_datetime_objectid", + doc={ + "val": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + ObjectId("000000000000000000000001"), + ], + "arr": [ + 0, + [datetime(2024, 1, 1, tzinfo=timezone.utc), ObjectId("000000000000000000000001")], + "end", + ], + }, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find subarray with datetime and objectid", + ), + ExpressionTestCase( + id="nested_find_subarray_minkey_maxkey", + doc={"val": [MinKey(), MaxKey()], "arr": [[MinKey(), MaxKey()], 1, "a"]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find subarray with minkey and maxkey", + ), +] + +# Success: deeply nested search targets (3-5 levels) +DEEPLY_NESTED_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="nested_3_levels", + doc={"val": [[2, 3], [4, 5]], "arr": [1, [[2, 3], [4, 5]], "end"]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find 3-level nested array", + ), + ExpressionTestCase( + id="nested_4_levels", + doc={"val": [[[1, 2], 3], 4], "arr": ["a", [[[1, 2], 3], 4], None]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find 4-level nested array", + ), + ExpressionTestCase( + id="nested_deep_mixed_bson", + doc={ + "val": [[MinKey(), {"a": [Decimal128("1.5")]}], True], + "arr": [0, [[MinKey(), {"a": [Decimal128("1.5")]}], True], "x"], + }, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find deeply nested mixed BSON", + ), + ExpressionTestCase( + id="nested_inner_not_outer", + doc={"val": [2, 3], "arr": [[1, [2, 3]], [2, 3], 4]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find inner array match", + ), + ExpressionTestCase( + id="nested_5_levels", + doc={"val": [[[[99]]]], "arr": [[[[[99]]]], "other"]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find 5-level nested array", + ), + ExpressionTestCase( + id="nested_deep_not_found", + doc={"val": [2, 3], "arr": [[1, [2, 3]], [4, 5]]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="Should not find array at wrong nesting level", + ), +] + +# Aggregate and test +# Property [Literal Evaluation]: $in nested array matching works with inline literal values. +TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_nested_find_object_in_mixed", + doc=None, + expression={"$in": [{"a": 1}, {"$literal": [1, "two", {"a": 1}, [3, 4], True]}]}, + expected=True, + msg="$in should find object in literal nested mixed array", + ), + ExpressionTestCase( + "literal_nested_3_levels", + doc=None, + expression={ + "$in": [{"$literal": [[2, 3], [4, 5]]}, {"$literal": [1, [[2, 3], [4, 5]], "end"]}] + }, + expected=True, + msg="$in should find 3-level nested array in literal", + ), + ExpressionTestCase( + "literal_nested_deep_not_found", + doc=None, + expression={"$in": [{"$literal": [2, 3]}, {"$literal": [[1, [2, 3]], [4, 5]]}]}, + expected=False, + msg="$in should not find array at wrong nesting level in literal", + ), +] + +ALL_TESTS = NESTED_MIXED_ARRAY_TESTS + DEEPLY_NESTED_TESTS + TEST_SUBSET_FOR_LITERAL + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_in_nested_insert(collection, test): + """Test $in nested array matching with values from inserted documents.""" + if test.doc is None: + result = execute_expression(collection, test.expression) + else: + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_null_missing.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_null_missing.py new file mode 100644 index 000000000..292fb5b26 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_null_missing.py @@ -0,0 +1,66 @@ +""" +Null and missing field handling tests for $in expression. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import MISSING + +# Success: null/missing handling (runs both literal and insert) +NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="null_value_in_array", + doc={"val": None, "arr": [1, None, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="Should find null value in array containing null", + ), + ExpressionTestCase( + id="null_value_not_in_array", + doc={"val": None, "arr": [1, 2, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="Should not find null in array without null", + ), +] + +# Success: missing value handling (literal only, MISSING is a field ref) +LITERAL_ONLY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="missing_value", + doc={"val": MISSING, "arr": [1, 2, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="Should not find missing value in array", + ), + ExpressionTestCase( + id="missing_value_null_in_array", + doc={"val": MISSING, "arr": [1, None, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="Should not find missing value even with null in array", + ), +] + +# Aggregate and test +TEST_SUBSET_FOR_LITERAL = [ + NULL_TESTS[0], # null_value_in_array + NULL_TESTS[1], # null_value_not_in_array +] + LITERAL_ONLY_TESTS + + +@pytest.mark.parametrize("test", pytest_params(NULL_TESTS)) +def test_in_insert(collection, test): + """Test $in null with values from inserted documents.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_smoke_expression_in.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_smoke_in.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_smoke_expression_in.py rename to documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_smoke_in.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_bson_types.py new file mode 100644 index 000000000..5c19daf72 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_bson_types.py @@ -0,0 +1,416 @@ +""" +BSON type search and nested array tests for $indexOfArray expression. + +Tests searching for various BSON types, numeric equivalence, and +complex nested mixed arrays. +""" + +from datetime import datetime, timezone +from uuid import UUID + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.array.indexOfArray.utils.indexOfArray_common import ( # noqa: E501 + IndexOfArrayTest, + build_args, + build_insert_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Success: search for various BSON types +BSON_TYPE_SEARCH_TESTS: list[IndexOfArrayTest] = [ + IndexOfArrayTest( + id="search_int64", + array=[Int64(99), 1], + search=Int64(99), + expected=0, + msg="Should find Int64 value", + ), + IndexOfArrayTest( + id="search_decimal128", + array=[Decimal128("1.5"), 2], + search=Decimal128("1.5"), + expected=0, + msg="Should find Decimal128 value", + ), + IndexOfArrayTest( + id="search_datetime", + array=[datetime(2024, 1, 1, tzinfo=timezone.utc), 1], + search=datetime(2024, 1, 1, tzinfo=timezone.utc), + expected=0, + msg="Should find datetime value", + ), + IndexOfArrayTest( + id="search_objectid", + array=[ObjectId("000000000000000000000001"), 1], + search=ObjectId("000000000000000000000001"), + expected=0, + msg="Should find ObjectId value", + ), + IndexOfArrayTest( + id="search_binary", + array=[Binary(b"\x01\x02", 0), 1], + search=Binary(b"\x01\x02", 0), + expected=0, + msg="Should find Binary value", + ), + IndexOfArrayTest( + id="search_regex", + array=[Regex("^abc", "i"), 1], + search=Regex("^abc", "i"), + expected=0, + msg="Should find Regex value", + ), + IndexOfArrayTest( + id="search_timestamp", + array=[Timestamp(1, 1), 1], + search=Timestamp(1, 1), + expected=0, + msg="Should find Timestamp value", + ), + IndexOfArrayTest( + id="search_minkey", + array=[MinKey(), 1], + search=MinKey(), + expected=0, + msg="Should find MinKey value", + ), + IndexOfArrayTest( + id="search_maxkey", + array=[1, MaxKey()], + search=MaxKey(), + expected=1, + msg="Should find MaxKey value", + ), + IndexOfArrayTest( + id="search_uuid", + array=[Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), 1], + search=Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), + expected=0, + msg="Should find UUID binary value", + ), + # Special float values + IndexOfArrayTest( + id="search_infinity", + array=[FLOAT_INFINITY, 1], + search=FLOAT_INFINITY, + expected=0, + msg="Should find Infinity", + ), + IndexOfArrayTest( + id="search_neg_infinity", + array=[FLOAT_NEGATIVE_INFINITY, 1], + search=FLOAT_NEGATIVE_INFINITY, + expected=0, + msg="Should find -Infinity", + ), + IndexOfArrayTest( + id="search_infinity_not_found", + array=[1, 2, 3], + search=FLOAT_INFINITY, + expected=-1, + msg="Should not find Infinity in regular array", + ), + # Special Decimal128 values + IndexOfArrayTest( + id="search_decimal128_infinity", + array=[DECIMAL128_INFINITY, 1], + search=DECIMAL128_INFINITY, + expected=0, + msg="Should find Decimal128 Infinity", + ), + IndexOfArrayTest( + id="search_decimal128_neg_infinity", + array=[DECIMAL128_NEGATIVE_INFINITY, 1], + search=DECIMAL128_NEGATIVE_INFINITY, + expected=0, + msg="Should find Decimal128 -Infinity", + ), + # NaN equality: NaN == NaN in BSON comparison (unlike IEEE 754) + IndexOfArrayTest( + id="search_nan_found", + array=[FLOAT_NAN, 1], + search=FLOAT_NAN, + expected=0, + msg="Should find NaN (BSON equality)", + ), + IndexOfArrayTest( + id="search_decimal128_nan_found", + array=[DECIMAL128_NAN, 1], + search=DECIMAL128_NAN, + expected=0, + msg="Should find Decimal128 NaN (BSON equality)", + ), + # Cross-type NaN matching + IndexOfArrayTest( + id="search_decimal128_nan_matches_float_nan", + array=[FLOAT_NAN, 1], + search=DECIMAL128_NAN, + expected=0, + msg="Decimal128 NaN should match float NaN cross-type", + ), + IndexOfArrayTest( + id="search_decimal128_neg_nan_matches_nan", + array=[DECIMAL128_NAN, 1], + search=Decimal128("-NaN"), + expected=0, + msg="Decimal128 -NaN should match Decimal128 NaN", + ), +] + +# Success: numeric type equivalence in search +NUMERIC_EQUIVALENCE_TESTS: list[IndexOfArrayTest] = [ + IndexOfArrayTest( + id="int_matches_double", + array=[1.0, 2.0], + search=1, + expected=0, + msg="Should match int to double", + ), + IndexOfArrayTest( + id="int_matches_long", + array=[Int64(1), 2], + search=1, + expected=0, + msg="Should match int to long", + ), + IndexOfArrayTest( + id="int_matches_decimal128", + array=[Decimal128("1"), 2], + search=1, + expected=0, + msg="Should match int to decimal128", + ), + IndexOfArrayTest( + id="double_matches_int", + array=[1, 2], + search=1.0, + expected=0, + msg="Should match double to int", + ), + IndexOfArrayTest( + id="long_matches_int", + array=[1, 2], + search=Int64(1), + expected=0, + msg="Should match long to int", + ), + IndexOfArrayTest( + id="decimal128_matches_int", + array=[1, 2], + search=Decimal128("1"), + expected=0, + msg="Should match decimal128 to int", + ), +] + +# Success: nested mixed arrays +NESTED_MIXED_ARRAY_TESTS: list[IndexOfArrayTest] = [ + IndexOfArrayTest( + id="nested_find_object_in_mixed", + array=[1, "two", {"a": 1}, [3, 4], True], + search={"a": 1}, + expected=2, + msg="Should find object in mixed array", + ), + IndexOfArrayTest( + id="nested_find_array_in_mixed", + array=[1, "two", {"a": 1}, [3, 4], True], + search=[3, 4], + expected=3, + msg="Should find array in mixed array", + ), + IndexOfArrayTest( + id="nested_find_bool_in_mixed", + array=[1, "two", {"a": 1}, [3, 4], True], + search=True, + expected=4, + msg="Should find bool in mixed array", + ), + IndexOfArrayTest( + id="nested_find_null_in_mixed", + array=[1, None, "three", [4], {"b": 2}], + search=None, + expected=1, + msg="Should find null in mixed array", + ), + IndexOfArrayTest( + id="nested_find_deep_object", + array=[[1, 2], {"a": {"b": 3}}, "x"], + search={"a": {"b": 3}}, + expected=1, + msg="Should find deep object", + ), + IndexOfArrayTest( + id="nested_find_array_with_mixed_types", + array=[1, [None, "a", 2], "b"], + search=[None, "a", 2], + expected=1, + msg="Should find mixed-type subarray", + ), + IndexOfArrayTest( + id="nested_find_empty_object_in_mixed", + array=[1, {}, [2], "a"], + search={}, + expected=1, + msg="Should find empty object", + ), + IndexOfArrayTest( + id="nested_find_empty_array_in_mixed", + array=[1, {}, [], "a"], + search=[], + expected=2, + msg="Should find empty array", + ), + IndexOfArrayTest( + id="nested_find_datetime_in_mixed", + array=["a", datetime(2024, 1, 1, tzinfo=timezone.utc), 3, [4]], + search=datetime(2024, 1, 1, tzinfo=timezone.utc), + expected=1, + msg="Should find datetime in mixed array", + ), + IndexOfArrayTest( + id="nested_find_objectid_in_mixed", + array=[1, ObjectId("000000000000000000000001"), "x", [2]], + search=ObjectId("000000000000000000000001"), + expected=1, + msg="Should find objectid in mixed array", + ), + IndexOfArrayTest( + id="nested_find_decimal128_in_mixed", + array=["a", [1], Decimal128("3.14"), None], + search=Decimal128("3.14"), + expected=2, + msg="Should find decimal128 in mixed array", + ), + IndexOfArrayTest( + id="nested_find_binary_in_mixed", + array=[1, Binary(b"\x01\x02", 0), "x", [3]], + search=Binary(b"\x01\x02", 0), + expected=1, + msg="Should find binary in mixed array", + ), + IndexOfArrayTest( + id="nested_find_subarray_binary_decimal128", + array=[1, [Binary(b"\x01\x02", 0), Decimal128("3.14")], "x", [3]], + search=[Binary(b"\x01\x02", 0), Decimal128("3.14")], + expected=1, + msg="Should find subarray with binary and decimal128", + ), + IndexOfArrayTest( + id="nested_find_subarray_object_array", + array=["a", [{"k": 1}, [2, 3]], None, 4], + search=[{"k": 1}, [2, 3]], + expected=1, + msg="Should find subarray with object and array", + ), + IndexOfArrayTest( + id="nested_find_subarray_null_bool_int", + array=[[None, True, 42], "x", 1], + search=[None, True, 42], + expected=0, + msg="Should find subarray with null bool int", + ), + IndexOfArrayTest( + id="nested_find_subarray_datetime_objectid", + array=[ + 0, + [datetime(2024, 1, 1, tzinfo=timezone.utc), ObjectId("000000000000000000000001")], + "end", + ], + search=[datetime(2024, 1, 1, tzinfo=timezone.utc), ObjectId("000000000000000000000001")], + expected=1, + msg="Should find subarray with datetime and objectid", + ), + IndexOfArrayTest( + id="nested_find_subarray_minkey_maxkey", + array=[[MinKey(), MaxKey()], 1, "a"], + search=[MinKey(), MaxKey()], + expected=0, + msg="Should find subarray with minkey and maxkey", + ), + IndexOfArrayTest( + id="nested_3_levels_deep", + array=[1, [[2, 3], [4, 5]], "end"], + search=[[2, 3], [4, 5]], + expected=1, + msg="Should find 3-level nested array", + ), + IndexOfArrayTest( + id="nested_4_levels_deep", + array=["a", [[[1, 2], 3], 4], None], + search=[[[1, 2], 3], 4], + expected=1, + msg="Should find 4-level nested array", + ), + IndexOfArrayTest( + id="nested_deep_mixed_bson", + array=[0, [[MinKey(), {"a": [Decimal128("1.5")]}], True], "x"], + search=[[MinKey(), {"a": [Decimal128("1.5")]}], True], + expected=1, + msg="Should find deeply nested mixed BSON", + ), + IndexOfArrayTest( + id="nested_inner_not_outer", + array=[[1, [2, 3]], [2, 3], 4], + search=[2, 3], + expected=1, + msg="Should find inner array match", + ), + IndexOfArrayTest( + id="nested_5_levels_deep", + array=[[[[[99]]]], "other"], + search=[[[[99]]]], + expected=0, + msg="Should find 5-level nested array", + ), + IndexOfArrayTest( + id="nested_deep_not_found", + array=[[1, [2, 3]], [4, 5]], + search=[2, 3], + expected=-1, + msg="Should not find array at wrong nesting level", + ), +] + +# Aggregate and test +ALL_TESTS = BSON_TYPE_SEARCH_TESTS + NUMERIC_EQUIVALENCE_TESTS + NESTED_MIXED_ARRAY_TESTS + +TEST_SUBSET_FOR_LITERAL = [ + BSON_TYPE_SEARCH_TESTS[0], # search_int64 + NUMERIC_EQUIVALENCE_TESTS[0], # int_matches_double + NESTED_MIXED_ARRAY_TESTS[0], # nested_find_object_in_mixed +] + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_indexOfArray_literal(collection, test): + """Test $indexOfArray BSON types with literal values.""" + result = execute_expression(collection, {"$indexOfArray": build_args(test)}) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_indexOfArray_insert(collection, test): + """Test $indexOfArray BSON types with values from inserted documents.""" + args, doc = build_insert_args(test) + result = execute_expression_with_insert(collection, {"$indexOfArray": args}, doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_core_behavior.py new file mode 100644 index 000000000..856eb3e1e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_core_behavior.py @@ -0,0 +1,680 @@ +""" +Core behavior tests for $indexOfArray expression. + +Tests basic search, not found, start/end index ranges, degenerate cases, +mixed types, and large arrays. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.array.indexOfArray.utils.indexOfArray_common import ( # noqa: E501 + IndexOfArrayTest, + build_args, + build_insert_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import DECIMAL128_NEGATIVE_ZERO, INT32_MAX + +# Success: basic search — value found +BASIC_FOUND_TESTS: list[IndexOfArrayTest] = [ + IndexOfArrayTest( + id="found_first", + array=[1, 2, 3], + search=1, + expected=0, + msg="Should find first element at index 0", + ), + IndexOfArrayTest( + id="found_middle", + array=[1, 2, 3], + search=2, + expected=1, + msg="Should find middle element at index 1", + ), + IndexOfArrayTest( + id="found_last", + array=[1, 2, 3], + search=3, + expected=2, + msg="Should find last element at index 2", + ), + IndexOfArrayTest( + id="found_string", + array=["a", "b", "c"], + search="b", + expected=1, + msg="Should find string in array", + ), + IndexOfArrayTest( + id="found_bool_true", + array=[True, False], + search=True, + expected=0, + msg="Should find true at index 0", + ), + IndexOfArrayTest( + id="found_bool_false", + array=[True, False], + search=False, + expected=1, + msg="Should find false at index 1", + ), + IndexOfArrayTest( + id="found_null", + array=[None, 1, 2], + search=None, + expected=0, + msg="Should find null at index 0", + ), + IndexOfArrayTest( + id="found_nested_array", + array=[[1, 2], [3, 4]], + search=[3, 4], + expected=1, + msg="Should find nested array", + ), + IndexOfArrayTest( + id="found_object", + array=[{"a": 1}, {"b": 2}], + search={"a": 1}, + expected=0, + msg="Should find object in array", + ), + IndexOfArrayTest( + id="found_single_element", + array=[42], + search=42, + expected=0, + msg="Should find value in single-element array", + ), + IndexOfArrayTest( + id="first_occurrence", + array=[1, 2, 1, 2], + search=1, + expected=0, + msg="Should return first occurrence index", + ), + IndexOfArrayTest( + id="duplicate_values", + array=[5, 5, 5], + search=5, + expected=0, + msg="Should return first index for duplicates", + ), +] + +# Success: value not found → -1 +NOT_FOUND_TESTS: list[IndexOfArrayTest] = [ + IndexOfArrayTest( + id="not_found_int", + array=[1, 2, 3], + search=4, + expected=-1, + msg="Should return -1 for absent int", + ), + IndexOfArrayTest( + id="not_found_string", + array=["a", "b"], + search="z", + expected=-1, + msg="Should return -1 for absent string", + ), + IndexOfArrayTest( + id="empty_array", array=[], search=1, expected=-1, msg="Should return -1 for empty array" + ), + IndexOfArrayTest( + id="type_mismatch_search", + array=[1, 2, 3], + search="1", + expected=-1, + msg="Should return -1 for type mismatch", + ), + IndexOfArrayTest( + id="bool_vs_int", + array=[1, 0], + search=True, + expected=-1, + msg="Should return -1 for bool vs int", + ), + IndexOfArrayTest( + id="not_found_null", + array=[1, 2, 3], + search=None, + expected=-1, + msg="Should return -1 for null not in array", + ), + IndexOfArrayTest( + id="not_found_partial_array", + array=[[1, 2], [3, 4]], + search=[1], + expected=-1, + msg="Should return -1 for partial array match", + ), + IndexOfArrayTest( + id="not_found_partial_object", + array=[{"a": 1, "b": 2}], + search={"a": 1}, + expected=-1, + msg="Should return -1 for partial object match", + ), +] + +# Success: with start index +START_INDEX_TESTS: list[IndexOfArrayTest] = [ + IndexOfArrayTest( + id="start_skips_first", + array=[1, 2, 1, 2], + search=1, + start=1, + expected=2, + msg="Should skip first match with start index", + ), + IndexOfArrayTest( + id="start_at_match", + array=[10, 20, 30], + search=20, + start=1, + expected=1, + msg="Should find at start index", + ), + IndexOfArrayTest( + id="start_past_match", + array=[10, 20, 30], + search=10, + start=1, + expected=-1, + msg="Should return -1 when start is past match", + ), + IndexOfArrayTest( + id="start_at_zero", + array=[1, 2, 3], + search=1, + start=0, + expected=0, + msg="Should find with start at zero", + ), + IndexOfArrayTest( + id="start_beyond_array", + array=[1, 2, 3], + search=1, + start=20, + expected=-1, + msg="Should return -1 when start beyond array", + ), + IndexOfArrayTest( + id="start_int64", + array=[10, 20, 30], + search=20, + start=Int64(1), + expected=1, + msg="Should accept Int64 start index", + ), + IndexOfArrayTest( + id="start_double_integral", + array=[10, 20, 30], + search=30, + start=2.0, + expected=2, + msg="Should accept integral double start index", + ), + IndexOfArrayTest( + id="start_decimal128_integral", + array=[10, 20, 30], + search=20, + start=Decimal128("1"), + expected=1, + msg="Should accept Decimal128 start index", + ), + IndexOfArrayTest( + id="start_decimal128_10E_neg1", + array=[10, 20, 30], + search=20, + start=Decimal128("10E-1"), + expected=1, + msg="Should accept Decimal128 10E-1 as start index 1", + ), + IndexOfArrayTest( + id="end_decimal128_30E_neg1", + array=[10, 20, 30], + search=20, + start=0, + end=Decimal128("30E-1"), + expected=1, + msg="Should accept Decimal128 30E-1 as end index 3", + ), +] + +# Success: with start and end index +START_END_INDEX_TESTS: list[IndexOfArrayTest] = [ + IndexOfArrayTest( + id="range_found", + array=["a", "b", "c", "b"], + search="b", + start=1, + end=3, + expected=1, + msg="Should find in range", + ), + IndexOfArrayTest( + id="range_not_found_exclusive_end", + array=["a", "b", "c"], + search="c", + start=0, + end=2, + expected=-1, + msg="Should not find at exclusive end", + ), + IndexOfArrayTest( + id="range_end_equals_start", + array=[1, 2, 3], + search=1, + start=0, + end=0, + expected=-1, + msg="Should return -1 when end equals start", + ), + IndexOfArrayTest( + id="range_end_less_than_start", + array=["a", "b", "c"], + search="b", + start=2, + end=1, + expected=-1, + msg="Should return -1 when end less than start", + ), + IndexOfArrayTest( + id="range_end_beyond_array", + array=[1, 2, 3], + search=3, + start=0, + end=100, + expected=2, + msg="Should find when end beyond array", + ), + IndexOfArrayTest( + id="range_full_array", + array=[1, 2, 3], + search=2, + start=0, + end=3, + expected=1, + msg="Should find in full array range", + ), + IndexOfArrayTest( + id="range_int64_bounds", + array=[10, 20, 30], + search=20, + start=Int64(0), + end=Int64(3), + expected=1, + msg="Should accept Int64 bounds", + ), + IndexOfArrayTest( + id="range_double_integral_bounds", + array=[10, 20, 30], + search=20, + start=0.0, + end=3.0, + expected=1, + msg="Should accept integral double bounds", + ), + IndexOfArrayTest( + id="range_decimal128_bounds", + array=[10, 20, 30], + search=20, + start=Decimal128("0"), + end=Decimal128("3"), + expected=1, + msg="Should accept Decimal128 bounds", + ), +] + +# Success: first occurrence from start with multiple duplicates +FIRST_FROM_START_TESTS: list[IndexOfArrayTest] = [ + IndexOfArrayTest( + id="dup_skip_to_third_occurrence", + array=[5, 3, 5, 3, 5], + search=5, + start=3, + expected=4, + msg="Should find third occurrence of 5 when start=3", + ), + IndexOfArrayTest( + id="dup_skip_different_value", + array=[5, 3, 5, 3, 5], + search=3, + start=2, + expected=3, + msg="Should find second 3 when start=2", + ), +] + +# Success: detailed range semantics +RANGE_SEMANTICS_TESTS: list[IndexOfArrayTest] = [ + IndexOfArrayTest( + id="end_exclusive_includes_before_boundary", + array=["a", "b", "c"], + search="b", + start=0, + end=2, + expected=1, + msg="Element before end boundary should be included in [0,2)", + ), + IndexOfArrayTest( + id="end_exclusive_excludes_at_boundary", + array=["a", "b", "c"], + search="b", + start=0, + end=1, + expected=-1, + msg="Element at end boundary should be excluded from [0,1)", + ), + IndexOfArrayTest( + id="empty_range_at_array_length", + array=[1, 2, 3], + search=3, + start=3, + end=3, + expected=-1, + msg="Empty range [len,len) at array boundary should return -1", + ), + IndexOfArrayTest( + id="range_finds_dup_in_subrange", + array=[1, 2, 3, 2, 1, 2, 3], + search=2, + start=2, + end=4, + expected=3, + msg="Should find 2 at index 3 within range [2,4) skipping earlier occurrences", + ), + IndexOfArrayTest( + id="start_at_array_length", + array=["a", "b", "c"], + search="a", + start=3, + expected=-1, + msg="Should return -1 when start equals array length", + ), + IndexOfArrayTest( + id="start_and_end_both_beyond_array", + array=["a", "b", "c"], + search="a", + start=100, + end=200, + expected=-1, + msg="Should return -1 when both start and end are beyond array", + ), + IndexOfArrayTest( + id="end_before_match_position", + array=["a", "abc", "b"], + search="b", + start=0, + end=1, + expected=-1, + msg="Should return -1 when end is before the matching element", + ), +] + +# Success: degenerate and single-element edge cases +DEGENERATE_TESTS: list[IndexOfArrayTest] = [ + IndexOfArrayTest( + id="single_not_found", + array=[1], + search=2, + expected=-1, + msg="Should return -1 when single element doesn't match", + ), + IndexOfArrayTest( + id="single_found_in_range", + array=[42], + search=42, + start=0, + end=1, + expected=0, + msg="Single element found in range [0,1)", + ), + IndexOfArrayTest( + id="single_empty_range", + array=[42], + search=42, + start=0, + end=0, + expected=-1, + msg="Single element not found in empty range [0,0)", + ), + IndexOfArrayTest( + id="single_start_past_element", + array=[42], + search=42, + start=1, + expected=-1, + msg="Single element not found when start past it", + ), + IndexOfArrayTest( + id="all_null_from_start", + array=[None, None, None], + search=None, + start=1, + expected=1, + msg="Should find null at index 1 from start=1 in all-null array", + ), + IndexOfArrayTest( + id="all_null_search_different_type", + array=[None, None, None], + search=1, + expected=-1, + msg="Should not find int in all-null array", + ), + IndexOfArrayTest( + id="all_true_search_false", + array=[True, True, True], + search=False, + expected=-1, + msg="Should not find false in all-true array", + ), +] + +# Success: mixed types in array +MIXED_TYPE_TESTS: list[IndexOfArrayTest] = [ + IndexOfArrayTest( + id="mixed_find_string", + array=[1, "2", True, None, [1]], + search="2", + expected=1, + msg="Should find string in mixed array", + ), + IndexOfArrayTest( + id="mixed_find_null", + array=[1, "2", True, None, [1]], + search=None, + expected=3, + msg="Should find null in mixed array", + ), + IndexOfArrayTest( + id="mixed_find_array", + array=[1, "2", True, None, [1]], + search=[1], + expected=4, + msg="Should find array in mixed array", + ), +] + +# Success: large array + +LARGE_ARRAY_TESTS: list[IndexOfArrayTest] = [ + IndexOfArrayTest( + id="large_array_first", + array=list(range(20_000)), + search=0, + expected=0, + msg="Should find first in large array", + ), + IndexOfArrayTest( + id="large_array_last", + array=list(range(20_000)), + search=19_999, + expected=19_999, + msg="Should find last in large array", + ), + IndexOfArrayTest( + id="large_array_middle", + array=list(range(20_000)), + search=10_000, + expected=10_000, + msg="Should find middle in large array", + ), + IndexOfArrayTest( + id="large_array_not_found", + array=list(range(20_000)), + search=-1, + expected=-1, + msg="Should return -1 for absent value in large array", + ), + IndexOfArrayTest( + id="large_array_with_start", + array=list(range(20_000)), + search=19_999, + start=19_998, + expected=19_999, + msg="Should find with start in large array", + ), +] + +# Negative zero treated as equivalent to positive zero in search +NEGATIVE_ZERO_SEARCH_TESTS: list[IndexOfArrayTest] = [ + IndexOfArrayTest( + id="search_double_neg_zero_in_zeros", + array=[0, 1, 2], + search=-0.0, + expected=0, + msg="$indexOfArray should find -0.0 at index of 0", + ), + IndexOfArrayTest( + id="search_zero_finds_neg_zero", + array=[-0.0, 1, 2], + search=0, + expected=0, + msg="$indexOfArray should find 0 matching -0.0 in array", + ), + IndexOfArrayTest( + id="search_decimal128_neg_zero", + array=[0, 1, 2], + search=Decimal128("-0"), + expected=0, + msg="$indexOfArray should find Decimal128 -0 at index of 0", + ), +] + +# Boundary values for start/end indices +BOUNDARY_INDEX_TESTS: list[IndexOfArrayTest] = [ + IndexOfArrayTest( + id="start_int32_max", + array=[1, 2, 3], + search=1, + start=INT32_MAX, + expected=-1, + msg="Should return -1 with INT32_MAX start", + ), + IndexOfArrayTest( + id="end_int32_max", + array=[1, 2, 3], + search=2, + start=0, + end=INT32_MAX, + expected=1, + msg="Should find with INT32_MAX end", + ), + IndexOfArrayTest( + id="start_and_end_int32_max", + array=[1, 2, 3], + search=1, + start=INT32_MAX, + end=INT32_MAX, + expected=-1, + msg="Should return -1 with both INT32_MAX", + ), + IndexOfArrayTest( + id="start_int32_max_minus_1", + array=[1, 2, 3], + search=1, + start=INT32_MAX - 1, + expected=-1, + msg="Should return -1 with INT32_MAX-1 start", + ), +] + +# Negative zero as start/end index treated as 0 +NEGATIVE_ZERO_INDEX_TESTS: list[IndexOfArrayTest] = [ + IndexOfArrayTest( + id="double_neg_zero_start", + array=[10, 20, 30], + search=10, + start=-0.0, + expected=0, + msg="Should treat -0.0 start as 0", + ), + IndexOfArrayTest( + id="decimal128_neg_zero_start", + array=[10, 20, 30], + search=10, + start=DECIMAL128_NEGATIVE_ZERO, + expected=0, + msg="Should treat decimal128 -0 start as 0", + ), + IndexOfArrayTest( + id="double_neg_zero_end", + array=[10, 20, 30], + search=10, + start=0, + end=-0.0, + expected=-1, + msg="Should treat -0.0 end as 0", + ), +] + +# Aggregate and test +ALL_TESTS = ( + BASIC_FOUND_TESTS + + NOT_FOUND_TESTS + + START_INDEX_TESTS + + START_END_INDEX_TESTS + + FIRST_FROM_START_TESTS + + RANGE_SEMANTICS_TESTS + + DEGENERATE_TESTS + + MIXED_TYPE_TESTS + + LARGE_ARRAY_TESTS + + NEGATIVE_ZERO_SEARCH_TESTS + + BOUNDARY_INDEX_TESTS + + NEGATIVE_ZERO_INDEX_TESTS +) + +TEST_SUBSET_FOR_LITERAL = [ + BASIC_FOUND_TESTS[0], # found_first + NOT_FOUND_TESTS[0], # not_found_int + START_END_INDEX_TESTS[0], # range_found +] + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_indexOfArray_literal(collection, test): + """Test $indexOfArray with literal values.""" + result = execute_expression(collection, {"$indexOfArray": build_args(test)}) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_indexOfArray_insert(collection, test): + """Test $indexOfArray with values from inserted documents.""" + args, doc = build_insert_args(test) + result = execute_expression_with_insert(collection, {"$indexOfArray": args}, doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_errors.py new file mode 100644 index 000000000..d74c3cada --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_errors.py @@ -0,0 +1,544 @@ +""" +Error tests for $indexOfArray expression. + +Tests non-array first argument, non-integral start/end, negative start/end, +boundary values, negative zero, and wrong arity errors. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.array.indexOfArray.utils.indexOfArray_common import ( # noqa: E501 + IndexOfArrayTest, + build_args, + build_insert_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + EXPRESSION_ARITY_ERROR, + INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + INDEX_OF_ARRAY_NOT_ARRAY_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + INT64_MAX, + MISSING, +) + +# Error: INT64_MAX start/end index (not representable as int32) +BOUNDARY_ERROR_TESTS: list[IndexOfArrayTest] = [ + IndexOfArrayTest( + id="start_int64_max", + array=[1, 2, 3], + search=1, + start=INT64_MAX, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="Should reject INT64_MAX start", + ), + IndexOfArrayTest( + id="end_int64_max", + array=[1, 2, 3], + search=2, + start=0, + end=INT64_MAX, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="Should reject INT64_MAX end", + ), +] + +# Error: first argument not an array (and not null) +NOT_ARRAY_ERROR_TESTS: list[IndexOfArrayTest] = [ + IndexOfArrayTest( + id="string_as_array", + array="hello", + search=1, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject string as array", + ), + IndexOfArrayTest( + id="int_as_array", + array=42, + search=1, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject int as array", + ), + IndexOfArrayTest( + id="double_as_array", + array=3.14, + search=1, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject double as array", + ), + IndexOfArrayTest( + id="bool_true_as_array", + array=True, + search=1, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject bool true as array", + ), + IndexOfArrayTest( + id="bool_false_as_array", + array=False, + search=1, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject bool false as array", + ), + IndexOfArrayTest( + id="object_as_array", + array={"a": 1}, + search=1, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject object as array", + ), + IndexOfArrayTest( + id="decimal128_as_array", + array=Decimal128("1"), + search=1, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject decimal128 as array", + ), + IndexOfArrayTest( + id="int64_as_array", + array=Int64(1), + search=1, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject int64 as array", + ), + IndexOfArrayTest( + id="binary_as_array", + array=Binary(b"x", 0), + search=1, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject binary as array", + ), + IndexOfArrayTest( + id="datetime_as_array", + array=datetime(2024, 1, 1, tzinfo=timezone.utc), + search=1, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject datetime as array", + ), + IndexOfArrayTest( + id="objectid_as_array", + array=ObjectId(), + search=1, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject objectid as array", + ), + IndexOfArrayTest( + id="regex_as_array", + array=Regex("x"), + search=1, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject regex as array", + ), + IndexOfArrayTest( + id="maxkey_as_array", + array=MaxKey(), + search=1, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject maxkey as array", + ), + IndexOfArrayTest( + id="minkey_as_array", + array=MinKey(), + search=1, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject minkey as array", + ), + IndexOfArrayTest( + id="timestamp_as_array", + array=Timestamp(0, 0), + search=1, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="Should reject timestamp as array", + ), +] + +# Error: start index not integral +START_NOT_INTEGRAL_TESTS: list[IndexOfArrayTest] = [ + IndexOfArrayTest( + id="start_fractional_double", + array=[1, 2, 3], + search=1, + start=1.5, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="Should reject fractional double start", + ), + IndexOfArrayTest( + id="start_fractional_decimal128", + array=[1, 2, 3], + search=1, + start=Decimal128("0.5"), + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="Should reject fractional decimal128 start", + ), + IndexOfArrayTest( + id="start_nan", + array=[1, 2, 3], + search=1, + start=float("nan"), + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="Should reject NaN start", + ), + IndexOfArrayTest( + id="start_inf", + array=[1, 2, 3], + search=1, + start=float("inf"), + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="Should reject infinity start", + ), + IndexOfArrayTest( + id="start_neg_inf", + array=[1, 2, 3], + search=1, + start=float("-inf"), + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="Should reject -infinity start", + ), + IndexOfArrayTest( + id="start_decimal128_nan", + array=[1, 2, 3], + search=1, + start=Decimal128("NaN"), + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="Should reject decimal128 NaN start", + ), + IndexOfArrayTest( + id="start_decimal128_inf", + array=[1, 2, 3], + search=1, + start=Decimal128("Infinity"), + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="Should reject decimal128 infinity start", + ), + IndexOfArrayTest( + id="start_string", + array=[1, 2, 3], + search=1, + start="0", + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="Should reject string start", + ), + IndexOfArrayTest( + id="start_bool", + array=[1, 2, 3], + search=1, + start=True, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="Should reject bool start", + ), + IndexOfArrayTest( + id="start_array", + array=[1, 2, 3], + search=1, + start=[0], + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="Should reject array start", + ), + IndexOfArrayTest( + id="start_object", + array=[1, 2, 3], + search=1, + start={"a": 0}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="Should reject object start", + ), +] + +# Error: end index not integral +END_NOT_INTEGRAL_TESTS: list[IndexOfArrayTest] = [ + IndexOfArrayTest( + id="end_fractional_double", + array=[1, 2, 3], + search=1, + start=0, + end=1.5, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="Should reject fractional double end", + ), + IndexOfArrayTest( + id="end_fractional_decimal128", + array=[1, 2, 3], + search=1, + start=0, + end=Decimal128("0.5"), + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="Should reject fractional decimal128 end", + ), + IndexOfArrayTest( + id="end_nan", + array=[1, 2, 3], + search=1, + start=0, + end=float("nan"), + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="Should reject NaN end", + ), + IndexOfArrayTest( + id="end_inf", + array=[1, 2, 3], + search=1, + start=0, + end=float("inf"), + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="Should reject infinity end", + ), + IndexOfArrayTest( + id="end_string", + array=[1, 2, 3], + search=1, + start=0, + end="3", + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="Should reject string end", + ), + IndexOfArrayTest( + id="end_bool", + array=[1, 2, 3], + search=1, + start=0, + end=True, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="Should reject bool end", + ), + IndexOfArrayTest( + id="end_neg_inf", + array=[1, 2, 3], + search=1, + start=0, + end=float("-inf"), + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="Should reject -infinity end", + ), + IndexOfArrayTest( + id="end_decimal128_nan", + array=[1, 2, 3], + search=1, + start=0, + end=Decimal128("NaN"), + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="Should reject decimal128 NaN end", + ), + IndexOfArrayTest( + id="end_decimal128_inf", + array=[1, 2, 3], + search=1, + start=0, + end=Decimal128("Infinity"), + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="Should reject decimal128 infinity end", + ), + IndexOfArrayTest( + id="end_array", + array=[1, 2, 3], + search=1, + start=0, + end=[3], + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="Should reject array end", + ), + IndexOfArrayTest( + id="end_object", + array=[1, 2, 3], + search=1, + start=0, + end={"a": 0}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="Should reject object end", + ), +] + +# Error: negative start index +START_NEGATIVE_TESTS: list[IndexOfArrayTest] = [ + IndexOfArrayTest( + id="start_neg_one", + array=[1, 2, 3], + search=1, + start=-1, + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="Should reject negative start -1", + ), + IndexOfArrayTest( + id="start_neg_large", + array=[1, 2, 3], + search=1, + start=-100, + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="Should reject negative start -100", + ), + IndexOfArrayTest( + id="start_neg_int64", + array=[1, 2, 3], + search=1, + start=Int64(-1), + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="Should reject negative Int64 start", + ), + IndexOfArrayTest( + id="start_neg_double", + array=[1, 2, 3], + search=1, + start=-1.0, + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="Should reject negative double start", + ), + IndexOfArrayTest( + id="start_neg_decimal128", + array=[1, 2, 3], + search=1, + start=Decimal128("-1"), + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="Should reject negative decimal128 start", + ), +] + +# Error: negative end index +END_NEGATIVE_TESTS: list[IndexOfArrayTest] = [ + IndexOfArrayTest( + id="end_neg_one", + array=[1, 2, 3], + search=1, + start=0, + end=-1, + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="Should reject negative end -1", + ), + IndexOfArrayTest( + id="end_neg_large", + array=[1, 2, 3], + search=1, + start=0, + end=-100, + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="Should reject negative end -100", + ), + IndexOfArrayTest( + id="end_neg_int64", + array=[1, 2, 3], + search=1, + start=0, + end=Int64(-1), + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="Should reject negative Int64 end", + ), + IndexOfArrayTest( + id="end_neg_double", + array=[1, 2, 3], + search=1, + start=0, + end=-1.0, + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="Should reject negative double end", + ), + IndexOfArrayTest( + id="end_neg_decimal128", + array=[1, 2, 3], + search=1, + start=0, + end=Decimal128("-1"), + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="Should reject negative decimal128 end", + ), +] + +# Aggregate and test +ALL_TESTS = ( + BOUNDARY_ERROR_TESTS + + NOT_ARRAY_ERROR_TESTS + + START_NOT_INTEGRAL_TESTS + + END_NOT_INTEGRAL_TESTS + + START_NEGATIVE_TESTS + + END_NEGATIVE_TESTS +) + +LITERAL_ONLY_TESTS: list[IndexOfArrayTest] = [ + IndexOfArrayTest( + id="start_missing_field", + array=[1, 2, 3], + search=1, + start=MISSING, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="Should reject missing field as start", + ), + IndexOfArrayTest( + id="end_missing_field", + array=[1, 2, 3], + search=1, + start=0, + end=MISSING, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="Should reject missing field as end", + ), +] + +TEST_SUBSET_FOR_LITERAL = [ + NOT_ARRAY_ERROR_TESTS[0], # string_as_array + START_NOT_INTEGRAL_TESTS[0], # start_fractional_double + START_NEGATIVE_TESTS[0], # start_neg_one +] + LITERAL_ONLY_TESTS + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_indexOfArray_literal(collection, test): + """Test $indexOfArray error cases with literal values.""" + result = execute_expression(collection, {"$indexOfArray": build_args(test)}) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_indexOfArray_insert(collection, test): + """Test $indexOfArray error cases with values from inserted documents.""" + args, doc = build_insert_args(test) + result = execute_expression_with_insert(collection, {"$indexOfArray": args}, doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +# Error: wrong arity +ARITY_ERROR_TESTS = [ + pytest.param({"$indexOfArray": []}, id="zero_args"), + pytest.param({"$indexOfArray": [[1, 2, 3]]}, id="one_arg"), + pytest.param({"$indexOfArray": [[1, 2, 3], 1, 0, 3, 99]}, id="five_args"), +] + + +@pytest.mark.parametrize("expr", ARITY_ERROR_TESTS) +def test_indexOfArray_arity_error(collection, expr): + """Test $indexOfArray errors with wrong number of arguments.""" + result = execute_expression(collection, expr) + assert_expression_result(result, error_code=EXPRESSION_ARITY_ERROR) + + +# Error: null as literal start/end index +# Standalone test because end=None in IndexOfArrayTest means "no end argument", +# so null-as-end cannot be expressed via the dataclass. +def test_indexOfArray_null_end(collection): + """Test $indexOfArray with null as end index errors.""" + result = execute_expression(collection, {"$indexOfArray": [[1, 2, 3], 1, 0, None]}) + assert_expression_result( + result, error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, msg="Null end should fail" + ) + + +def test_indexOfArray_null_start(collection): + """Test $indexOfArray with null as start index errors.""" + + result = execute_expression(collection, {"$indexOfArray": [[1, 2, 3], 1, None]}) + + assert_expression_result( + result, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="Null start should fail", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_expressions.py new file mode 100644 index 000000000..d367d62cd --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_expressions.py @@ -0,0 +1,99 @@ +""" +Expression and field path tests for $indexOfArray expression. + +Tests nested expressions, field path lookups, composite paths, +and path through array of objects. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) + + +# Nested expressions +@pytest.mark.parametrize( + "expression,expected", + [ + # 2-level: inner result used as search value + ( + { + "$indexOfArray": [ + [0, 1, 2, 3], + {"$indexOfArray": [[10, 20, 30], 30]}, + ] + }, + 2, + ), + # 3-level: triple nested, each result feeds the next as search value + ( + { + "$indexOfArray": [ + [0, 1, 2], + { + "$indexOfArray": [ + [0, 1, 2], + {"$indexOfArray": [[5, 10, 15], 10]}, + ] + }, + ] + }, + 1, + ), + # 2-level: inner result used as start index + ( + { + "$indexOfArray": [ + [1, 2, 1, 2], + 1, + {"$indexOfArray": [[10, 20, 30], 20]}, + ] + }, + 2, + ), + ], + ids=["nested_2_level", "nested_3_level", "nested_start_index"], +) +def test_indexOfArray_nested_expression(collection, expression, expected): + """Test $indexOfArray composed with other expressions.""" + result = execute_expression(collection, expression) + assert_expression_result(result, expected=expected) + + +# Field path lookups +@pytest.mark.parametrize( + "document,array_ref,search,expected", + [ + ({"a": {"b": [10, 20, 30]}}, "$a.b", 20, 1), + ({"a": {"missing": 1}}, "$a.nonexistent", 0, None), + ({"a": {"b": {"c": [5, 6, 7]}}}, "$a.b.c", 7, 2), + ], + ids=["nested_field_path", "nonexistent_field_null", "deeply_nested_field"], +) +def test_indexOfArray_field_lookup(collection, document, array_ref, search, expected): + """Test $indexOfArray with field path lookups from inserted documents.""" + result = execute_expression_with_insert( + collection, {"$indexOfArray": [array_ref, search]}, document + ) + assert_expression_result(result, expected=expected) + + +def test_indexOfArray_composite_array_as_array(collection): + """Test $indexOfArray with composite array from $x.y as the array argument.""" + result = execute_expression_with_insert( + collection, {"$indexOfArray": ["$x.y", 20]}, {"x": [{"y": 10}, {"y": 20}, {"y": 30}]} + ) + assert_expression_result(result, expected=1) + + +def test_indexOfArray_composite_array_as_search(collection): + """Test $indexOfArray with composite array from $x.y as the search value.""" + result = execute_expression_with_insert( + collection, + {"$indexOfArray": [[[10, 20], [30, 40]], "$x.y"]}, + {"x": [{"y": 30}, {"y": 40}]}, + ) + assert_expression_result(result, expected=1) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_null_missing.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_null_missing.py new file mode 100644 index 000000000..96d32a0b0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_null_missing.py @@ -0,0 +1,107 @@ +""" +Null and missing field handling tests for $indexOfArray expression. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.array.indexOfArray.utils.indexOfArray_common import ( # noqa: E501 + IndexOfArrayTest, + build_args, + build_insert_args, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import MISSING + +# Success: null/missing array → null (runs both literal and insert) +NULL_ARRAY_TESTS: list[IndexOfArrayTest] = [ + IndexOfArrayTest( + id="null_array", + array=None, + search=1, + expected=None, + msg="Should return null for null array", + ), + IndexOfArrayTest( + id="null_array_with_start", + array=None, + search=1, + start=0, + expected=None, + msg="Should return null for null array with start", + ), +] + +# Success: null/missing as search value (runs both literal and insert) +NULL_SEARCH_TESTS: list[IndexOfArrayTest] = [ + IndexOfArrayTest( + id="null_value_in_array", + array=[1, None, 3], + search=None, + expected=1, + msg="Should find null in array", + ), + IndexOfArrayTest( + id="null_value_not_in_array", + array=[1, 2, 3], + search=None, + expected=-1, + msg="Should return -1 for null not in array", + ), +] + +# Literal only: MISSING field refs +LITERAL_ONLY_TESTS: list[IndexOfArrayTest] = [ + IndexOfArrayTest( + id="missing_array", + array=MISSING, + search=1, + expected=None, + msg="Should return null for missing array", + ), + IndexOfArrayTest( + id="missing_value", + array=[1, 2, 3], + search=MISSING, + expected=-1, + msg="Should return -1 for missing search value", + ), + IndexOfArrayTest( + id="missing_value_null_in_array", + array=[1, None, 3], + search=MISSING, + expected=-1, + msg="Should return -1 for missing search even with null in array", + ), +] + +# Aggregate and test +ALL_TESTS = NULL_ARRAY_TESTS + NULL_SEARCH_TESTS + +TEST_SUBSET_FOR_LITERAL = [ + NULL_ARRAY_TESTS[0], # null_array + NULL_SEARCH_TESTS[0], # null_value_in_array +] + LITERAL_ONLY_TESTS + + +@pytest.mark.parametrize("test", pytest_params(TEST_SUBSET_FOR_LITERAL)) +def test_indexOfArray_literal(collection, test): + """Test $indexOfArray null/missing with literal values.""" + result = execute_expression(collection, {"$indexOfArray": build_args(test)}) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_indexOfArray_insert(collection, test): + """Test $indexOfArray null with values from inserted documents.""" + args, doc = build_insert_args(test) + result = execute_expression_with_insert(collection, {"$indexOfArray": args}, doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_smoke_expression_indexOfArray.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_smoke_indexOfArray.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_smoke_expression_indexOfArray.py rename to documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_smoke_indexOfArray.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/utils/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/utils/indexOfArray_common.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/utils/indexOfArray_common.py new file mode 100644 index 000000000..55876abf1 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/utils/indexOfArray_common.py @@ -0,0 +1,41 @@ +""" +Shared test infrastructure for $indexOfArray expression tests. +""" + +from dataclasses import dataclass +from typing import Any + +from documentdb_tests.framework.test_case import BaseTestCase + + +@dataclass(frozen=True) +class IndexOfArrayTest(BaseTestCase): + """Test case for $indexOfArray operator.""" + + array: Any = None + search: Any = None + start: Any = None + end: Any = None + + +def build_args(test: IndexOfArrayTest): + """Build the argument list for $indexOfArray from a test case.""" + args = [test.array, test.search] + if test.start is not None: + args.append(test.start) + if test.end is not None: + args.append(test.end) + return args + + +def build_insert_args(test: IndexOfArrayTest): + """Build field-reference argument list and document for insert-based tests.""" + args = ["$arr", "$search"] + doc = {"arr": test.array, "search": test.search} + if test.start is not None: + args.append("$start") + doc["start"] = test.start + if test.end is not None: + args.append("$end") + doc["end"] = test.end + return args, doc diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_bson_types.py new file mode 100644 index 000000000..b1d430550 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_bson_types.py @@ -0,0 +1,414 @@ +""" +BSON type tests for $isArray expression. + +Tests arrays containing specific BSON types return true, +non-array BSON types return false, special numeric values, +and boundary values. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_MAX, + DECIMAL128_MIN, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT64_MAX, + INT64_MIN, +) + +# Arrays containing specific BSON types → true +BSON_ARRAY_TRUE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="bindata_array", + doc={"val": [Binary(b"\x00", 0)]}, + expression={"$isArray": "$val"}, + expected=True, + msg="Should return true for BinData array", + ), + ExpressionTestCase( + id="timestamp_array", + doc={"val": [Timestamp(0, 0)]}, + expression={"$isArray": "$val"}, + expected=True, + msg="Should return true for Timestamp array", + ), + ExpressionTestCase( + id="int64_array", + doc={"val": [Int64(1)]}, + expression={"$isArray": "$val"}, + expected=True, + msg="Should return true for Int64 array", + ), + ExpressionTestCase( + id="decimal128_array", + doc={"val": [Decimal128("1")]}, + expression={"$isArray": "$val"}, + expected=True, + msg="Should return true for Decimal128 array", + ), + ExpressionTestCase( + id="objectid_array", + doc={"val": [ObjectId()]}, + expression={"$isArray": "$val"}, + expected=True, + msg="Should return true for ObjectId array", + ), + ExpressionTestCase( + id="datetime_array", + doc={"val": [datetime(2024, 1, 1, tzinfo=timezone.utc)]}, + expression={"$isArray": "$val"}, + expected=True, + msg="Should return true for datetime array", + ), + ExpressionTestCase( + id="minkey_array", + doc={"val": [MinKey()]}, + expression={"$isArray": "$val"}, + expected=True, + msg="Should return true for MinKey array", + ), + ExpressionTestCase( + id="maxkey_array", + doc={"val": [MaxKey()]}, + expression={"$isArray": "$val"}, + expected=True, + msg="Should return true for MaxKey array", + ), + ExpressionTestCase( + id="regex_array", + doc={"val": [Regex(".*")]}, + expression={"$isArray": "$val"}, + expected=True, + msg="Should return true for Regex array", + ), + ExpressionTestCase( + id="nan_array", + doc={"val": [float("nan")]}, + expression={"$isArray": "$val"}, + expected=True, + msg="Should return true for NaN array", + ), + ExpressionTestCase( + id="inf_array", + doc={"val": [float("inf")]}, + expression={"$isArray": "$val"}, + expected=True, + msg="Should return true for Infinity array", + ), + ExpressionTestCase( + id="decimal128_nan_array", + doc={"val": [Decimal128("NaN")]}, + expression={"$isArray": "$val"}, + expected=True, + msg="Should return true for Decimal128 NaN array", + ), + ExpressionTestCase( + id="decimal128_inf_array", + doc={"val": [Decimal128("Infinity")]}, + expression={"$isArray": "$val"}, + expected=True, + msg="Should return true for Decimal128 Infinity array", + ), + ExpressionTestCase( + id="decimal128_neg_nan_array", + doc={"val": [Decimal128("-NaN")]}, + expression={"$isArray": "$val"}, + expected=True, + msg="Should return true for Decimal128 -NaN array", + ), + ExpressionTestCase( + id="decimal128_neg_inf_array", + doc={"val": [DECIMAL128_NEGATIVE_INFINITY]}, + expression={"$isArray": "$val"}, + expected=True, + msg="Should return true for Decimal128 -Infinity array", + ), + ExpressionTestCase( + id="neg_inf_array", + doc={"val": [FLOAT_NEGATIVE_INFINITY]}, + expression={"$isArray": "$val"}, + expected=True, + msg="Should return true for -Infinity array", + ), + ExpressionTestCase( + id="neg_zero_array", + doc={"val": [DOUBLE_NEGATIVE_ZERO]}, + expression={"$isArray": "$val"}, + expected=True, + msg="Should return true for negative zero array", + ), + ExpressionTestCase( + id="decimal128_neg_zero_array", + doc={"val": [DECIMAL128_NEGATIVE_ZERO]}, + expression={"$isArray": "$val"}, + expected=True, + msg="Should return true for Decimal128 -0 array", + ), + ExpressionTestCase( + id="nested_mixed_bson_array", + doc={ + "val": [ + MinKey(), + {"a": [Decimal128("1.5")]}, + Int64(1), + datetime(2024, 1, 1, tzinfo=timezone.utc), + Binary(b"\x01", 0), + ] + }, + expression={"$isArray": "$val"}, + expected=True, + msg="Should return true for nested mixed BSON array", + ), +] + +# Non-array BSON types → false +BSON_FALSE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="int64", + doc={"val": Int64(1)}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for Int64", + ), + ExpressionTestCase( + id="decimal128", + doc={"val": Decimal128("1")}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for Decimal128", + ), + ExpressionTestCase( + id="objectid", + doc={"val": ObjectId("000000000000000000000001")}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for ObjectId", + ), + ExpressionTestCase( + id="datetime", + doc={"val": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for datetime", + ), + ExpressionTestCase( + id="binary", + doc={"val": Binary(b"\x01", 0)}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for Binary", + ), + ExpressionTestCase( + id="regex", + doc={"val": Regex("^abc")}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for Regex", + ), + ExpressionTestCase( + id="timestamp", + doc={"val": Timestamp(1, 1)}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for Timestamp", + ), + ExpressionTestCase( + id="minkey", + doc={"val": MinKey()}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for MinKey", + ), + ExpressionTestCase( + id="maxkey", + doc={"val": MaxKey()}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for MaxKey", + ), +] + +# Special numeric values → false +SPECIAL_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="nan", + doc={"val": FLOAT_NAN}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for NaN", + ), + ExpressionTestCase( + id="inf", + doc={"val": FLOAT_INFINITY}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for Infinity", + ), + ExpressionTestCase( + id="neg_inf", + doc={"val": FLOAT_NEGATIVE_INFINITY}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for -Infinity", + ), + ExpressionTestCase( + id="neg_zero", + doc={"val": DOUBLE_NEGATIVE_ZERO}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for negative zero", + ), + ExpressionTestCase( + id="decimal128_nan", + doc={"val": DECIMAL128_NAN}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for Decimal128 NaN", + ), + ExpressionTestCase( + id="decimal128_neg_nan", + doc={"val": Decimal128("-NaN")}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for Decimal128 -NaN", + ), + ExpressionTestCase( + id="decimal128_inf", + doc={"val": DECIMAL128_INFINITY}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for Decimal128 Infinity", + ), + ExpressionTestCase( + id="decimal128_neg_inf", + doc={"val": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for Decimal128 -Infinity", + ), + ExpressionTestCase( + id="decimal128_neg_zero", + doc={"val": DECIMAL128_NEGATIVE_ZERO}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for Decimal128 -0", + ), +] + +# Boundary values → false +BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="int32_max", + doc={"val": INT32_MAX}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for INT32_MAX", + ), + ExpressionTestCase( + id="int32_min", + doc={"val": INT32_MIN}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for INT32_MIN", + ), + ExpressionTestCase( + id="int64_max", + doc={"val": INT64_MAX}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for INT64_MAX", + ), + ExpressionTestCase( + id="int64_min", + doc={"val": INT64_MIN}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for INT64_MIN", + ), + ExpressionTestCase( + id="decimal128_max", + doc={"val": DECIMAL128_MAX}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for DECIMAL128_MAX", + ), + ExpressionTestCase( + id="decimal128_min", + doc={"val": DECIMAL128_MIN}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for DECIMAL128_MIN", + ), +] + +# Aggregate and test +# Property [Literal Evaluation]: $isArray BSON type detection works with inline literal values. +TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_bindata_array", + doc=None, + expression={"$isArray": [{"$literal": [Binary(b"\x00", 0)]}]}, + expected=True, + msg="$isArray should return true for literal BinData array", + ), + ExpressionTestCase( + "literal_nested_mixed_bson_array", + doc=None, + expression={"$isArray": [{"$literal": [MinKey(), {"a": [Decimal128("1.5")]}, Int64(1)]}]}, + expected=True, + msg="$isArray should return true for literal nested mixed BSON array", + ), + ExpressionTestCase( + "literal_int64", + doc=None, + expression={"$isArray": [Int64(1)]}, + expected=False, + msg="$isArray should return false for literal Int64", + ), + ExpressionTestCase( + "literal_nan", + doc=None, + expression={"$isArray": [FLOAT_NAN]}, + expected=False, + msg="$isArray should return false for literal NaN", + ), +] + +ALL_BSON_TESTS = ( + BSON_ARRAY_TRUE_TESTS + + BSON_FALSE_TESTS + + SPECIAL_NUMERIC_TESTS + + BOUNDARY_TESTS + + TEST_SUBSET_FOR_LITERAL +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_BSON_TESTS)) +def test_isArray_bson_insert(collection, test): + """Test $isArray BSON types with values from inserted documents.""" + if test.doc is None: + result = execute_expression(collection, test.expression) + else: + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result(result, expected=test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_core_behavior.py new file mode 100644 index 000000000..ba05d3a46 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_core_behavior.py @@ -0,0 +1,324 @@ +""" +Core behavior tests for $isArray expression. + +Tests that arrays return true, non-arrays return false, +with basic types via both literal and insert paths. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Success: arrays → true +IS_ARRAY_TRUE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="simple_array", + doc={"val": [1, 2, 3]}, + expression={"$isArray": "$val"}, + expected=True, + msg="Should return true for simple array", + ), + ExpressionTestCase( + id="empty_array", + doc={"val": []}, + expression={"$isArray": "$val"}, + expected=True, + msg="Should return true for empty array", + ), + ExpressionTestCase( + id="single_element", + doc={"val": [42]}, + expression={"$isArray": "$val"}, + expected=True, + msg="Should return true for single-element array", + ), + ExpressionTestCase( + id="nested_array", + doc={"val": [[1, 2], [3, 4]]}, + expression={"$isArray": "$val"}, + expected=True, + msg="Should return true for nested array", + ), + ExpressionTestCase( + id="mixed_type_array", + doc={"val": [1, "two", True, None, {"a": 1}]}, + expression={"$isArray": "$val"}, + expected=True, + msg="Should return true for mixed-type array", + ), + ExpressionTestCase( + id="array_of_objects", + doc={"val": [{"a": 1}, {"b": 2}]}, + expression={"$isArray": "$val"}, + expected=True, + msg="Should return true for array of objects", + ), + ExpressionTestCase( + id="array_of_nulls", + doc={"val": [None, None]}, + expression={"$isArray": "$val"}, + expected=True, + msg="Should return true for array of nulls", + ), + ExpressionTestCase( + id="string_array", + doc={"val": ["a", "b", "c"]}, + expression={"$isArray": "$val"}, + expected=True, + msg="Should return true for string array", + ), + ExpressionTestCase( + id="bool_array", + doc={"val": [True]}, + expression={"$isArray": "$val"}, + expected=True, + msg="Should return true for bool array", + ), + ExpressionTestCase( + id="large_array_10k", + doc={"val": list(range(10000))}, + expression={"$isArray": "$val"}, + expected=True, + msg="10K element array returns true", + ), + ExpressionTestCase( + id="deeply_nested_array", + doc={"val": [[[[[[1]]]]]]}, + expression={"$isArray": "$val"}, + expected=True, + msg="Deeply nested array returns true", + ), + ExpressionTestCase( + id="large_array_of_arrays", + doc={"val": [[i] for i in range(10000)]}, + expression={"$isArray": "$val"}, + expected=True, + msg="10K nested arrays returns true", + ), +] + +# Success: non-arrays → false +IS_ARRAY_FALSE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="string", + doc={"val": "hello"}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for string", + ), + ExpressionTestCase( + id="int", + doc={"val": 42}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for int", + ), + ExpressionTestCase( + id="double", + doc={"val": 3.14}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for double", + ), + ExpressionTestCase( + id="bool_true", + doc={"val": True}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for true", + ), + ExpressionTestCase( + id="bool_false", + doc={"val": False}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for false", + ), + ExpressionTestCase( + id="null", + doc={"val": None}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for null", + ), + ExpressionTestCase( + id="object", + doc={"val": {"a": 1}}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for object", + ), + ExpressionTestCase( + id="empty_string", + doc={"val": ""}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for empty string", + ), + ExpressionTestCase( + id="empty_object", + doc={"val": {}}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for empty object", + ), + ExpressionTestCase( + id="zero", + doc={"val": 0}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for zero", + ), + ExpressionTestCase( + id="negative_int", + doc={"val": -123}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for negative int", + ), + ExpressionTestCase( + id="negative_double", + doc={"val": -1.23}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for negative double", + ), +] + +# Array-like edge cases → false +ARRAY_LIKE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="string_brackets", + doc={"val": "[]"}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for string '[]'", + ), + ExpressionTestCase( + id="string_array_repr", + doc={"val": "[1, 2, 3]"}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for string '[1, 2, 3]'", + ), + ExpressionTestCase( + id="array_like_object", + doc={"val": {"0": "a", "1": "b"}}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for array-like object", + ), + ExpressionTestCase( + id="length_object", + doc={"val": {"length": 3}}, + expression={"$isArray": "$val"}, + expected=False, + msg="Should return false for object with length key", + ), +] + +# Aggregate and test +# Property [Literal Evaluation]: $isArray evaluates correctly with inline literal values. +TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_simple_array", + doc=None, + expression={"$isArray": [{"$literal": [1, 2, 3]}]}, + expected=True, + msg="$isArray should return true for literal simple array", + ), + ExpressionTestCase( + "literal_empty_array", + doc=None, + expression={"$isArray": [{"$literal": []}]}, + expected=True, + msg="$isArray should return true for literal empty array", + ), + ExpressionTestCase( + "literal_nested_array", + doc=None, + expression={"$isArray": [{"$literal": [[1], [2]]}]}, + expected=True, + msg="$isArray should return true for literal nested array", + ), + ExpressionTestCase( + "literal_array_of_objects", + doc=None, + expression={"$isArray": [{"$literal": [{"a": 1}, {"b": 2}]}]}, + expected=True, + msg="$isArray should return true for literal array of objects", + ), + ExpressionTestCase( + "literal_array_of_nulls", + doc=None, + expression={"$isArray": [{"$literal": [None, None]}]}, + expected=True, + msg="$isArray should return true for literal array of nulls", + ), + ExpressionTestCase( + "literal_string", + doc=None, + expression={"$isArray": ["hello"]}, + expected=False, + msg="$isArray should return false for literal string", + ), + ExpressionTestCase( + "literal_int", + doc=None, + expression={"$isArray": [42]}, + expected=False, + msg="$isArray should return false for literal int", + ), + ExpressionTestCase( + "literal_bool_true", + doc=None, + expression={"$isArray": [True]}, + expected=False, + msg="$isArray should return false for literal true", + ), + ExpressionTestCase( + "literal_bool_false", + doc=None, + expression={"$isArray": [False]}, + expected=False, + msg="$isArray should return false for literal false", + ), + ExpressionTestCase( + "literal_null", + doc=None, + expression={"$isArray": [None]}, + expected=False, + msg="$isArray should return false for literal null", + ), + ExpressionTestCase( + "literal_object", + doc=None, + expression={"$isArray": [{"$literal": {"a": 1}}]}, + expected=False, + msg="$isArray should return false for literal object", + ), +] + +INSERT_TESTS = ( + IS_ARRAY_TRUE_TESTS + IS_ARRAY_FALSE_TESTS + ARRAY_LIKE_TESTS + TEST_SUBSET_FOR_LITERAL +) + + +@pytest.mark.parametrize("test", pytest_params(INSERT_TESTS)) +def test_isArray_insert(collection, test): + """Test $isArray with values from inserted documents.""" + if test.doc is None: + result = execute_expression(collection, test.expression) + else: + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_errors.py new file mode 100644 index 000000000..62e865714 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_errors.py @@ -0,0 +1,27 @@ +""" +Error and argument handling tests for $isArray expression. + +Tests arity errors. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.error_codes import EXPRESSION_TYPE_MISMATCH_ERROR + +# Arity errors +ARITY_ERROR_TESTS = [ + pytest.param({"$isArray": []}, id="zero_args"), + pytest.param({"$isArray": [1, 2]}, id="two_args"), + pytest.param({"$isArray": [1, 2, 3]}, id="three_args"), +] + + +@pytest.mark.parametrize("expr", ARITY_ERROR_TESTS) +def test_isArray_arity_error(collection, expr): + """Test $isArray errors with wrong number of arguments.""" + result = execute_expression(collection, expr) + assert_expression_result(result, error_code=EXPRESSION_TYPE_MISMATCH_ERROR) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_expressions.py new file mode 100644 index 000000000..ab5e1680f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_expressions.py @@ -0,0 +1,165 @@ +""" +Expression, field path, and variable tests for $isArray expression. + +Tests nested expressions, field path lookups, composite paths, +null/missing handling, self-nesting, system variables, +and large input. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +SELF_NESTING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="self_nested", + expression={"$isArray": [{"$isArray": "$arr"}]}, + doc={"arr": [1, 2]}, + expected=False, + msg="Inner returns bool, outer returns false", + ), +] + +# Field path lookups +FIELD_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="nested_field_array", + expression={"$isArray": "$a.b"}, + doc={"a": {"b": [1, 2]}}, + expected=True, + msg="Nested array field", + ), + ExpressionTestCase( + id="deeply_nested_field_array", + expression={"$isArray": "$a.b.c"}, + doc={"a": {"b": {"c": [1]}}}, + expected=True, + msg="Deeply nested array field", + ), + ExpressionTestCase( + id="numeric_index_on_object_key", + expression={"$isArray": "$a.0.b"}, + doc={"a": {"0": {"b": [1]}}}, + expected=True, + msg="Numeric key on object resolves to array", + ), +] + +# Composite array paths +COMPOSITE_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="composite_array_path", + expression={"$isArray": "$a.b"}, + doc={"a": [{"b": 1}, {"b": 2}]}, + expected=True, + msg="Composite array path should resolve to array", + ), + ExpressionTestCase( + id="composite_empty_array", + expression={"$isArray": "$a.b"}, + doc={"a": []}, + expected=True, + msg="Composite on empty array resolves to empty array", + ), + ExpressionTestCase( + id="composite_three_elements", + expression={"$isArray": "$a.b"}, + doc={"a": [{"b": 1}, {"b": 2}, {"b": 3}]}, + expected=True, + msg="Three-element composite resolves to array", + ), +] + +# Deep composite array traversal +DEEP_COMPOSITE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="array_at_leaf", + expression={"$isArray": "$a.b.c.d"}, + doc={"a": {"b": {"c": {"d": [1, 2, 3]}}}}, + expected=True, + msg="Array at leaf level", + ), + ExpressionTestCase( + id="array_at_c", + expression={"$isArray": "$a.b.c.d"}, + doc={"a": {"b": {"c": [{"d": 1}]}}}, + expected=True, + msg="Array at c level", + ), +] + +# Null and missing handling +NULL_MISSING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="missing_field", + expression={"$isArray": "$nonexistent"}, + doc={"other": 1}, + expected=False, + msg="Missing field should return false", + ), + ExpressionTestCase( + id="missing_nested_partial_path", + expression={"$isArray": "$a.b"}, + doc={"a": 1}, + expected=False, + msg="Nested field on scalar parent returns false", + ), + ExpressionTestCase( + id="missing_nested_empty_doc", + expression={"$isArray": "$a.b"}, + doc={"x": 1}, + expected=False, + msg="Missing nested field returns false", + ), +] + +# System variables +SYSTEM_VAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="root_variable", + expression={"$isArray": "$$ROOT"}, + doc={"a": 1}, + expected=False, + msg="$$ROOT is object, returns false", + ), + ExpressionTestCase( + id="current_variable", + expression={"$isArray": "$$CURRENT"}, + doc={"a": 1}, + expected=False, + msg="$$CURRENT is object, returns false", + ), + ExpressionTestCase( + id="let_array_variable", + expression={"$let": {"vars": {"x": "$arr"}, "in": {"$isArray": "$$x"}}}, + doc={"arr": [1, 2]}, + expected=True, + msg="$let var bound to array returns true", + ), +] + +# Aggregate and test +ALL_EXPR_TESTS = ( + FIELD_LOOKUP_TESTS + + COMPOSITE_PATH_TESTS + + DEEP_COMPOSITE_TESTS + + NULL_MISSING_TESTS + + SYSTEM_VAR_TESTS + + SELF_NESTING_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_EXPR_TESTS)) +def test_isArray_expression(collection, test): + """Test $isArray with field paths and expressions.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_smoke_expression_isArray.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_smoke_isArray.py similarity index 100% rename from documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_smoke_expression_isArray.py rename to documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_smoke_isArray.py diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_array_arrayElemAt_indexOfArray_in_slice.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_array_arrayElemAt_indexOfArray_in_slice.py new file mode 100644 index 000000000..8d742302e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_array_arrayElemAt_indexOfArray_in_slice.py @@ -0,0 +1,358 @@ +""" +Combination tests for array expression operators: $arrayElemAt, $indexOfArray, $in, $slice. + +Tests that verify these operators work correctly when composed with each other +and with other operators like $concatArrays, $reverseArray, $filter, $map, $size, etc. +""" + +import pytest +from bson import Decimal128, MaxKey, MinKey, Regex + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.parametrize import pytest_params + +ARRAY_ELEM_AT_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="arrayElemAt_index_from_indexOfArray", + expression={"$arrayElemAt": [[10, 20, 30], {"$indexOfArray": [[10, 20, 30], 30]}]}, + expected=30, + msg="Should use $indexOfArray result as index", + ), + ExpressionTestCase( + id="arrayElemAt_last_element_via_size", + expression={"$arrayElemAt": [[10, 20, 30], {"$subtract": [{"$size": [[10, 20, 30]]}, 1]}]}, + expected=30, + msg="Should access last element via $size - 1", + ), + ExpressionTestCase( + id="arrayElemAt_elem_from_slice", + expression={"$arrayElemAt": [{"$slice": [[10, 20, 30, 40], -2]}, 0]}, + expected=30, + msg="Should access element from $slice result", + ), + ExpressionTestCase( + id="arrayElemAt_elem_from_slice_3arg", + expression={"$arrayElemAt": [{"$slice": [[10, 20, 30, 40], 1, 2]}, 1]}, + expected=30, + msg="Should access element from $slice 3-arg result", + ), + ExpressionTestCase( + id="arrayElemAt_elem_from_reverseArray", + expression={"$arrayElemAt": [{"$reverseArray": [[10, 20, 30]]}, 0]}, + expected=30, + msg="Should access element from $reverseArray result", + ), + ExpressionTestCase( + id="arrayElemAt_elem_from_concatArrays", + expression={"$arrayElemAt": [{"$concatArrays": [[10, 20], [30, 40]]}, 2]}, + expected=30, + msg="Should access element from $concatArrays result", + ), + ExpressionTestCase( + id="arrayElemAt_computed_index", + expression={"$arrayElemAt": [[10, 20, 30], {"$subtract": [3, 1]}]}, + expected=30, + msg="Should use computed index from $subtract", + ), +] + +# $in combinations +IN_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="in_value_from_add", + expression={"$in": [{"$add": [1, 1]}, [1, 2, 3]]}, + expected=True, + msg="Should find value computed by $add", + ), + ExpressionTestCase( + id="in_array_from_concatArrays", + expression={"$in": [3, {"$concatArrays": [[1, 2], [3, 4]]}]}, + expected=True, + msg="Should search in $concatArrays result", + ), + ExpressionTestCase( + id="in_value_from_arrayElemAt", + expression={"$in": [{"$arrayElemAt": [[10, 20, 30], 1]}, [5, 20, 35]]}, + expected=True, + msg="Should find value from $arrayElemAt", + ), + ExpressionTestCase( + id="in_array_from_filter", + expression={ + "$in": [ + 4, + { + "$filter": { + "input": [1, 2, 3, 4, 5], + "as": "n", + "cond": {"$gte": ["$$n", 3]}, + } + }, + ] + }, + expected=True, + msg="Should search in $filter result", + ), + ExpressionTestCase( + id="in_array_from_map", + expression={ + "$in": [ + 20, + { + "$map": { + "input": [1, 2, 3], + "as": "n", + "in": {"$multiply": ["$$n", 10]}, + } + }, + ] + }, + expected=True, + msg="Should search in $map result", + ), + ExpressionTestCase( + id="in_array_from_reverseArray", + expression={"$in": [1, {"$reverseArray": [[1, 2, 3]]}]}, + expected=True, + msg="Should search in $reverseArray result", + ), + ExpressionTestCase( + id="in_cond_with_inner_in", + expression={"$in": [5, {"$cond": [{"$in": ["a", ["a", "b"]]}, [5, 6], [7, 8]]}]}, + expected=True, + msg="Should search in $cond-selected array", + ), + ExpressionTestCase( + id="in_inside_cond", + expression={"$cond": [{"$in": [2, [1, 2, 3]]}, "found", "not_found"]}, + expected="found", + msg="Should use $in result in $cond", + ), + ExpressionTestCase( + id="in_value_from_indexOfArray", + expression={"$in": [{"$indexOfArray": [[10, 20, 30], 20]}, [0, 1, 2]]}, + expected=True, + msg="Should find $indexOfArray result in array", + ), + ExpressionTestCase( + id="in_nested_decimal128", + expression={ + "$in": [ + {"$arrayElemAt": [[Decimal128("1.1"), Decimal128("2.2")], 1]}, + [Decimal128("2.2"), Decimal128("3.3")], + ] + }, + expected=True, + msg="Should find Decimal128 from $arrayElemAt in array", + ), +] + +# $indexOfArray combinations +INDEX_OF_ARRAY_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="indexOfArray_result_as_arrayElemAt_index", + expression={"$arrayElemAt": [[10, 20, 30], {"$indexOfArray": [[10, 20, 30], 20]}]}, + expected=20, + msg="Should use $indexOfArray result as $arrayElemAt index", + ), + ExpressionTestCase( + id="indexOfArray_search_from_add", + expression={"$indexOfArray": [[1, 2, 3], {"$add": [1, 1]}]}, + expected=1, + msg="Should search for value computed by $add", + ), + ExpressionTestCase( + id="indexOfArray_array_from_concatArrays", + expression={"$indexOfArray": [{"$concatArrays": [[1, 2], [3, 4]]}, 3]}, + expected=2, + msg="Should search in $concatArrays result", + ), + ExpressionTestCase( + id="indexOfArray_array_from_filter", + expression={ + "$indexOfArray": [ + {"$filter": {"input": [1, 2, 3, 4, 5], "cond": {"$gt": ["$$this", 2]}}}, + 4, + ] + }, + expected=1, + msg="Should search in $filter result", + ), + ExpressionTestCase( + id="indexOfArray_result_in_cond", + expression={ + "$cond": [{"$gte": [{"$indexOfArray": [[1, 2, 3], 2]}, 0]}, "found", "not_found"] + }, + expected="found", + msg="Should use $indexOfArray result in $cond", + ), + ExpressionTestCase( + id="indexOfArray_start_from_subtract", + expression={"$indexOfArray": [[1, 2, 1, 2], 1, {"$subtract": [3, 1]}]}, + expected=2, + msg="Should use $subtract result as start index", + ), + ExpressionTestCase( + id="indexOfArray_via_arrayElemAt", + expression={ + "$indexOfArray": [ + ["a", "b", "c", "d"], + { + "$arrayElemAt": [ + ["a", "b", "c", "d"], + {"$indexOfArray": [[10, 20, 30], 20]}, + ] + }, + ] + }, + expected=1, + msg="Should search for value from nested $arrayElemAt/$indexOfArray", + ), + ExpressionTestCase( + id="indexOfArray_subarray_mixed_bson", + expression={ + "$indexOfArray": [ + [[MinKey(), MaxKey()], [1, 2], "x"], + { + "$arrayElemAt": [ + [[MinKey(), MaxKey()], [1, 2], "x"], + {"$indexOfArray": [[[MinKey(), MaxKey()], [1, 2], "x"], [1, 2]]}, + ] + }, + ] + }, + expected=1, + msg="Should find mixed BSON subarray via nested operators", + ), + ExpressionTestCase( + id="indexOfArray_triple_nested_decimal128", + expression={ + "$indexOfArray": [ + [Decimal128("1.1"), Decimal128("2.2"), Decimal128("3.3")], + { + "$arrayElemAt": [ + [Decimal128("1.1"), Decimal128("2.2"), Decimal128("3.3")], + { + "$indexOfArray": [ + [Decimal128("1.1"), Decimal128("2.2"), Decimal128("3.3")], + Decimal128("3.3"), + ] + }, + ] + }, + ] + }, + expected=2, + msg="Should resolve triple-nested Decimal128 operators", + ), +] + +# $slice combinations +SLICE_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="slice_array_from_concatArrays", + expression={"$slice": [{"$concatArrays": [[1, 2], [3, 4, 5]]}, 3]}, + expected=[1, 2, 3], + msg="Should slice $concatArrays result", + ), + ExpressionTestCase( + id="slice_n_from_subtract", + expression={"$slice": [[1, 2, 3, 4, 5], {"$subtract": [5, 2]}]}, + expected=[1, 2, 3], + msg="Should use $subtract result as n", + ), + ExpressionTestCase( + id="slice_array_from_filter", + expression={ + "$slice": [ + { + "$filter": { + "input": [1, 2, 3, 4, 5], + "as": "n", + "cond": {"$gte": ["$$n", 3]}, + } + }, + 2, + ] + }, + expected=[3, 4], + msg="Should slice $filter result", + ), + ExpressionTestCase( + id="slice_position_from_indexOfArray", + expression={ + "$slice": [ + [10, 20, 30, 40, 50], + {"$indexOfArray": [[10, 20, 30, 40, 50], 30]}, + 2, + ] + }, + expected=[30, 40], + msg="Should use $indexOfArray result as position", + ), + ExpressionTestCase( + id="slice_array_from_map", + expression={ + "$slice": [ + { + "$map": { + "input": [1, 2, 3], + "as": "n", + "in": {"$multiply": ["$$n", 10]}, + } + }, + 2, + ] + }, + expected=[10, 20], + msg="Should slice $map result", + ), + ExpressionTestCase( + id="slice_array_from_reverseArray", + expression={"$slice": [{"$reverseArray": [[1, 2, 3, 4, 5]]}, 3]}, + expected=[5, 4, 3], + msg="Should slice $reverseArray result", + ), + ExpressionTestCase( + id="slice_n_from_size", + expression={ + "$slice": [[10, 20, 30, 40], {"$subtract": [{"$size": [[10, 20, 30, 40]]}, 1]}] + }, + expected=[10, 20, 30], + msg="Should use $size-based computation as n", + ), +] + +# Aggregate all combination tests +ALL_COMBINATION_TESTS = ( + ARRAY_ELEM_AT_COMBINATION_TESTS + + IN_COMBINATION_TESTS + + INDEX_OF_ARRAY_COMBINATION_TESTS + + SLICE_COMBINATION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_COMBINATION_TESTS)) +def test_combination_expression(collection, test): + """Test array operators composed with other operators.""" + result = execute_expression(collection, test.expression) + assert_expression_result(result, expected=test.expected, msg=test.msg) + + +# Standalone tests for behavior that doesn't fit the dataclass pattern +def test_arrayElemAt_oob_is_missing_not_null(collection): + """Test out-of-bounds result is truly MISSING (field absent), not null.""" + result = execute_expression(collection, {"$type": {"$arrayElemAt": [[1, 2, 3], 10]}}) + assert_expression_result(result, expected="missing") + + +def test_arrayElemAt_regex_type_preserved(collection): + """Test $arrayElemAt preserves regex element type.""" + result = execute_expression(collection, {"$type": {"$arrayElemAt": [[Regex("abc")], 0]}}) + assert_expression_result(result, expected="regex") diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_filter.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_filter.py new file mode 100644 index 000000000..87a7d2cba --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_filter.py @@ -0,0 +1,65 @@ +""" +Combination tests for $filter composed with other operators. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +FILTER_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="filter_then_size", + expression={"$size": {"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 2]}}}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=3, + msg="Size of filtered array", + ), + ExpressionTestCase( + id="map_then_filter", + expression={ + "$filter": { + "input": {"$map": {"input": "$arr", "in": {"$multiply": ["$$this", 2]}}}, + "cond": {"$gt": ["$$this", 5]}, + } + }, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[6, 8, 10], + msg="Should filter mapped array", + ), + ExpressionTestCase( + id="isArray_on_filter_result", + expression={"$isArray": {"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 0]}}}}, + doc={"arr": [1, 2, 3]}, + expected=True, + msg="$isArray on $filter result should return true", + ), + ExpressionTestCase( + id="filter_result_into_reduce", + expression={ + "$reduce": { + "input": {"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 3]}}}, + "initialValue": 0, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=9, + msg="$reduce of filtered result (4+5=9)", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(FILTER_COMBINATION_TESTS)) +def test_filter_combination(collection, test): + """Test $filter composed with other operators.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_isArray.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_isArray.py new file mode 100644 index 000000000..5c7566b68 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_isArray.py @@ -0,0 +1,54 @@ +""" +Combination tests for $isArray composed with other operators. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +ISARRAY_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + id="isarray_guard_array", + expression={"$cond": {"if": {"$isArray": "$arr"}, "then": {"$size": "$arr"}, "else": "NA"}}, + doc={"arr": [1, 2]}, + expected=2, + msg="$isArray guard should allow $size on array", + ), + ExpressionTestCase( + id="isarray_on_concatArrays", + expression={"$isArray": {"$concatArrays": ["$a", "$b"]}}, + doc={"a": [1], "b": [2]}, + expected=True, + msg="$isArray on $concatArrays result should return true", + ), + ExpressionTestCase( + id="isarray_on_objectToArray", + expression={"$isArray": {"$objectToArray": "$obj"}}, + doc={"obj": {"a": 1}}, + expected=True, + msg="$isArray on $objectToArray result should return true", + ), + ExpressionTestCase( + id="isarray_on_non_array_expression", + expression={"$isArray": {"$add": ["$x", "$y"]}}, + doc={"x": 1, "y": 2}, + expected=False, + msg="$isArray on $add result should return false", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ISARRAY_COMBINATION_TESTS)) +def test_isArray_combination(collection, test): + """Test $isArray composed with other operators.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + )