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..8e9ba4e4f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_as_errors.py @@ -0,0 +1,136 @@ +""" +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 +from documentdb_tests.framework.test_constants import ( + FLOAT_INFINITY, + FLOAT_NAN, +) + +INVALID_AS_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "type_int", + expression={"$filter": {"input": [1, 2, 3], "as": 1, "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject int as variable name", + ), + ExpressionTestCase( + "type_long", + expression={"$filter": {"input": [1, 2, 3], "as": Int64(1), "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject Int64 as variable name", + ), + ExpressionTestCase( + "type_object", + expression={"$filter": {"input": [1, 2, 3], "as": {"a": 1}, "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject object as variable name", + ), + ExpressionTestCase( + "type_array", + expression={"$filter": {"input": [1, 2, 3], "as": [1], "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject array as variable name", + ), + ExpressionTestCase( + "type_minkey", + expression={"$filter": {"input": [1, 2, 3], "as": MinKey(), "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject MinKey as variable name", + ), + ExpressionTestCase( + "type_maxkey", + expression={"$filter": {"input": [1, 2, 3], "as": MaxKey(), "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject MaxKey as variable name", + ), + ExpressionTestCase( + "type_bindata", + expression={"$filter": {"input": [1, 2, 3], "as": Binary(b"\x00", 0), "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject Binary as variable name", + ), + ExpressionTestCase( + "type_objectid", + expression={"$filter": {"input": [1, 2, 3], "as": ObjectId(), "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject ObjectId as variable name", + ), + ExpressionTestCase( + "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="$filter should reject datetime as variable name", + ), + ExpressionTestCase( + "type_timestamp", + expression={"$filter": {"input": [1, 2, 3], "as": Timestamp(0, 0), "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject Timestamp as variable name", + ), + ExpressionTestCase( + "type_regex", + expression={"$filter": {"input": [1, 2, 3], "as": Regex("pattern"), "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject Regex as variable name", + ), + ExpressionTestCase( + "type_bool_true", + expression={"$filter": {"input": [1, 2, 3], "as": True, "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject True as variable name", + ), + ExpressionTestCase( + "type_null", + expression={"$filter": {"input": [1, 2, 3], "as": None, "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject None as variable name", + ), + ExpressionTestCase( + "type_empty_string", + expression={"$filter": {"input": [1, 2, 3], "as": "", "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject '' as variable name", + ), + ExpressionTestCase( + "type_nan", + expression={"$filter": {"input": [1, 2, 3], "as": FLOAT_NAN, "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject NaN as variable name", + ), + ExpressionTestCase( + "type_infinity", + expression={"$filter": {"input": [1, 2, 3], "as": FLOAT_INFINITY, "cond": True}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$filter should reject inf as variable name", + ), +] + + +@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..2b15aa58d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_bson_types.py @@ -0,0 +1,322 @@ +""" +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, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_TRAILING_ZERO, + DECIMAL128_TWO_AND_HALF, + 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( + "int64_values", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [Int64(1), Int64(2), Int64(3)]}, + expected=[Int64(1), Int64(2), Int64(3)], + msg="$filter should preserve Int64 values", + ), + ExpressionTestCase( + "decimal128_values", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [DECIMAL128_ONE_AND_HALF, DECIMAL128_TWO_AND_HALF]}, + expected=[DECIMAL128_ONE_AND_HALF, DECIMAL128_TWO_AND_HALF], + msg="$filter should preserve Decimal128 values", + ), + ExpressionTestCase( + "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="$filter should preserve datetime values", + ), + ExpressionTestCase( + "objectid_values", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [ObjectId("000000000000000000000001"), ObjectId("000000000000000000000002")]}, + expected=[ObjectId("000000000000000000000001"), ObjectId("000000000000000000000002")], + msg="$filter should preserve ObjectId values", + ), + ExpressionTestCase( + "binary_values", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [Binary(b"\x01", 0), Binary(b"\x02", 0)]}, + expected=[b"\x01", b"\x02"], + msg="$filter should preserve Binary values", + ), + ExpressionTestCase( + "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="$filter should preserve Binary subtype", + ), + ExpressionTestCase( + "regex_values", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [Regex("^a", "i"), Regex("^b", "i")]}, + expected=[Regex("^a", "i"), Regex("^b", "i")], + msg="$filter should preserve Regex values", + ), + ExpressionTestCase( + "timestamp_values", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [Timestamp(1, 0), Timestamp(2, 0)]}, + expected=[Timestamp(1, 0), Timestamp(2, 0)], + msg="$filter should preserve Timestamp values", + ), + ExpressionTestCase( + "minkey_maxkey", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [MinKey(), MaxKey()]}, + expected=[MinKey(), MaxKey()], + msg="$filter should preserve MinKey/MaxKey values", + ), + ExpressionTestCase( + "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="$filter should preserve UUID binary values", + ), +] + +# Mixed BSON types +MIXED_BSON_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "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="$filter should preserve mixed BSON types", + ), + ExpressionTestCase( + "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="$filter should preserve dates, ObjectIds, timestamps", + ), +] + +# Special numeric values as elements +SPECIAL_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "infinity_values", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [FLOAT_INFINITY, FLOAT_NEGATIVE_INFINITY]}, + expected=[FLOAT_INFINITY, FLOAT_NEGATIVE_INFINITY], + msg="$filter should preserve infinity values", + ), + ExpressionTestCase( + "decimal128_infinity", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [DECIMAL128_INFINITY, DECIMAL128_NEGATIVE_INFINITY]}, + expected=[DECIMAL128_INFINITY, DECIMAL128_NEGATIVE_INFINITY], + msg="$filter should preserve Decimal128 infinity values", + ), + ExpressionTestCase( + "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="$filter should preserve numeric boundary values", + ), + ExpressionTestCase( + "negative_zero", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [DOUBLE_NEGATIVE_ZERO, DECIMAL128_NEGATIVE_ZERO]}, + expected=[DOUBLE_NEGATIVE_ZERO, DECIMAL128_NEGATIVE_ZERO], + msg="$filter should preserve negative zero values", + ), +] + +# Decimal128 precision preservation +DECIMAL128_PRECISION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal128_trailing_zeros", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [DECIMAL128_TRAILING_ZERO, Decimal128("1.00"), Decimal128("1.000")]}, + expected=[DECIMAL128_TRAILING_ZERO, Decimal128("1.00"), Decimal128("1.000")], + msg="$filter decimal128 trailing zeros preserved", + ), + ExpressionTestCase( + "decimal128_nan", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [DECIMAL128_NAN, Decimal128("1")]}, + expected=[DECIMAL128_NAN, Decimal128("1")], + msg="$filter decimal128 NaN preserved", + ), +] + +# BSON type filtering with $eq condition +BSON_FILTER_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "filter_int64", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", Int64(2)]}}}, + doc={"arr": [Int64(1), Int64(2), Int64(3)]}, + expected=[Int64(2)], + msg="$filter should filter and preserve Int64", + ), + ExpressionTestCase( + "filter_decimal128", + expression={ + "$filter": {"input": "$arr", "cond": {"$eq": ["$$this", DECIMAL128_TWO_AND_HALF]}} + }, + doc={"arr": [DECIMAL128_ONE_AND_HALF, DECIMAL128_TWO_AND_HALF]}, + expected=[DECIMAL128_TWO_AND_HALF], + msg="$filter should filter and preserve Decimal128", + ), + ExpressionTestCase( + "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="$filter should filter and preserve datetime", + ), + ExpressionTestCase( + "filter_objectid", + expression={ + "$filter": { + "input": "$arr", + "cond": {"$eq": ["$$this", ObjectId("000000000000000000000001")]}, + } + }, + doc={"arr": [ObjectId("000000000000000000000001"), ObjectId("000000000000000000000002")]}, + expected=[ObjectId("000000000000000000000001")], + msg="$filter should filter and preserve ObjectId", + ), + ExpressionTestCase( + "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="$filter should filter and preserve Binary subtype", + ), + ExpressionTestCase( + "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="$filter should filter and preserve Timestamp", + ), + ExpressionTestCase( + "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="$filter should filter and preserve Regex", + ), + ExpressionTestCase( + "filter_minkey", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", MinKey()]}}}, + doc={"arr": [MinKey(), MaxKey()]}, + expected=[MinKey()], + msg="$filter should filter and preserve MinKey", + ), + ExpressionTestCase( + "filter_decimal128_nan_not_gte", + expression={"$filter": {"input": "$arr", "cond": {"$gte": ["$$this", 1]}}}, + doc={"arr": [DECIMAL128_NAN]}, + expected=[], + msg="$filter decimal128 NaN not >= 1", + ), + ExpressionTestCase( + "filter_decimal128_neg_inf_not_gte", + expression={"$filter": {"input": "$arr", "cond": {"$gte": ["$$this", 1]}}}, + doc={"arr": [DECIMAL128_NEGATIVE_INFINITY]}, + expected=[], + msg="$filter decimal128 -Infinity not >= 1", + ), + ExpressionTestCase( + "filter_decimal128_inf_gte", + expression={"$filter": {"input": "$arr", "cond": {"$gte": ["$$this", 1]}}}, + doc={"arr": [DECIMAL128_INFINITY]}, + expected=[DECIMAL128_INFINITY], + msg="$filter 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..5c1db1f96 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_core_behavior.py @@ -0,0 +1,424 @@ +""" +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 ( + DECIMAL128_ZERO, + DOUBLE_ZERO, + INT32_MAX, + INT64_ZERO, +) + +# Success: basic filtering +BASIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "gt_filter", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 3]}}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[4, 5], + msg="$filter should keep elements greater than 3", + ), + ExpressionTestCase( + "gte_filter", + expression={"$filter": {"input": "$arr", "cond": {"$gte": ["$$this", 3]}}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[3, 4, 5], + msg="$filter should keep elements >= 3", + ), + ExpressionTestCase( + "lt_filter", + expression={"$filter": {"input": "$arr", "cond": {"$lt": ["$$this", 3]}}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[1, 2], + msg="$filter should keep elements less than 3", + ), + ExpressionTestCase( + "eq_filter", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", 2]}}}, + doc={"arr": [1, 2, 3, 2, 1]}, + expected=[2, 2], + msg="$filter should keep elements equal to 2", + ), + ExpressionTestCase( + "ne_filter", + expression={"$filter": {"input": "$arr", "cond": {"$ne": ["$$this", 2]}}}, + doc={"arr": [1, 2, 3, 2, 1]}, + expected=[1, 3, 1], + msg="$filter should keep elements not equal to 2", + ), + ExpressionTestCase( + "none_match", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 100]}}}, + doc={"arr": [1, 2, 3]}, + expected=[], + msg="$filter should return empty when none match", + ), + ExpressionTestCase( + "string_filter", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", "abcd"]}}}, + doc={"arr": ["abcd", "efgh", "abcd", "zyz"]}, + expected=["abcd", "abcd"], + msg="$filter should filter strings abcd", + ), + ExpressionTestCase( + "bool_cond_true", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [1, 2, 3]}, + expected=[1, 2, 3], + msg="$filter literal true cond should keep all elements", + ), + ExpressionTestCase( + "bool_cond_false", + expression={"$filter": {"input": "$arr", "cond": False}}, + doc={"arr": [1, 2, 3]}, + expected=[], + msg="$filter literal false cond should keep no elements", + ), + ExpressionTestCase( + "empty_array", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 0]}}}, + doc={"arr": []}, + expected=[], + msg="$filter should return empty array for empty input", + ), + ExpressionTestCase( + "null_input", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 0]}}}, + doc={"arr": None}, + expected=None, + msg="$filter should return null when input is null", + ), + ExpressionTestCase( + "custom_as_var", + expression={"$filter": {"input": "$arr", "as": "item", "cond": {"$gt": ["$$item", 3]}}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[4, 5], + msg="$filter should use custom 'as' variable name", + ), + ExpressionTestCase( + "single_element_match", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 0]}}}, + doc={"arr": [42]}, + expected=[42], + msg="$filter should keep single matching element", + ), + ExpressionTestCase( + "large_array_1000", + expression={"$filter": {"input": "$arr", "cond": {"$gte": ["$$this", 500]}}}, + doc={"arr": list(range(1000))}, + expected=list(range(500, 1000)), + msg="$filter should filter large array", + ), +] + +# Success: nested arrays (filter does not recurse) +NESTED_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "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="$filter should filter subarrays by size", + ), + ExpressionTestCase( + "nested_arrays_identity", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": [[1], [2], [3]]}, + expected=[[1], [2], [3]], + msg="$filter should preserve nested arrays when all match", + ), +] + +# Success: elements with null +NULL_ELEMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "filter_out_nulls", + expression={"$filter": {"input": "$arr", "cond": {"$ne": ["$$this", None]}}}, + doc={"arr": [1, None, 2, None, 3]}, + expected=[1, 2, 3], + msg="$filter should filter out null elements", + ), + ExpressionTestCase( + "keep_only_nulls", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", None]}}}, + doc={"arr": [1, None, 2, None]}, + expected=[None, None], + msg="$filter should keep only null elements", + ), +] + +# Success: objects as elements +OBJECT_ELEMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "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="$filter should filter objects by nested field", + ), + ExpressionTestCase( + "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="$filter should return all matching duplicate objects", + ), + ExpressionTestCase( + "single_object_no_match", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this.a", 10]}}}, + doc={"arr": [{"a": 1}]}, + expected=[], + msg="$filter should return empty array when single object does not match", + ), +] + +# Success: limit parameter +LIMIT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "limit_1", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 2]}, "limit": 1}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[3], + msg="$filter should return at most 1 matching element", + ), + ExpressionTestCase( + "limit_2", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 2]}, "limit": 2}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[3, 4], + msg="$filter should return at most 2 matching elements", + ), + ExpressionTestCase( + "limit_exceeds_matches", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 3]}, "limit": 10}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[4, 5], + msg="$filter limit > matches should return all matches", + ), + ExpressionTestCase( + "limit_on_empty", + expression={"$filter": {"input": "$arr", "cond": True, "limit": 5}}, + doc={"arr": []}, + expected=[], + msg="$filter limit on empty array returns empty", + ), + ExpressionTestCase( + "limit_with_none_matching", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 100]}, "limit": 2}}, + doc={"arr": [1, 2, 3]}, + expected=[], + msg="$filter limit with no matches returns empty", + ), + ExpressionTestCase( + "limit_long", + expression={ + "$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 2]}, "limit": Int64(1)} + }, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[3], + msg="$filter long limit should work", + ), + ExpressionTestCase( + "limit_double_whole", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 2]}, "limit": 1.0}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[3], + msg="$filter whole-number double limit should work", + ), + ExpressionTestCase( + "limit_decimal128", + expression={ + "$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 2]}, "limit": Decimal128("1")} + }, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[3], + msg="$filter decimal128 limit should work", + ), + ExpressionTestCase( + "limit_with_duplicates", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", 2]}, "limit": 2}}, + doc={"arr": [2, 2, 2]}, + expected=[2, 2], + msg="$filter limit 2 with duplicates returns first 2 matches", + ), + ExpressionTestCase( + "limit_int32_max", + expression={"$filter": {"input": "$arr", "cond": True, "limit": INT32_MAX}}, + doc={"arr": [1, 2, 3]}, + expected=[1, 2, 3], + msg="$filter iNT32_MAX limit should return all matches", + ), + ExpressionTestCase( + "limit_null", + expression={"$filter": {"input": "$arr", "cond": True, "limit": None}}, + doc={"arr": [1, 2, 3]}, + expected=[1, 2, 3], + msg="$filter null limit should return all matches", + ), + ExpressionTestCase( + "limit_missing_field_ref", + expression={"$filter": {"input": "$arr", "cond": True, "limit": "$nonexistent"}}, + doc={"arr": [1, 2, 3]}, + expected=[1, 2, 3], + msg="$filter missing field reference for limit should return all matches", + ), +] + +# Success: type-based filtering +TYPE_FILTER_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "filter_by_type", + expression={"$filter": {"input": "$arr", "cond": {"$eq": [{"$type": "$$this"}, "int"]}}}, + doc={"arr": [1, "two", True, None, [5], {"a": 1}]}, + expected=[1], + msg="$filter should filter by BSON type", + ), + ExpressionTestCase( + "filter_strings_only", + expression={"$filter": {"input": "$arr", "cond": {"$eq": [{"$type": "$$this"}, "string"]}}}, + doc={"arr": [1, "good", 2, "morning", True]}, + expected=["good", "morning"], + msg="$filter should keep only string elements", + ), +] + +# Success: cond true or false +COND_FALSY_TRUTHY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "cond_nonzero_truthy", + expression={"$filter": {"input": "$arr", "cond": "$$this"}}, + doc={"arr": [0, 1, 2, 0, 3]}, + expected=[1, 2, 3], + msg="$filter non-zero values are truthy", + ), + ExpressionTestCase( + "cond_null_falsy", + expression={"$filter": {"input": "$arr", "cond": "$$this"}}, + doc={"arr": [1, None, 2, None]}, + expected=[1, 2], + msg="$filter null is falsy", + ), + ExpressionTestCase( + "cond_zero_falsy", + expression={"$filter": {"input": "$arr", "cond": 0}}, + doc={"arr": [1, 2]}, + expected=[], + msg="$filter 0 is falsy", + ), + ExpressionTestCase( + "cond_empty_string_truthy", + expression={"$filter": {"input": "$arr", "cond": ""}}, + doc={"arr": [1, 2]}, + expected=[1, 2], + msg="$filter empty string is truthy in MongoDB", + ), + ExpressionTestCase( + "cond_object_truthy", + expression={"$filter": {"input": "$arr", "cond": {"x": 10}}}, + doc={"arr": [1, 2]}, + expected=[1, 2], + msg="$filter object is truthy", + ), + ExpressionTestCase( + "cond_empty_object_truthy", + expression={"$filter": {"input": "$arr", "cond": {}}}, + doc={"arr": [1, 2]}, + expected=[1, 2], + msg="$filter empty object is truthy", + ), + ExpressionTestCase( + "cond_empty_array_truthy", + expression={"$filter": {"input": "$arr", "cond": []}}, + doc={"arr": [1, 2]}, + expected=[1, 2], + msg="$filter empty array is truthy", + ), +] + + +# Success: type strict equality +TYPE_STRICT_EQUALITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "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( + "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( + "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( + "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( + "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="$filter all numeric representations of 1 should match", + ), + ExpressionTestCase( + "numeric_equivalence_zero", + expression={"$filter": {"input": "$arr", "cond": {"$eq": ["$$this", 0]}}}, + doc={"arr": [0, INT64_ZERO, DOUBLE_ZERO, DECIMAL128_ZERO]}, + expected=[0, INT64_ZERO, DOUBLE_ZERO, DECIMAL128_ZERO], + msg="$filter 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..c658b98ed --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/filter/test_filter_errors.py @@ -0,0 +1,445 @@ +""" +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_HALF, + DECIMAL128_INFINITY, + DECIMAL128_MAX, + DECIMAL128_MIN, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + 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( + "string_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": "hello"}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject string input", + ), + ExpressionTestCase( + "int_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": 42}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject int input", + ), + ExpressionTestCase( + "negative_int_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": -42}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject negative int input", + ), + ExpressionTestCase( + "bool_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": True}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject bool input", + ), + ExpressionTestCase( + "bool_false_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": False}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject bool false input", + ), + ExpressionTestCase( + "object_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": {"a": 1}}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject object input", + ), + ExpressionTestCase( + "double_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": 3.14}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject double input", + ), + ExpressionTestCase( + "negative_double_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": -3.14}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject negative double input", + ), + ExpressionTestCase( + "decimal128_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": Decimal128("1")}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject decimal128 input", + ), + ExpressionTestCase( + "int64_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": Int64(1)}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject int64 input", + ), + ExpressionTestCase( + "objectid_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": ObjectId()}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject objectid input", + ), + ExpressionTestCase( + "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="$filter should reject datetime input", + ), + ExpressionTestCase( + "binary_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": Binary(b"x", 0)}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject binary input", + ), + ExpressionTestCase( + "regex_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": Regex("x")}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject regex input", + ), + ExpressionTestCase( + "maxkey_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": MaxKey()}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject maxkey input", + ), + ExpressionTestCase( + "minkey_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": MinKey()}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject minkey input", + ), + ExpressionTestCase( + "timestamp_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": Timestamp(0, 0)}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject timestamp input", + ), +] + +# Error: special float/Decimal128 values +SPECIAL_NUMERIC_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nan_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": FLOAT_NAN}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject NaN input", + ), + ExpressionTestCase( + "inf_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": FLOAT_INFINITY}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject Infinity input", + ), + ExpressionTestCase( + "neg_inf_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": FLOAT_NEGATIVE_INFINITY}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject -Infinity input", + ), + ExpressionTestCase( + "neg_zero_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": DOUBLE_NEGATIVE_ZERO}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject negative zero input", + ), + ExpressionTestCase( + "decimal128_nan_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": DECIMAL128_NAN}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject Decimal128 NaN input", + ), + ExpressionTestCase( + "decimal128_neg_nan_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": DECIMAL128_NEGATIVE_NAN}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject Decimal128 -NaN input", + ), + ExpressionTestCase( + "decimal128_inf_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": DECIMAL128_INFINITY}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject Decimal128 Infinity input", + ), + ExpressionTestCase( + "decimal128_neg_inf_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": DECIMAL128_NEGATIVE_INFINITY}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject Decimal128 -Infinity input", + ), + ExpressionTestCase( + "decimal128_neg_zero_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": DECIMAL128_NEGATIVE_ZERO}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject Decimal128 -0 input", + ), +] + +# Error: numeric boundary values +BOUNDARY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_max_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": INT32_MAX}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject INT32_MAX input", + ), + ExpressionTestCase( + "int32_min_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": INT32_MIN}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject INT32_MIN input", + ), + ExpressionTestCase( + "int64_max_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": INT64_MAX}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject INT64_MAX input", + ), + ExpressionTestCase( + "int64_min_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": INT64_MIN}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject INT64_MIN input", + ), + ExpressionTestCase( + "decimal128_max_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": DECIMAL128_MAX}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject DECIMAL128_MAX input", + ), + ExpressionTestCase( + "decimal128_min_input", + expression={"$filter": {"input": "$arr", "cond": True}}, + doc={"arr": DECIMAL128_MIN}, + error_code=FILTER_INPUT_NOT_ARRAY_ERROR, + msg="$filter should reject DECIMAL128_MIN input", + ), +] + +# Error: invalid limit types +INVALID_LIMIT_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": "1"}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="$filter string limit should error", + ), + ExpressionTestCase( + "bool_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": True}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="$filter bool limit should error", + ), + ExpressionTestCase( + "object_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": {"a": 1}}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="$filter object limit should error", + ), + ExpressionTestCase( + "array_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": [1]}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="$filter array limit should error", + ), +] + +# Error: invalid limit numeric values +INVALID_LIMIT_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": 0}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_POSITIVE_ERROR, + msg="$filter limit 0 should error", + ), + ExpressionTestCase( + "neg_int_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": -1}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_POSITIVE_ERROR, + msg="$filter negative int should error", + ), + ExpressionTestCase( + "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="$filter negative long should error", + ), + ExpressionTestCase( + "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="$filter negative double should error", + ), + ExpressionTestCase( + "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="$filter negative decimal128 should error", + ), + ExpressionTestCase( + "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="$filter fractional 1.5 should error", + ), + ExpressionTestCase( + "fractional_dec_0_5_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": DECIMAL128_HALF}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="$filter fractional decimal128 0.5 should error", + ), + ExpressionTestCase( + "nan_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": FLOAT_NAN}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="$filter naN should error", + ), + ExpressionTestCase( + "inf_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": FLOAT_INFINITY}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="$filter infinity should error", + ), + ExpressionTestCase( + "neg_inf_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": FLOAT_NEGATIVE_INFINITY}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_INTEGRAL_ERROR, + msg="$filter -Infinity should error", + ), + ExpressionTestCase( + "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="$filter decimal128 NaN should error", + ), + ExpressionTestCase( + "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="$filter decimal128 Infinity should error", + ), + ExpressionTestCase( + "neg_zero_double_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": DOUBLE_NEGATIVE_ZERO}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_POSITIVE_ERROR, + msg="$filter -0.0 limit should error", + ), + ExpressionTestCase( + "neg_zero_decimal128_limit", + expression={"$filter": {"input": "$arr", "cond": True, "limit": DECIMAL128_NEGATIVE_ZERO}}, + doc={"arr": [1, 2, 3]}, + error_code=FILTER_LIMIT_NOT_POSITIVE_ERROR, + msg="$filter decimal128 -0 limit should error", + ), +] + +# Error: cond evaluation errors +COND_EVALUATION_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "cond_divide_by_zero", + expression={"$filter": {"input": "$arr", "cond": {"$divide": [1, 0]}}}, + doc={"arr": [1, 2]}, + error_code=BAD_VALUE_ERROR, + msg="$filter 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..207630821 --- /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( + "nested_field_path", + expression={"$filter": {"input": "$a.b", "cond": {"$gt": ["$$this", 2]}}}, + doc={"a": {"b": [1, 2, 3, 4]}}, + expected=[3, 4], + msg="$filter should resolve nested field path", + ), + ExpressionTestCase( + "deeply_nested_field", + expression={"$filter": {"input": "$a.b.c", "cond": True}}, + doc={"a": {"b": {"c": [10, 20]}}}, + expected=[10, 20], + msg="$filter should resolve deeply nested field path", + ), + ExpressionTestCase( + "composite_array_path", + expression={"$filter": {"input": "$a.b", "cond": {"$gt": ["$$this", 1]}}}, + doc={"a": [{"b": 1}, {"b": 2}, {"b": 3}]}, + expected=[2, 3], + msg="$filter composite array path should resolve to array", + ), + ExpressionTestCase( + "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="$filter object key '0' resolves correctly", + ), + ExpressionTestCase( + "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( + "access_outer_field", + expression={"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", "$threshold"]}}}, + doc={"arr": [1, 2, 3, 4, 5], "threshold": 3}, + expected=[4, 5], + msg="$filter should access outer document field in cond", + ), +] + +# $let and system variables +LET_AND_VARIABLE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "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="$filter should work with $let variables", + ), + ExpressionTestCase( + "root_variable", + expression={"$filter": {"input": "$$ROOT.values", "cond": {"$gt": ["$$this", 2]}}}, + doc={"_id": 1, "values": [1, 2, 3, 4]}, + expected=[3, 4], + msg="$filter should work with $$ROOT", + ), + ExpressionTestCase( + "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( + "missing_field", + expression={"$filter": {"input": "$nonexistent", "cond": True}}, + doc={"other": 1}, + expected=None, + msg="$filter missing field should return null", + ), + ExpressionTestCase( + "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( + "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="$filter nested $filter should chain conditions", + ), +] + + +# Limit with field reference +LIMIT_EXPR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "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="$filter limit from field reference", + ), +] + +# Literal array input (not field path) +LITERAL_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal_array_input", + expression={"$filter": {"input": [1, 2, 3, 4, 5], "cond": {"$gt": ["$$this", 3]}}}, + doc={"x": 1}, + expected=[4, 5], + msg="$filter 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..8c680dd05 --- /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( + "null_arg", + expression={"$filter": None}, + error_code=FILTER_NON_OBJECT_ARG_ERROR, + msg="$filter null arg should error", + ), + ExpressionTestCase( + "int_arg", + expression={"$filter": 1}, + error_code=FILTER_NON_OBJECT_ARG_ERROR, + msg="$filter int arg should error", + ), + ExpressionTestCase( + "string_arg", + expression={"$filter": "string"}, + error_code=FILTER_NON_OBJECT_ARG_ERROR, + msg="$filter string arg should error", + ), + ExpressionTestCase( + "array_arg", + expression={"$filter": []}, + error_code=FILTER_NON_OBJECT_ARG_ERROR, + msg="$filter array arg should error", + ), + ExpressionTestCase( + "bool_arg", + expression={"$filter": True}, + error_code=FILTER_NON_OBJECT_ARG_ERROR, + msg="$filter bool arg should error", + ), +] + +# Error: unknown fields +UNKNOWN_FIELD_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "extra_unknown", + expression={"$filter": {"input": [1], "cond": True, "unknown": 1}}, + error_code=FILTER_UNKNOWN_FIELD_ERROR, + msg="$filter extra unknown field should error", + ), + ExpressionTestCase( + "only_unknown", + expression={"$filter": {"dummy": 124}}, + error_code=FILTER_UNKNOWN_FIELD_ERROR, + msg="$filter only unknown field should error", + ), +] + +# Error: missing required fields +MISSING_REQUIRED_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_input", + expression={"$filter": {"as": "x", "cond": True}}, + error_code=FILTER_MISSING_INPUT_ERROR, + msg="$filter missing input should error", + ), + ExpressionTestCase( + "missing_cond", + expression={"$filter": {"input": [1, 2, 3]}}, + error_code=FILTER_MISSING_COND_ERROR, + msg="$filter missing cond should error", + ), + ExpressionTestCase( + "empty_object", + expression={"$filter": {}}, + error_code=FILTER_MISSING_INPUT_ERROR, + msg="$filter 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 92% 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 index 49048c03b..828336022 100644 --- 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 @@ -40,4 +40,4 @@ def test_smoke_expression_filter(collection): ) expected = [{"_id": 1, "filtered": [4, 5]}, {"_id": 2, "filtered": [10, 15, 20, 25]}] - assertSuccess(result, expected, msg="Should support $filter expression") + assertSuccess(result, expected, ignore_doc_order=True, msg="Should support $filter expression") 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..4d852a11e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_bson_types.py @@ -0,0 +1,285 @@ +""" +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, + DECIMAL128_ONE_AND_HALF, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Success: search for various BSON types +BSON_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "bson_int64", + doc={"val": Int64(99), "arr": [Int64(99), 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find Int64 in array", + ), + ExpressionTestCase( + "bson_decimal128", + doc={"val": DECIMAL128_ONE_AND_HALF, "arr": [DECIMAL128_ONE_AND_HALF, 2]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find Decimal128 in array", + ), + ExpressionTestCase( + "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="$in should find datetime in array", + ), + ExpressionTestCase( + "bson_objectid", + doc={ + "val": ObjectId("000000000000000000000001"), + "arr": [ObjectId("000000000000000000000001"), 1], + }, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find ObjectId in array", + ), + ExpressionTestCase( + "bson_binary", + doc={"val": Binary(b"\x01\x02", 0), "arr": [Binary(b"\x01\x02", 0), 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find Binary in array", + ), + ExpressionTestCase( + "bson_regex", + doc={"val": Regex("^abc", "i"), "arr": [Regex("^abc", "i"), 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find Regex in array", + ), + ExpressionTestCase( + "bson_timestamp", + doc={"val": Timestamp(1, 1), "arr": [Timestamp(1, 1), 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find Timestamp in array", + ), + ExpressionTestCase( + "bson_minkey", + doc={"val": MinKey(), "arr": [MinKey(), 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find MinKey in array", + ), + ExpressionTestCase( + "bson_maxkey", + doc={"val": MaxKey(), "arr": [1, MaxKey()]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find MaxKey in array", + ), + ExpressionTestCase( + "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="$in should find UUID binary in array", + ), + # Special float values + ExpressionTestCase( + "float_infinity_in_array", + doc={"val": FLOAT_INFINITY, "arr": [FLOAT_INFINITY, 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find Infinity in array", + ), + ExpressionTestCase( + "float_neg_infinity_in_array", + doc={"val": FLOAT_NEGATIVE_INFINITY, "arr": [FLOAT_NEGATIVE_INFINITY, 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find -Infinity in array", + ), + ExpressionTestCase( + "float_infinity_not_in_array", + doc={"val": FLOAT_INFINITY, "arr": [1, 2, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in should not find Infinity in non-Infinity array", + ), + # Special Decimal128 values + ExpressionTestCase( + "decimal128_infinity_in_array", + doc={"val": DECIMAL128_INFINITY, "arr": [DECIMAL128_INFINITY, 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find Decimal128 Infinity in array", + ), + ExpressionTestCase( + "decimal128_neg_infinity_in_array", + doc={"val": DECIMAL128_NEGATIVE_INFINITY, "arr": [DECIMAL128_NEGATIVE_INFINITY, 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find Decimal128 -Infinity in array", + ), + # NaN equality: NaN == NaN in BSON comparison (unlike IEEE 754) + ExpressionTestCase( + "float_nan_found", + doc={"val": FLOAT_NAN, "arr": [FLOAT_NAN, 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find NaN in array (BSON equality)", + ), + ExpressionTestCase( + "decimal128_nan_found", + doc={"val": DECIMAL128_NAN, "arr": [DECIMAL128_NAN, 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find Decimal128 NaN in array (BSON equality)", + ), + ExpressionTestCase( + "float_nan_matches_decimal128_nan", + doc={"val": FLOAT_NAN, "arr": [DECIMAL128_NAN, 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in float NaN should match Decimal128 NaN cross-type", + ), + ExpressionTestCase( + "decimal128_nan_matches_float_nan", + doc={"val": DECIMAL128_NAN, "arr": [FLOAT_NAN, 1]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in decimal128 NaN should match float NaN cross-type", + ), + # Aggregation $in does NOT pattern-match regex against strings (unlike query $in) + ExpressionTestCase( + "regex_no_pattern_match", + doc={"val": Regex("^a"), "arr": ["abc", "def"]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in regex should not pattern-match strings in aggregation $in", + ), +] + +# Success: numeric type equivalence +NUMERIC_EQUIVALENCE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int_in_doubles", + doc={"val": 1, "arr": [1.0, 2.0]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find int in doubles via numeric equivalence", + ), + ExpressionTestCase( + "int_in_longs", + doc={"val": 1, "arr": [Int64(1), 2]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find int in longs via numeric equivalence", + ), + ExpressionTestCase( + "int_in_decimal128s", + doc={"val": 1, "arr": [Decimal128("1"), 2]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find int in decimal128s via numeric equivalence", + ), + ExpressionTestCase( + "double_in_ints", + doc={"val": 1.0, "arr": [1, 2]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find double in ints via numeric equivalence", + ), + ExpressionTestCase( + "long_in_ints", + doc={"val": Int64(1), "arr": [1, 2]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find long in ints via numeric equivalence", + ), + ExpressionTestCase( + "decimal128_in_ints", + doc={"val": Decimal128("1"), "arr": [1, 2]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find decimal128 in ints via numeric equivalence", + ), + ExpressionTestCase( + "decimal128_in_doubles", + doc={"val": DECIMAL128_ONE_AND_HALF, "arr": [1.5, 2.5]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find decimal128 in doubles via numeric equivalence", + ), +] + +# Negative zero equivalence with positive zero +NEGATIVE_ZERO_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "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( + "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( + "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( + "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..f49c12f60 --- /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( + "found_int", + doc={"val": 2, "arr": [1, 2, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find int in array", + ), + ExpressionTestCase( + "found_first", + doc={"val": 1, "arr": [1, 2, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find first element", + ), + ExpressionTestCase( + "found_last", + doc={"val": 3, "arr": [1, 2, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find last element", + ), + ExpressionTestCase( + "found_string", + doc={"val": "b", "arr": ["a", "b", "c"]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find string in array", + ), + ExpressionTestCase( + "found_bool_true", + doc={"val": True, "arr": [True, False]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find true in array", + ), + ExpressionTestCase( + "found_bool_false", + doc={"val": False, "arr": [True, False]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find false in array", + ), + ExpressionTestCase( + "found_null", + doc={"val": None, "arr": [None, 1, 2]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find null in array", + ), + ExpressionTestCase( + "found_nested_array", + doc={"val": [3, 4], "arr": [[1, 2], [3, 4]]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find nested array", + ), + ExpressionTestCase( + "found_object", + doc={"val": {"a": 1}, "arr": [{"a": 1}, {"b": 2}]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find object in array", + ), + ExpressionTestCase( + "found_single_element", + doc={"val": 42, "arr": [42]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find value in single-element array", + ), + ExpressionTestCase( + "found_duplicate", + doc={"val": 5, "arr": [5, 5, 5]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find value in array of duplicates", + ), +] + +# Success: value not found → False +NOT_FOUND_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "not_found_int", + doc={"val": 4, "arr": [1, 2, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in should not find absent int", + ), + ExpressionTestCase( + "not_found_string", + doc={"val": "z", "arr": ["a", "b"]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in should not find absent string", + ), + ExpressionTestCase( + "not_found_empty_array", + doc={"val": 1, "arr": []}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in should not find value in empty array", + ), + ExpressionTestCase( + "not_found_type_mismatch", + doc={"val": "1", "arr": [1, 2, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in should not find string '1' in int array", + ), + ExpressionTestCase( + "not_found_bool_vs_int", + doc={"val": True, "arr": [1, 0]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in should not find bool in int array", + ), + ExpressionTestCase( + "not_found_null", + doc={"val": None, "arr": [1, 2, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in should not find null in non-null array", + ), + ExpressionTestCase( + "not_found_partial_array", + doc={"val": [1], "arr": [[1, 2], [3, 4]]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in should not find partial array match", + ), + ExpressionTestCase( + "not_found_partial_object", + doc={"val": {"a": 1}, "arr": [{"a": 1, "b": 2}]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in should not find partial object match", + ), +] + +# Success: mixed types in array +MIXED_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "mixed_find_string", + doc={"val": "2", "arr": [1, "2", True, None, [1]]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find string in mixed-type array", + ), + ExpressionTestCase( + "mixed_find_null", + doc={"val": None, "arr": [1, "2", True, None, [1]]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find null in mixed-type array", + ), + ExpressionTestCase( + "mixed_find_array", + doc={"val": [1], "arr": [1, "2", True, None, [1]]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find array in mixed-type array", + ), + ExpressionTestCase( + "mixed_not_found", + doc={"val": "x", "arr": [1, "2", True, None, [1]]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in should not find absent value in mixed-type array", + ), +] + +# Success: large array + +LARGE_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "large_array_found_first", + doc={"val": 0, "arr": list(range(20_000))}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find first element in large array", + ), + ExpressionTestCase( + "large_array_found_last", + doc={"val": 19_999, "arr": list(range(20_000))}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find last element in large array", + ), + ExpressionTestCase( + "large_array_found_middle", + doc={"val": 10_000, "arr": list(range(20_000))}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find middle element in large array", + ), + ExpressionTestCase( + "large_array_not_found", + doc={"val": -1, "arr": list(range(20_000))}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in 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..279549c19 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_errors.py @@ -0,0 +1,188 @@ +""" +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 + +# Property [Not Array]: $in rejects non-array second argument across all BSON types. +NOT_ARRAY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_as_array", + doc={"val": 1, "arr": "hello"}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject string as array arg", + ), + ExpressionTestCase( + "int_as_array", + doc={"val": 1, "arr": 42}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject int as array arg", + ), + ExpressionTestCase( + "double_as_array", + doc={"val": 1, "arr": 3.14}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject double as array arg", + ), + ExpressionTestCase( + "bool_true_as_array", + doc={"val": 1, "arr": True}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject bool true as array arg", + ), + ExpressionTestCase( + "bool_false_as_array", + doc={"val": 1, "arr": False}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject bool false as array arg", + ), + ExpressionTestCase( + "object_as_array", + doc={"val": 1, "arr": {"a": 1}}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject object as array arg", + ), + ExpressionTestCase( + "decimal128_as_array", + doc={"val": 1, "arr": Decimal128("1")}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject decimal128 as array arg", + ), + ExpressionTestCase( + "int64_as_array", + doc={"val": 1, "arr": Int64(1)}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject int64 as array arg", + ), + ExpressionTestCase( + "binary_as_array", + doc={"val": 1, "arr": Binary(b"x", 0)}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject binary as array arg", + ), + ExpressionTestCase( + "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="$in should reject datetime as array arg", + ), + ExpressionTestCase( + "objectid_as_array", + doc={"val": 1, "arr": ObjectId()}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject objectid as array arg", + ), + ExpressionTestCase( + "regex_as_array", + doc={"val": 1, "arr": Regex("x")}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject regex as array arg", + ), + ExpressionTestCase( + "maxkey_as_array", + doc={"val": 1, "arr": MaxKey()}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject maxkey as array arg", + ), + ExpressionTestCase( + "minkey_as_array", + doc={"val": 1, "arr": MinKey()}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject minkey as array arg", + ), + ExpressionTestCase( + "timestamp_as_array", + doc={"val": 1, "arr": Timestamp(0, 0)}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject timestamp as array arg", + ), + ExpressionTestCase( + "null_as_array", + doc={"val": 1, "arr": None}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject null as array arg", + ), +] + +# Property [Missing Field]: $in rejects a missing field as the second argument. +LITERAL_ONLY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_as_array", + doc={"val": 1, "arr": MISSING}, + expression={"$in": ["$val", "$arr"]}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should reject missing as array arg", + ), +] + +# Property [Arity]: $in requires exactly two arguments. +ARITY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_args", + expression={"$in": []}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$in should reject zero arguments", + ), + ExpressionTestCase( + "one_arg", + expression={"$in": [[1, 2, 3]]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$in should reject one argument", + ), + ExpressionTestCase( + "three_args", + expression={"$in": [1, [1, 2], 3]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$in should reject three arguments", + ), +] + +ALL_ERROR_TESTS = NOT_ARRAY_ERROR_TESTS + LITERAL_ONLY_TESTS + ARITY_ERROR_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_ERROR_TESTS)) +def test_in_error(collection, test): + """Test $in error cases.""" + 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_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_expressions.py new file mode 100644 index 000000000..bd6e4efe9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_expressions.py @@ -0,0 +1,113 @@ +""" +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.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 +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Nested Expressions]: $in evaluates nested expressions as arguments. +NESTED_EXPRESSION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_in_in", + expression={"$in": [{"$in": [2, [1, 2, 3]]}, [True, False]]}, + expected=True, + msg="$in should accept nested $in result as search value", + ), +] + +# Property [Field Path Resolution]: $in resolves nested and deeply nested field paths. +FIELD_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_found", + expression={"$in": [20, "$a.b"]}, + doc={"a": {"b": [10, 20, 30]}}, + expected=True, + msg="$in should find value in nested field path array", + ), + ExpressionTestCase( + "nested_field_not_found", + expression={"$in": [99, "$a.b"]}, + doc={"a": {"b": [10, 20, 30]}}, + expected=False, + msg="$in should not find absent value in nested field path array", + ), + ExpressionTestCase( + "deeply_nested_field", + expression={"$in": [7, "$a.b.c"]}, + doc={"a": {"b": {"c": [5, 6, 7]}}}, + expected=True, + msg="$in should resolve deeply nested field path", + ), +] + +# Property [Missing Field]: $in handles missing array and value fields correctly. +MISSING_FIELD_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nonexistent_array_field", + expression={"$in": [1, "$nonexistent"]}, + doc={"other": 1}, + error_code=EXPRESSION_IN_NOT_ARRAY_ERROR, + msg="$in should error when array field does not exist", + ), + ExpressionTestCase( + "nonexistent_value_array_contains_null", + expression={"$in": ["$nonexistent", "$arr"]}, + doc={"arr": [1, None, 3]}, + expected=False, + msg="$in should return false for missing value even when array contains null", + ), + ExpressionTestCase( + "nonexistent_value_array_without_null", + expression={"$in": ["$nonexistent", "$arr"]}, + doc={"arr": [1, 2, 3]}, + expected=False, + msg="$in should return false for missing value in array without null", + ), +] + +# Property [Composite Paths]: $in resolves composite array paths from array-of-objects. +COMPOSITE_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "composite_array_as_array", + expression={"$in": [20, "$x.y"]}, + doc={"x": [{"y": 10}, {"y": 20}, {"y": 30}]}, + expected=True, + msg="$in should find value in composite array path", + ), + ExpressionTestCase( + "composite_array_as_value", + expression={"$in": ["$x.y", [[10, 20, 30], "other"]]}, + doc={"x": [{"y": 10}, {"y": 20}, {"y": 30}]}, + expected=True, + msg="$in should match composite array as search value", + ), +] + +ALL_EXPRESSION_TESTS = ( + NESTED_EXPRESSION_TESTS + FIELD_LOOKUP_TESTS + MISSING_FIELD_TESTS + COMPOSITE_PATH_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_EXPRESSION_TESTS)) +def test_in_expression(collection, test): + """Test $in with expressions, field paths, and composite paths.""" + 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_nested_arrays.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_nested_arrays.py new file mode 100644 index 000000000..16e9946a5 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/in/test_in_nested_arrays.py @@ -0,0 +1,199 @@ +""" +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 +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF + +# Success: nested mixed arrays as search targets +NESTED_MIXED_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_find_object_in_mixed", + doc={"val": {"a": 1}, "arr": [1, "two", {"a": 1}, [3, 4], True]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find object in nested mixed array", + ), + ExpressionTestCase( + "nested_find_array_in_mixed", + doc={"val": [3, 4], "arr": [1, "two", {"a": 1}, [3, 4], True]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find array in nested mixed array", + ), + ExpressionTestCase( + "nested_find_deep_object", + doc={"val": {"a": {"b": 3}}, "arr": [[1, 2], {"a": {"b": 3}}, "x"]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find deep object in array", + ), + ExpressionTestCase( + "nested_find_array_with_mixed_types", + doc={"val": [None, "a", 2], "arr": [1, [None, "a", 2], "b"]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find mixed-type subarray", + ), + ExpressionTestCase( + "nested_find_empty_object", + doc={"val": {}, "arr": [1, {}, [2], "a"]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find empty object in array", + ), + ExpressionTestCase( + "nested_find_empty_array", + doc={"val": [], "arr": [1, {}, [], "a"]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find empty array in array", + ), + ExpressionTestCase( + "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="$in should find subarray with binary and decimal128", + ), + ExpressionTestCase( + "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="$in should find subarray with object and array", + ), + ExpressionTestCase( + "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="$in should find subarray with datetime and objectid", + ), + ExpressionTestCase( + "nested_find_subarray_minkey_maxkey", + doc={"val": [MinKey(), MaxKey()], "arr": [[MinKey(), MaxKey()], 1, "a"]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find subarray with minkey and maxkey", + ), +] + +# Success: deeply nested search targets (3-5 levels) +DEEPLY_NESTED_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_3_levels", + doc={"val": [[2, 3], [4, 5]], "arr": [1, [[2, 3], [4, 5]], "end"]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find 3-level nested array", + ), + ExpressionTestCase( + "nested_4_levels", + doc={"val": [[[1, 2], 3], 4], "arr": ["a", [[[1, 2], 3], 4], None]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find 4-level nested array", + ), + ExpressionTestCase( + "nested_deep_mixed_bson", + doc={ + "val": [[MinKey(), {"a": [DECIMAL128_ONE_AND_HALF]}], True], + "arr": [0, [[MinKey(), {"a": [DECIMAL128_ONE_AND_HALF]}], True], "x"], + }, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find deeply nested mixed BSON", + ), + ExpressionTestCase( + "nested_inner_not_outer", + doc={"val": [2, 3], "arr": [[1, [2, 3]], [2, 3], 4]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find inner array match", + ), + ExpressionTestCase( + "nested_5_levels", + doc={"val": [[[[99]]]], "arr": [[[[[99]]]], "other"]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find 5-level nested array", + ), + ExpressionTestCase( + "nested_deep_not_found", + doc={"val": [2, 3], "arr": [[1, [2, 3]], [4, 5]]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in 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..710449768 --- /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 + +# Property [Null/Missing]: $in returns null when value or array is null or missing. +NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_value_in_array", + doc={"val": None, "arr": [1, None, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=True, + msg="$in should find null value in array containing null", + ), + ExpressionTestCase( + "null_value_not_in_array", + doc={"val": None, "arr": [1, 2, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in should not find null in array without null", + ), +] + +# Property [Missing Value]: $in handles missing value field correctly. +LITERAL_ONLY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_value", + doc={"val": MISSING, "arr": [1, 2, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in should not find missing value in array", + ), + ExpressionTestCase( + "missing_value_null_in_array", + doc={"val": MISSING, "arr": [1, None, 3]}, + expression={"$in": ["$val", "$arr"]}, + expected=False, + msg="$in 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 89% 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 index 46e97f208..4de6a75e8 100644 --- 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 @@ -28,4 +28,4 @@ def test_smoke_expression_in(collection): ) expected = [{"_id": 1, "found": True}, {"_id": 2, "found": False}] - assertSuccess(result, expected, msg="Should support $in expression") + assertSuccess(result, expected, ignore_doc_order=True, msg="Should support $in expression") 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..431b06594 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_bson_types.py @@ -0,0 +1,464 @@ +""" +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.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_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_ONE_AND_HALF, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Success: search for various BSON types +BSON_TYPE_SEARCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "search_int64", + doc={"arr": [Int64(99), 1], "search": Int64(99)}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find Int64 value", + ), + ExpressionTestCase( + "search_decimal128", + doc={"arr": [DECIMAL128_ONE_AND_HALF, 2], "search": DECIMAL128_ONE_AND_HALF}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find Decimal128 value", + ), + ExpressionTestCase( + "search_datetime", + doc={ + "arr": [datetime(2024, 1, 1, tzinfo=timezone.utc), 1], + "search": datetime(2024, 1, 1, tzinfo=timezone.utc), + }, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find datetime value", + ), + ExpressionTestCase( + "search_objectid", + doc={ + "arr": [ObjectId("000000000000000000000001"), 1], + "search": ObjectId("000000000000000000000001"), + }, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find ObjectId value", + ), + ExpressionTestCase( + "search_binary", + doc={"arr": [Binary(b"\x01\x02", 0), 1], "search": Binary(b"\x01\x02", 0)}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find Binary value", + ), + ExpressionTestCase( + "search_regex", + doc={"arr": [Regex("^abc", "i"), 1], "search": Regex("^abc", "i")}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find Regex value", + ), + ExpressionTestCase( + "search_timestamp", + doc={"arr": [Timestamp(1, 1), 1], "search": Timestamp(1, 1)}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find Timestamp value", + ), + ExpressionTestCase( + "search_minkey", + doc={"arr": [MinKey(), 1], "search": MinKey()}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find MinKey value", + ), + ExpressionTestCase( + "search_maxkey", + doc={"arr": [1, MaxKey()], "search": MaxKey()}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find MaxKey value", + ), + ExpressionTestCase( + "search_uuid", + doc={ + "arr": [Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), 1], + "search": Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), + }, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find UUID binary value", + ), + # Special float values + ExpressionTestCase( + "search_infinity", + doc={"arr": [FLOAT_INFINITY, 1], "search": FLOAT_INFINITY}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find Infinity", + ), + ExpressionTestCase( + "search_neg_infinity", + doc={"arr": [FLOAT_NEGATIVE_INFINITY, 1], "search": FLOAT_NEGATIVE_INFINITY}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find -Infinity", + ), + ExpressionTestCase( + "search_infinity_not_found", + doc={"arr": [1, 2, 3], "search": FLOAT_INFINITY}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should not find Infinity in regular array", + ), + # Special Decimal128 values + ExpressionTestCase( + "search_decimal128_infinity", + doc={"arr": [DECIMAL128_INFINITY, 1], "search": DECIMAL128_INFINITY}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find Decimal128 Infinity", + ), + ExpressionTestCase( + "search_decimal128_neg_infinity", + doc={ + "arr": [DECIMAL128_NEGATIVE_INFINITY, 1], + "search": DECIMAL128_NEGATIVE_INFINITY, + }, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find Decimal128 -Infinity", + ), + # NaN equality: NaN == NaN in BSON comparison (unlike IEEE 754) + ExpressionTestCase( + "search_nan_found", + doc={"arr": [FLOAT_NAN, 1], "search": FLOAT_NAN}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find NaN (BSON equality)", + ), + ExpressionTestCase( + "search_decimal128_nan_found", + doc={"arr": [DECIMAL128_NAN, 1], "search": DECIMAL128_NAN}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find Decimal128 NaN (BSON equality)", + ), + # Cross-type NaN matching + ExpressionTestCase( + "search_decimal128_nan_matches_float_nan", + doc={"arr": [FLOAT_NAN, 1], "search": DECIMAL128_NAN}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray decimal128 NaN should match float NaN cross-type", + ), + ExpressionTestCase( + "search_decimal128_neg_nan_matches_nan", + doc={"arr": [DECIMAL128_NAN, 1], "search": DECIMAL128_NEGATIVE_NAN}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray decimal128 -NaN should match Decimal128 NaN", + ), +] + +# Success: numeric type equivalence in search +NUMERIC_EQUIVALENCE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int_matches_double", + doc={"arr": [1.0, 2.0], "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should match int to double", + ), + ExpressionTestCase( + "int_matches_long", + doc={"arr": [Int64(1), 2], "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should match int to long", + ), + ExpressionTestCase( + "int_matches_decimal128", + doc={"arr": [Decimal128("1"), 2], "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should match int to decimal128", + ), + ExpressionTestCase( + "double_matches_int", + doc={"arr": [1, 2], "search": 1.0}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should match double to int", + ), + ExpressionTestCase( + "long_matches_int", + doc={"arr": [1, 2], "search": Int64(1)}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should match long to int", + ), + ExpressionTestCase( + "decimal128_matches_int", + doc={"arr": [1, 2], "search": Decimal128("1")}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should match decimal128 to int", + ), +] + +# Success: nested mixed arrays +NESTED_MIXED_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_find_object_in_mixed", + doc={"arr": [1, "two", {"a": 1}, [3, 4], True], "search": {"a": 1}}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=2, + msg="$indexOfArray should find object in mixed array", + ), + ExpressionTestCase( + "nested_find_array_in_mixed", + doc={"arr": [1, "two", {"a": 1}, [3, 4], True], "search": [3, 4]}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=3, + msg="$indexOfArray should find array in mixed array", + ), + ExpressionTestCase( + "nested_find_bool_in_mixed", + doc={"arr": [1, "two", {"a": 1}, [3, 4], True], "search": True}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=4, + msg="$indexOfArray should find bool in mixed array", + ), + ExpressionTestCase( + "nested_find_null_in_mixed", + doc={"arr": [1, None, "three", [4], {"b": 2}], "search": None}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find null in mixed array", + ), + ExpressionTestCase( + "nested_find_deep_object", + doc={"arr": [[1, 2], {"a": {"b": 3}}, "x"], "search": {"a": {"b": 3}}}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find deep object", + ), + ExpressionTestCase( + "nested_find_array_with_mixed_types", + doc={"arr": [1, [None, "a", 2], "b"], "search": [None, "a", 2]}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find mixed-type subarray", + ), + ExpressionTestCase( + "nested_find_empty_object_in_mixed", + doc={"arr": [1, {}, [2], "a"], "search": {}}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find empty object", + ), + ExpressionTestCase( + "nested_find_empty_array_in_mixed", + doc={"arr": [1, {}, [], "a"], "search": []}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=2, + msg="$indexOfArray should find empty array", + ), + ExpressionTestCase( + "nested_find_datetime_in_mixed", + doc={ + "arr": ["a", datetime(2024, 1, 1, tzinfo=timezone.utc), 3, [4]], + "search": datetime(2024, 1, 1, tzinfo=timezone.utc), + }, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find datetime in mixed array", + ), + ExpressionTestCase( + "nested_find_objectid_in_mixed", + doc={ + "arr": [1, ObjectId("000000000000000000000001"), "x", [2]], + "search": ObjectId("000000000000000000000001"), + }, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find objectid in mixed array", + ), + ExpressionTestCase( + "nested_find_decimal128_in_mixed", + doc={"arr": ["a", [1], Decimal128("3.14"), None], "search": Decimal128("3.14")}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=2, + msg="$indexOfArray should find decimal128 in mixed array", + ), + ExpressionTestCase( + "nested_find_binary_in_mixed", + doc={"arr": [1, Binary(b"\x01\x02", 0), "x", [3]], "search": Binary(b"\x01\x02", 0)}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find binary in mixed array", + ), + ExpressionTestCase( + "nested_find_subarray_binary_decimal128", + doc={ + "arr": [1, [Binary(b"\x01\x02", 0), Decimal128("3.14")], "x", [3]], + "search": [Binary(b"\x01\x02", 0), Decimal128("3.14")], + }, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find subarray with binary and decimal128", + ), + ExpressionTestCase( + "nested_find_subarray_object_array", + doc={"arr": ["a", [{"k": 1}, [2, 3]], None, 4], "search": [{"k": 1}, [2, 3]]}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find subarray with object and array", + ), + ExpressionTestCase( + "nested_find_subarray_null_bool_int", + doc={"arr": [[None, True, 42], "x", 1], "search": [None, True, 42]}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find subarray with null bool int", + ), + ExpressionTestCase( + "nested_find_subarray_datetime_objectid", + doc={ + "arr": [ + 0, + [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + ObjectId("000000000000000000000001"), + ], + "end", + ], + "search": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + ObjectId("000000000000000000000001"), + ], + }, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find subarray with datetime and objectid", + ), + ExpressionTestCase( + "nested_find_subarray_minkey_maxkey", + doc={"arr": [[MinKey(), MaxKey()], 1, "a"], "search": [MinKey(), MaxKey()]}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find subarray with minkey and maxkey", + ), + ExpressionTestCase( + "nested_3_levels_deep", + doc={"arr": [1, [[2, 3], [4, 5]], "end"], "search": [[2, 3], [4, 5]]}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find 3-level nested array", + ), + ExpressionTestCase( + "nested_4_levels_deep", + doc={"arr": ["a", [[[1, 2], 3], 4], None], "search": [[[1, 2], 3], 4]}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find 4-level nested array", + ), + ExpressionTestCase( + "nested_deep_mixed_bson", + doc={ + "arr": [0, [[MinKey(), {"a": [DECIMAL128_ONE_AND_HALF]}], True], "x"], + "search": [[MinKey(), {"a": [DECIMAL128_ONE_AND_HALF]}], True], + }, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find deeply nested mixed BSON", + ), + ExpressionTestCase( + "nested_inner_not_outer", + doc={"arr": [[1, [2, 3]], [2, 3], 4], "search": [2, 3]}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find inner array match", + ), + ExpressionTestCase( + "nested_5_levels_deep", + doc={"arr": [[[[[99]]]], "other"], "search": [[[[99]]]]}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find 5-level nested array", + ), + ExpressionTestCase( + "nested_deep_not_found", + doc={"arr": [[1, [2, 3]], [4, 5]], "search": [2, 3]}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should not find array at wrong nesting level", + ), +] + +# Aggregate and test +ALL_TESTS = BSON_TYPE_SEARCH_TESTS + NUMERIC_EQUIVALENCE_TESTS + NESTED_MIXED_ARRAY_TESTS + +# Property [Literal Evaluation]: BSON type search with inline literal values. +TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ + ExpressionTestCase( + "search_int64", + expression={"$indexOfArray": [[Int64(99), 1], Int64(99)]}, + expected=0, + msg="$indexOfArray should find Int64 value", + ), + ExpressionTestCase( + "int_matches_double", + expression={"$indexOfArray": [[1.0, 2.0], 1]}, + expected=0, + msg="$indexOfArray should match int to double", + ), + ExpressionTestCase( + "nested_find_object_in_mixed", + expression={"$indexOfArray": [[1, "two", {"a": 1}, [3, 4], True], {"a": 1}]}, + expected=2, + msg="$indexOfArray should find object in mixed array", + ), +] + + +@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, test.expression) + 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.""" + 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/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..e3cbdba6e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_core_behavior.py @@ -0,0 +1,647 @@ +""" +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.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_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + INT32_MAX, + INT64_ZERO, +) + +# Success: basic search — value found +BASIC_FOUND_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "found_first", + doc={"arr": [1, 2, 3], "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find first element at index 0", + ), + ExpressionTestCase( + "found_middle", + doc={"arr": [1, 2, 3], "search": 2}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find middle element at index 1", + ), + ExpressionTestCase( + "found_last", + doc={"arr": [1, 2, 3], "search": 3}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=2, + msg="$indexOfArray should find last element at index 2", + ), + ExpressionTestCase( + "found_string", + doc={"arr": ["a", "b", "c"], "search": "b"}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find string in array", + ), + ExpressionTestCase( + "found_bool_true", + doc={"arr": [True, False], "search": True}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find true at index 0", + ), + ExpressionTestCase( + "found_bool_false", + doc={"arr": [True, False], "search": False}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find false at index 1", + ), + ExpressionTestCase( + "found_null", + doc={"arr": [None, 1, 2], "search": None}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find null at index 0", + ), + ExpressionTestCase( + "found_nested_array", + doc={"arr": [[1, 2], [3, 4]], "search": [3, 4]}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find nested array", + ), + ExpressionTestCase( + "found_object", + doc={"arr": [{"a": 1}, {"b": 2}], "search": {"a": 1}}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find object in array", + ), + ExpressionTestCase( + "found_single_element", + doc={"arr": [42], "search": 42}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find value in single-element array", + ), + ExpressionTestCase( + "first_occurrence", + doc={"arr": [1, 2, 1, 2], "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should return first occurrence index", + ), + ExpressionTestCase( + "duplicate_values", + doc={"arr": [5, 5, 5], "search": 5}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should return first index for duplicates", + ), +] + +# Success: value not found → -1 +NOT_FOUND_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "not_found_int", + doc={"arr": [1, 2, 3], "search": 4}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should return -1 for absent int", + ), + ExpressionTestCase( + "not_found_string", + doc={"arr": ["a", "b"], "search": "z"}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should return -1 for absent string", + ), + ExpressionTestCase( + "empty_array", + doc={"arr": [], "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should return -1 for empty array", + ), + ExpressionTestCase( + "type_mismatch_search", + doc={"arr": [1, 2, 3], "search": "1"}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should return -1 for type mismatch", + ), + ExpressionTestCase( + "bool_vs_int", + doc={"arr": [1, 0], "search": True}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should return -1 for bool vs int", + ), + ExpressionTestCase( + "not_found_null", + doc={"arr": [1, 2, 3], "search": None}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should return -1 for null not in array", + ), + ExpressionTestCase( + "not_found_partial_array", + doc={"arr": [[1, 2], [3, 4]], "search": [1]}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should return -1 for partial array match", + ), + ExpressionTestCase( + "not_found_partial_object", + doc={"arr": [{"a": 1, "b": 2}], "search": {"a": 1}}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should return -1 for partial object match", + ), +] + +# Success: with start index +START_INDEX_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "start_skips_first", + doc={"arr": [1, 2, 1, 2], "search": 1, "start": 1}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=2, + msg="$indexOfArray should skip first match with start index", + ), + ExpressionTestCase( + "start_at_match", + doc={"arr": [10, 20, 30], "search": 20, "start": 1}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=1, + msg="$indexOfArray should find at start index", + ), + ExpressionTestCase( + "start_past_match", + doc={"arr": [10, 20, 30], "search": 10, "start": 1}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=-1, + msg="$indexOfArray should return -1 when start is past match", + ), + ExpressionTestCase( + "start_at_zero", + doc={"arr": [1, 2, 3], "search": 1, "start": 0}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=0, + msg="$indexOfArray should find with start at zero", + ), + ExpressionTestCase( + "start_beyond_array", + doc={"arr": [1, 2, 3], "search": 1, "start": 20}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=-1, + msg="$indexOfArray should return -1 when start beyond array", + ), + ExpressionTestCase( + "start_int64", + doc={"arr": [10, 20, 30], "search": 20, "start": Int64(1)}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=1, + msg="$indexOfArray should accept Int64 start index", + ), + ExpressionTestCase( + "start_double_integral", + doc={"arr": [10, 20, 30], "search": 30, "start": 2.0}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=2, + msg="$indexOfArray should accept integral double start index", + ), + ExpressionTestCase( + "start_decimal128_integral", + doc={"arr": [10, 20, 30], "search": 20, "start": Decimal128("1")}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=1, + msg="$indexOfArray should accept Decimal128 start index", + ), + ExpressionTestCase( + "start_decimal128_10E_neg1", + doc={"arr": [10, 20, 30], "search": 20, "start": Decimal128("10E-1")}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=1, + msg="$indexOfArray should accept Decimal128 10E-1 as start index 1", + ), + ExpressionTestCase( + "end_decimal128_30E_neg1", + doc={"arr": [10, 20, 30], "search": 20, "start": 0, "end": Decimal128("30E-1")}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=1, + msg="$indexOfArray should accept Decimal128 30E-1 as end index 3", + ), +] + +# Success: with start and end index +START_END_INDEX_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "range_found", + doc={"arr": ["a", "b", "c", "b"], "search": "b", "start": 1, "end": 3}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=1, + msg="$indexOfArray should find in range", + ), + ExpressionTestCase( + "range_not_found_exclusive_end", + doc={"arr": ["a", "b", "c"], "search": "c", "start": 0, "end": 2}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=-1, + msg="$indexOfArray should not find at exclusive end", + ), + ExpressionTestCase( + "range_end_equals_start", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": 0}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=-1, + msg="$indexOfArray should return -1 when end equals start", + ), + ExpressionTestCase( + "range_end_less_than_start", + doc={"arr": ["a", "b", "c"], "search": "b", "start": 2, "end": 1}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=-1, + msg="$indexOfArray should return -1 when end less than start", + ), + ExpressionTestCase( + "range_end_beyond_array", + doc={"arr": [1, 2, 3], "search": 3, "start": 0, "end": 100}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=2, + msg="$indexOfArray should find when end beyond array", + ), + ExpressionTestCase( + "range_full_array", + doc={"arr": [1, 2, 3], "search": 2, "start": 0, "end": 3}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=1, + msg="$indexOfArray should find in full array range", + ), + ExpressionTestCase( + "range_int64_bounds", + doc={"arr": [10, 20, 30], "search": 20, "start": INT64_ZERO, "end": Int64(3)}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=1, + msg="$indexOfArray should accept Int64 bounds", + ), + ExpressionTestCase( + "range_double_integral_bounds", + doc={"arr": [10, 20, 30], "search": 20, "start": DOUBLE_ZERO, "end": 3.0}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=1, + msg="$indexOfArray should accept integral double bounds", + ), + ExpressionTestCase( + "range_decimal128_bounds", + doc={ + "arr": [10, 20, 30], + "search": 20, + "start": DECIMAL128_ZERO, + "end": Decimal128("3"), + }, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=1, + msg="$indexOfArray should accept Decimal128 bounds", + ), +] + +# Success: first occurrence from start with multiple duplicates +FIRST_FROM_START_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dup_skip_to_third_occurrence", + doc={"arr": [5, 3, 5, 3, 5], "search": 5, "start": 3}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=4, + msg="$indexOfArray should find third occurrence of 5 when start=3", + ), + ExpressionTestCase( + "dup_skip_different_value", + doc={"arr": [5, 3, 5, 3, 5], "search": 3, "start": 2}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=3, + msg="$indexOfArray should find second 3 when start=2", + ), +] + +# Success: detailed range semantics +RANGE_SEMANTICS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "end_exclusive_includes_before_boundary", + doc={"arr": ["a", "b", "c"], "search": "b", "start": 0, "end": 2}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=1, + msg="$indexOfArray element before end boundary should be included in [0,2)", + ), + ExpressionTestCase( + "end_exclusive_excludes_at_boundary", + doc={"arr": ["a", "b", "c"], "search": "b", "start": 0, "end": 1}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=-1, + msg="$indexOfArray element at end boundary should be excluded from [0,1)", + ), + ExpressionTestCase( + "empty_range_at_array_length", + doc={"arr": [1, 2, 3], "search": 3, "start": 3, "end": 3}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=-1, + msg="$indexOfArray empty range [len,len) at array boundary should return -1", + ), + ExpressionTestCase( + "range_finds_dup_in_subrange", + doc={"arr": [1, 2, 3, 2, 1, 2, 3], "search": 2, "start": 2, "end": 4}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=3, + msg="$indexOfArray should find value within range skipping earlier occurrences", + ), + ExpressionTestCase( + "start_at_array_length", + doc={"arr": ["a", "b", "c"], "search": "a", "start": 3}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=-1, + msg="$indexOfArray should return -1 when start equals array length", + ), + ExpressionTestCase( + "start_and_end_both_beyond_array", + doc={"arr": ["a", "b", "c"], "search": "a", "start": 100, "end": 200}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=-1, + msg="$indexOfArray should return -1 when both start and end are beyond array", + ), + ExpressionTestCase( + "end_before_match_position", + doc={"arr": ["a", "abc", "b"], "search": "b", "start": 0, "end": 1}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=-1, + msg="$indexOfArray should return -1 when end is before the matching element", + ), +] + +# Success: degenerate and single-element edge cases +DEGENERATE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "single_not_found", + doc={"arr": [1], "search": 2}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should return -1 when single element doesn't match", + ), + ExpressionTestCase( + "single_found_in_range", + doc={"arr": [42], "search": 42, "start": 0, "end": 1}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=0, + msg="$indexOfArray single element found in range [0,1)", + ), + ExpressionTestCase( + "single_empty_range", + doc={"arr": [42], "search": 42, "start": 0, "end": 0}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=-1, + msg="$indexOfArray single element not found in empty range [0,0)", + ), + ExpressionTestCase( + "single_start_past_element", + doc={"arr": [42], "search": 42, "start": 1}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=-1, + msg="$indexOfArray single element not found when start past it", + ), + ExpressionTestCase( + "all_null_from_start", + doc={"arr": [None, None, None], "search": None, "start": 1}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=1, + msg="$indexOfArray should find null at index 1 from start=1 in all-null array", + ), + ExpressionTestCase( + "all_null_search_different_type", + doc={"arr": [None, None, None], "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should not find int in all-null array", + ), + ExpressionTestCase( + "all_true_search_false", + doc={"arr": [True, True, True], "search": False}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should not find false in all-true array", + ), +] + +# Success: mixed types in array +MIXED_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "mixed_find_string", + doc={"arr": [1, "2", True, None, [1]], "search": "2"}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find string in mixed array", + ), + ExpressionTestCase( + "mixed_find_null", + doc={"arr": [1, "2", True, None, [1]], "search": None}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=3, + msg="$indexOfArray should find null in mixed array", + ), + ExpressionTestCase( + "mixed_find_array", + doc={"arr": [1, "2", True, None, [1]], "search": [1]}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=4, + msg="$indexOfArray should find array in mixed array", + ), +] + +# Success: large array +LARGE_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "large_array_first", + doc={"arr": list(range(20_000)), "search": 0}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find first in large array", + ), + ExpressionTestCase( + "large_array_last", + doc={"arr": list(range(20_000)), "search": 19_999}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=19_999, + msg="$indexOfArray should find last in large array", + ), + ExpressionTestCase( + "large_array_middle", + doc={"arr": list(range(20_000)), "search": 10_000}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=10_000, + msg="$indexOfArray should find middle in large array", + ), + ExpressionTestCase( + "large_array_not_found", + doc={"arr": list(range(20_000)), "search": -1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should return -1 for absent value in large array", + ), + ExpressionTestCase( + "large_array_with_start", + doc={"arr": list(range(20_000)), "search": 19_999, "start": 19_998}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=19_999, + msg="$indexOfArray should find with start in large array", + ), +] + +# Negative zero treated as equivalent to positive zero in search +NEGATIVE_ZERO_SEARCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "search_double_neg_zero_in_zeros", + doc={"arr": [0, 1, 2], "search": DOUBLE_NEGATIVE_ZERO}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find -0.0 at index of 0", + ), + ExpressionTestCase( + "search_zero_finds_neg_zero", + doc={"arr": [DOUBLE_NEGATIVE_ZERO, 1, 2], "search": 0}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find 0 matching -0.0 in array", + ), + ExpressionTestCase( + "search_decimal128_neg_zero", + doc={"arr": [0, 1, 2], "search": DECIMAL128_NEGATIVE_ZERO}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=0, + msg="$indexOfArray should find Decimal128 -0 at index of 0", + ), +] + +# Boundary values for start/end indices +BOUNDARY_INDEX_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "start_int32_max", + doc={"arr": [1, 2, 3], "search": 1, "start": INT32_MAX}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=-1, + msg="$indexOfArray should return -1 with INT32_MAX start", + ), + ExpressionTestCase( + "end_int32_max", + doc={"arr": [1, 2, 3], "search": 2, "start": 0, "end": INT32_MAX}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=1, + msg="$indexOfArray should find with INT32_MAX end", + ), + ExpressionTestCase( + "start_and_end_int32_max", + doc={"arr": [1, 2, 3], "search": 1, "start": INT32_MAX, "end": INT32_MAX}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=-1, + msg="$indexOfArray should return -1 with both INT32_MAX", + ), + ExpressionTestCase( + "start_int32_max_minus_1", + doc={"arr": [1, 2, 3], "search": 1, "start": INT32_MAX - 1}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=-1, + msg="$indexOfArray should return -1 with INT32_MAX-1 start", + ), +] + +# Negative zero as start/end index treated as 0 +NEGATIVE_ZERO_INDEX_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "double_neg_zero_start", + doc={"arr": [10, 20, 30], "search": 10, "start": DOUBLE_NEGATIVE_ZERO}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=0, + msg="$indexOfArray should treat -0.0 start as 0", + ), + ExpressionTestCase( + "decimal128_neg_zero_start", + doc={"arr": [10, 20, 30], "search": 10, "start": DECIMAL128_NEGATIVE_ZERO}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=0, + msg="$indexOfArray should treat decimal128 -0 start as 0", + ), + ExpressionTestCase( + "double_neg_zero_end", + doc={"arr": [10, 20, 30], "search": 10, "start": 0, "end": DOUBLE_NEGATIVE_ZERO}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + expected=-1, + msg="$indexOfArray 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 +) + +# Property [Literal Evaluation]: $indexOfArray evaluates correctly with inline literal values. +TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ + ExpressionTestCase( + "found_first", + expression={"$indexOfArray": [[1, 2, 3], 1]}, + expected=0, + msg="$indexOfArray should find first element at index 0", + ), + ExpressionTestCase( + "not_found_int", + expression={"$indexOfArray": [[1, 2, 3], 4]}, + expected=-1, + msg="$indexOfArray should return -1 for absent int", + ), + ExpressionTestCase( + "range_found", + expression={"$indexOfArray": [["a", "b", "c", "b"], "b", 1, 3]}, + expected=1, + msg="$indexOfArray should find in range", + ), +] + + +@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, test.expression) + 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.""" + 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/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..5fcf66bcd --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_errors.py @@ -0,0 +1,524 @@ +""" +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.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_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 ( + DECIMAL128_HALF, + DECIMAL128_INFINITY, + DECIMAL128_NAN, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT64_MAX, + MISSING, +) + +# Error: INT64_MAX start/end index (not representable as int32) +BOUNDARY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "start_int64_max", + doc={"arr": [1, 2, 3], "search": 1, "start": INT64_MAX}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject INT64_MAX start", + ), + ExpressionTestCase( + "end_int64_max", + doc={"arr": [1, 2, 3], "search": 2, "start": 0, "end": INT64_MAX}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject INT64_MAX end", + ), +] + +# Error: first argument not an array (and not null) +NOT_ARRAY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_as_array", + doc={"arr": "hello", "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject string as array", + ), + ExpressionTestCase( + "int_as_array", + doc={"arr": 42, "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject int as array", + ), + ExpressionTestCase( + "double_as_array", + doc={"arr": 3.14, "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject double as array", + ), + ExpressionTestCase( + "bool_true_as_array", + doc={"arr": True, "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject bool true as array", + ), + ExpressionTestCase( + "bool_false_as_array", + doc={"arr": False, "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject bool false as array", + ), + ExpressionTestCase( + "object_as_array", + doc={"arr": {"a": 1}, "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject object as array", + ), + ExpressionTestCase( + "decimal128_as_array", + doc={"arr": Decimal128("1"), "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject decimal128 as array", + ), + ExpressionTestCase( + "int64_as_array", + doc={"arr": Int64(1), "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject int64 as array", + ), + ExpressionTestCase( + "binary_as_array", + doc={"arr": Binary(b"x", 0), "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject binary as array", + ), + ExpressionTestCase( + "datetime_as_array", + doc={"arr": datetime(2024, 1, 1, tzinfo=timezone.utc), "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject datetime as array", + ), + ExpressionTestCase( + "objectid_as_array", + doc={"arr": ObjectId(), "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject objectid as array", + ), + ExpressionTestCase( + "regex_as_array", + doc={"arr": Regex("x"), "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject regex as array", + ), + ExpressionTestCase( + "maxkey_as_array", + doc={"arr": MaxKey(), "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject maxkey as array", + ), + ExpressionTestCase( + "minkey_as_array", + doc={"arr": MinKey(), "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject minkey as array", + ), + ExpressionTestCase( + "timestamp_as_array", + doc={"arr": Timestamp(0, 0), "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject timestamp as array", + ), +] + +# Error: start index not integral +START_NOT_INTEGRAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "start_fractional_double", + doc={"arr": [1, 2, 3], "search": 1, "start": 1.5}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject fractional double start", + ), + ExpressionTestCase( + "start_fractional_decimal128", + doc={"arr": [1, 2, 3], "search": 1, "start": DECIMAL128_HALF}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject fractional decimal128 start", + ), + ExpressionTestCase( + "start_nan", + doc={"arr": [1, 2, 3], "search": 1, "start": FLOAT_NAN}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject NaN start", + ), + ExpressionTestCase( + "start_inf", + doc={"arr": [1, 2, 3], "search": 1, "start": FLOAT_INFINITY}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject infinity start", + ), + ExpressionTestCase( + "start_neg_inf", + doc={"arr": [1, 2, 3], "search": 1, "start": FLOAT_NEGATIVE_INFINITY}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject -infinity start", + ), + ExpressionTestCase( + "start_decimal128_nan", + doc={"arr": [1, 2, 3], "search": 1, "start": DECIMAL128_NAN}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject decimal128 NaN start", + ), + ExpressionTestCase( + "start_decimal128_inf", + doc={"arr": [1, 2, 3], "search": 1, "start": DECIMAL128_INFINITY}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject decimal128 infinity start", + ), + ExpressionTestCase( + "start_string", + doc={"arr": [1, 2, 3], "search": 1, "start": "0"}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject string start", + ), + ExpressionTestCase( + "start_bool", + doc={"arr": [1, 2, 3], "search": 1, "start": True}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject bool start", + ), + ExpressionTestCase( + "start_array", + doc={"arr": [1, 2, 3], "search": 1, "start": [0]}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject array start", + ), + ExpressionTestCase( + "start_object", + doc={"arr": [1, 2, 3], "search": 1, "start": {"a": 0}}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject object start", + ), +] + +# Error: end index not integral +END_NOT_INTEGRAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "end_fractional_double", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": 1.5}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject fractional double end", + ), + ExpressionTestCase( + "end_fractional_decimal128", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": DECIMAL128_HALF}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject fractional decimal128 end", + ), + ExpressionTestCase( + "end_nan", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": FLOAT_NAN}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject NaN end", + ), + ExpressionTestCase( + "end_inf", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": FLOAT_INFINITY}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject infinity end", + ), + ExpressionTestCase( + "end_string", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": "3"}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject string end", + ), + ExpressionTestCase( + "end_bool", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": True}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject bool end", + ), + ExpressionTestCase( + "end_neg_inf", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": FLOAT_NEGATIVE_INFINITY}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject -infinity end", + ), + ExpressionTestCase( + "end_decimal128_nan", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": DECIMAL128_NAN}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject decimal128 NaN end", + ), + ExpressionTestCase( + "end_decimal128_inf", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": DECIMAL128_INFINITY}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject decimal128 infinity end", + ), + ExpressionTestCase( + "end_array", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": [3]}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject array end", + ), + ExpressionTestCase( + "end_object", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": {"a": 0}}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject object end", + ), +] + +# Error: negative start index +START_NEGATIVE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "start_neg_one", + doc={"arr": [1, 2, 3], "search": 1, "start": -1}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="$indexOfArray should reject negative start -1", + ), + ExpressionTestCase( + "start_neg_large", + doc={"arr": [1, 2, 3], "search": 1, "start": -100}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="$indexOfArray should reject negative start -100", + ), + ExpressionTestCase( + "start_neg_int64", + doc={"arr": [1, 2, 3], "search": 1, "start": Int64(-1)}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="$indexOfArray should reject negative Int64 start", + ), + ExpressionTestCase( + "start_neg_double", + doc={"arr": [1, 2, 3], "search": 1, "start": -1.0}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="$indexOfArray should reject negative double start", + ), + ExpressionTestCase( + "start_neg_decimal128", + doc={"arr": [1, 2, 3], "search": 1, "start": Decimal128("-1")}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="$indexOfArray should reject negative decimal128 start", + ), +] + +# Error: negative end index +END_NEGATIVE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "end_neg_one", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": -1}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="$indexOfArray should reject negative end -1", + ), + ExpressionTestCase( + "end_neg_large", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": -100}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="$indexOfArray should reject negative end -100", + ), + ExpressionTestCase( + "end_neg_int64", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": Int64(-1)}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="$indexOfArray should reject negative Int64 end", + ), + ExpressionTestCase( + "end_neg_double", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": -1.0}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="$indexOfArray should reject negative double end", + ), + ExpressionTestCase( + "end_neg_decimal128", + doc={"arr": [1, 2, 3], "search": 1, "start": 0, "end": Decimal128("-1")}, + expression={"$indexOfArray": ["$arr", "$search", "$start", "$end"]}, + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="$indexOfArray 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 +) + +# Property [Literal Evaluation]: error cases with inline literal values. +TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_as_array", + expression={"$indexOfArray": ["hello", 1]}, + error_code=INDEX_OF_ARRAY_NOT_ARRAY_ERROR, + msg="$indexOfArray should reject string as array", + ), + ExpressionTestCase( + "start_fractional_double", + expression={"$indexOfArray": [[1, 2, 3], 1, 1.5]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject fractional double start", + ), + ExpressionTestCase( + "start_neg_one", + expression={"$indexOfArray": [[1, 2, 3], 1, -1]}, + error_code=INDEX_OF_ARRAY_INDEX_NEGATIVE_ERROR, + msg="$indexOfArray should reject negative start -1", + ), + ExpressionTestCase( + "start_missing_field", + expression={"$indexOfArray": [[1, 2, 3], 1, MISSING]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject missing field as start", + ), + ExpressionTestCase( + "end_missing_field", + expression={"$indexOfArray": [[1, 2, 3], 1, 0, MISSING]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject missing field as end", + ), +] + + +@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, test.expression) + 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.""" + 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 + ) + + +# Property [Arity]: $indexOfArray requires two to four arguments. +ARITY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_args", + expression={"$indexOfArray": []}, + error_code=EXPRESSION_ARITY_ERROR, + msg="$indexOfArray should reject zero arguments", + ), + ExpressionTestCase( + "one_arg", + expression={"$indexOfArray": [[1, 2, 3]]}, + error_code=EXPRESSION_ARITY_ERROR, + msg="$indexOfArray should reject one argument", + ), + ExpressionTestCase( + "five_args", + expression={"$indexOfArray": [[1, 2, 3], 1, 0, 3, 99]}, + error_code=EXPRESSION_ARITY_ERROR, + msg="$indexOfArray should reject five arguments", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ARITY_ERROR_TESTS)) +def test_indexOfArray_arity_error(collection, test): + """Test $indexOfArray arity error cases.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +# Property [Null Index]: $indexOfArray rejects null as start or end index. +NULL_INDEX_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_end", + expression={"$indexOfArray": [[1, 2, 3], 1, 0, None]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject null end", + ), + ExpressionTestCase( + "null_start", + expression={"$indexOfArray": [[1, 2, 3], 1, None]}, + error_code=INDEX_OF_ARRAY_INDEX_NOT_INTEGRAL_ERROR, + msg="$indexOfArray should reject null start", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(NULL_INDEX_ERROR_TESTS)) +def test_indexOfArray_null_index_error(collection, test): + """Test $indexOfArray rejects null as start or end index.""" + result = execute_expression(collection, test.expression) + 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_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_expressions.py new file mode 100644 index 000000000..4f7d6662c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_indexOfArray_expressions.py @@ -0,0 +1,118 @@ +""" +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.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 + +# Property [Nested Expressions]: $indexOfArray evaluates nested expressions as arguments. +NESTED_EXPRESSION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_2_level", + expression={ + "$indexOfArray": [ + [0, 1, 2, 3], + {"$indexOfArray": [[10, 20, 30], 30]}, + ] + }, + expected=2, + msg="$indexOfArray should use inner result as search value", + ), + ExpressionTestCase( + "nested_3_level", + expression={ + "$indexOfArray": [ + [0, 1, 2], + { + "$indexOfArray": [ + [0, 1, 2], + {"$indexOfArray": [[5, 10, 15], 10]}, + ] + }, + ] + }, + expected=1, + msg="$indexOfArray should support triple nested composition", + ), + ExpressionTestCase( + "nested_start_index", + expression={ + "$indexOfArray": [ + [1, 2, 1, 2], + 1, + {"$indexOfArray": [[10, 20, 30], 20]}, + ] + }, + expected=2, + msg="$indexOfArray should use nested result as start index", + ), +] + +# Property [Field Path Resolution]: $indexOfArray resolves nested and deeply nested field paths. +FIELD_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_path", + expression={"$indexOfArray": ["$a.b", 20]}, + doc={"a": {"b": [10, 20, 30]}}, + expected=1, + msg="$indexOfArray should resolve nested field path as array", + ), + ExpressionTestCase( + "nonexistent_field_null", + expression={"$indexOfArray": ["$a.nonexistent", 0]}, + doc={"a": {"missing": 1}}, + expected=None, + msg="$indexOfArray should return null for non-existent field", + ), + ExpressionTestCase( + "deeply_nested_field", + expression={"$indexOfArray": ["$a.b.c", 7]}, + doc={"a": {"b": {"c": [5, 6, 7]}}}, + expected=2, + msg="$indexOfArray should resolve deeply nested field path", + ), +] + +# Property [Composite Paths]: $indexOfArray resolves composite array paths from array-of-objects. +COMPOSITE_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "composite_array_as_array", + expression={"$indexOfArray": ["$x.y", 20]}, + doc={"x": [{"y": 10}, {"y": 20}, {"y": 30}]}, + expected=1, + msg="$indexOfArray should find value in composite array path", + ), + ExpressionTestCase( + "composite_array_as_search", + expression={"$indexOfArray": [[[10, 20], [30, 40]], "$x.y"]}, + doc={"x": [{"y": 30}, {"y": 40}]}, + expected=1, + msg="$indexOfArray should match composite array as search value", + ), +] + +ALL_EXPRESSION_TESTS = NESTED_EXPRESSION_TESTS + FIELD_LOOKUP_TESTS + COMPOSITE_PATH_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_EXPRESSION_TESTS)) +def test_indexOfArray_expression(collection, test): + """Test $indexOfArray with expressions, field paths, and composite paths.""" + 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/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..df42559dc --- /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.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 MISSING + +# Property [Null Array]: $indexOfArray returns null when array is null or missing. +NULL_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_array", + doc={"arr": None, "search": 1}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=None, + msg="$indexOfArray should return null for null array", + ), + ExpressionTestCase( + "null_array_with_start", + doc={"arr": None, "search": 1, "start": 0}, + expression={"$indexOfArray": ["$arr", "$search", "$start"]}, + expected=None, + msg="$indexOfArray should return null for null array with start", + ), +] + +# Property [Null Search]: $indexOfArray handles null and missing search values. +NULL_SEARCH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_value_in_array", + doc={"arr": [1, None, 3], "search": None}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=1, + msg="$indexOfArray should find null in array", + ), + ExpressionTestCase( + "null_value_not_in_array", + doc={"arr": [1, 2, 3], "search": None}, + expression={"$indexOfArray": ["$arr", "$search"]}, + expected=-1, + msg="$indexOfArray should return -1 for null not in array", + ), +] + +# Property [Literal Evaluation]: null/missing handling with inline literal values. +TEST_SUBSET_FOR_LITERAL: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_array", + expression={"$indexOfArray": [None, 1]}, + expected=None, + msg="$indexOfArray should return null for null array", + ), + ExpressionTestCase( + "null_value_in_array", + expression={"$indexOfArray": [[1, None, 3], None]}, + expected=1, + msg="$indexOfArray should find null in array", + ), + ExpressionTestCase( + "missing_array", + expression={"$indexOfArray": [MISSING, 1]}, + expected=None, + msg="$indexOfArray should return null for missing array", + ), + ExpressionTestCase( + "missing_value", + expression={"$indexOfArray": [[1, 2, 3], MISSING]}, + expected=-1, + msg="$indexOfArray should return -1 for missing search value", + ), + ExpressionTestCase( + "missing_value_null_in_array", + expression={"$indexOfArray": [[1, None, 3], MISSING]}, + expected=-1, + msg="$indexOfArray should return -1 for missing search even with null in array", + ), +] + +# Aggregate and test +ALL_TESTS = NULL_ARRAY_TESTS + NULL_SEARCH_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, test.expression) + 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.""" + 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/indexOfArray/test_smoke_expression_indexOfArray.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/indexOfArray/test_smoke_indexOfArray.py similarity index 87% 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 index d92b4fb31..3b2492b85 100644 --- 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 @@ -31,4 +31,6 @@ def test_smoke_expression_indexOfArray(collection): ) expected = [{"_id": 1, "index": 1}, {"_id": 2, "index": 1}] - assertSuccess(result, expected, msg="Should support $indexOfArray expression") + assertSuccess( + result, expected, ignore_doc_order=True, msg="Should support $indexOfArray expression" + ) 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..dc15c40f4 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_bson_types.py @@ -0,0 +1,418 @@ +""" +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_NAN, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + 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( + "bindata_array", + doc={"val": [Binary(b"\x00", 0)]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for BinData array", + ), + ExpressionTestCase( + "timestamp_array", + doc={"val": [Timestamp(0, 0)]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for Timestamp array", + ), + ExpressionTestCase( + "int64_array", + doc={"val": [Int64(1)]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for Int64 array", + ), + ExpressionTestCase( + "decimal128_array", + doc={"val": [Decimal128("1")]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for Decimal128 array", + ), + ExpressionTestCase( + "objectid_array", + doc={"val": [ObjectId()]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for ObjectId array", + ), + ExpressionTestCase( + "datetime_array", + doc={"val": [datetime(2024, 1, 1, tzinfo=timezone.utc)]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for datetime array", + ), + ExpressionTestCase( + "minkey_array", + doc={"val": [MinKey()]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for MinKey array", + ), + ExpressionTestCase( + "maxkey_array", + doc={"val": [MaxKey()]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for MaxKey array", + ), + ExpressionTestCase( + "regex_array", + doc={"val": [Regex(".*")]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for Regex array", + ), + ExpressionTestCase( + "nan_array", + doc={"val": [FLOAT_NAN]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for NaN array", + ), + ExpressionTestCase( + "inf_array", + doc={"val": [FLOAT_INFINITY]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for Infinity array", + ), + ExpressionTestCase( + "decimal128_nan_array", + doc={"val": [DECIMAL128_NAN]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for Decimal128 NaN array", + ), + ExpressionTestCase( + "decimal128_inf_array", + doc={"val": [DECIMAL128_INFINITY]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for Decimal128 Infinity array", + ), + ExpressionTestCase( + "decimal128_neg_nan_array", + doc={"val": [DECIMAL128_NEGATIVE_NAN]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for Decimal128 -NaN array", + ), + ExpressionTestCase( + "decimal128_neg_inf_array", + doc={"val": [DECIMAL128_NEGATIVE_INFINITY]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for Decimal128 -Infinity array", + ), + ExpressionTestCase( + "neg_inf_array", + doc={"val": [FLOAT_NEGATIVE_INFINITY]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for -Infinity array", + ), + ExpressionTestCase( + "neg_zero_array", + doc={"val": [DOUBLE_NEGATIVE_ZERO]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for negative zero array", + ), + ExpressionTestCase( + "decimal128_neg_zero_array", + doc={"val": [DECIMAL128_NEGATIVE_ZERO]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for Decimal128 -0 array", + ), + ExpressionTestCase( + "nested_mixed_bson_array", + doc={ + "val": [ + MinKey(), + {"a": [DECIMAL128_ONE_AND_HALF]}, + Int64(1), + datetime(2024, 1, 1, tzinfo=timezone.utc), + Binary(b"\x01", 0), + ] + }, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for nested mixed BSON array", + ), +] + +# Non-array BSON types → false +BSON_FALSE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int64", + doc={"val": Int64(1)}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for Int64", + ), + ExpressionTestCase( + "decimal128", + doc={"val": Decimal128("1")}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for Decimal128", + ), + ExpressionTestCase( + "objectid", + doc={"val": ObjectId("000000000000000000000001")}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for ObjectId", + ), + ExpressionTestCase( + "datetime", + doc={"val": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for datetime", + ), + ExpressionTestCase( + "binary", + doc={"val": Binary(b"\x01", 0)}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for Binary", + ), + ExpressionTestCase( + "regex", + doc={"val": Regex("^abc")}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for Regex", + ), + ExpressionTestCase( + "timestamp", + doc={"val": Timestamp(1, 1)}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for Timestamp", + ), + ExpressionTestCase( + "minkey", + doc={"val": MinKey()}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for MinKey", + ), + ExpressionTestCase( + "maxkey", + doc={"val": MaxKey()}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for MaxKey", + ), +] + +# Special numeric values → false +SPECIAL_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nan", + doc={"val": FLOAT_NAN}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for NaN", + ), + ExpressionTestCase( + "inf", + doc={"val": FLOAT_INFINITY}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for Infinity", + ), + ExpressionTestCase( + "neg_inf", + doc={"val": FLOAT_NEGATIVE_INFINITY}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for -Infinity", + ), + ExpressionTestCase( + "neg_zero", + doc={"val": DOUBLE_NEGATIVE_ZERO}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for negative zero", + ), + ExpressionTestCase( + "decimal128_nan", + doc={"val": DECIMAL128_NAN}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for Decimal128 NaN", + ), + ExpressionTestCase( + "decimal128_neg_nan", + doc={"val": DECIMAL128_NEGATIVE_NAN}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for Decimal128 -NaN", + ), + ExpressionTestCase( + "decimal128_inf", + doc={"val": DECIMAL128_INFINITY}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for Decimal128 Infinity", + ), + ExpressionTestCase( + "decimal128_neg_inf", + doc={"val": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for Decimal128 -Infinity", + ), + ExpressionTestCase( + "decimal128_neg_zero", + doc={"val": DECIMAL128_NEGATIVE_ZERO}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for Decimal128 -0", + ), +] + +# Boundary values → false +BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_max", + doc={"val": INT32_MAX}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for INT32_MAX", + ), + ExpressionTestCase( + "int32_min", + doc={"val": INT32_MIN}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for INT32_MIN", + ), + ExpressionTestCase( + "int64_max", + doc={"val": INT64_MAX}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for INT64_MAX", + ), + ExpressionTestCase( + "int64_min", + doc={"val": INT64_MIN}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for INT64_MIN", + ), + ExpressionTestCase( + "decimal128_max", + doc={"val": DECIMAL128_MAX}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for DECIMAL128_MAX", + ), + ExpressionTestCase( + "decimal128_min", + doc={"val": DECIMAL128_MIN}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray 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_ONE_AND_HALF]}, 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..dd0725c80 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_core_behavior.py @@ -0,0 +1,325 @@ +""" +Core behavior tests for $isArray expression. + +Tests that arrays return true, non-arrays return false, +with basic types. +""" + +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.lazy_payload import lazy +from documentdb_tests.framework.parametrize import pytest_params + +# Success: arrays → true +IS_ARRAY_TRUE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "simple_array", + doc={"val": [1, 2, 3]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for simple array", + ), + ExpressionTestCase( + "empty_array", + doc={"val": []}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for empty array", + ), + ExpressionTestCase( + "single_element", + doc={"val": [42]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for single-element array", + ), + ExpressionTestCase( + "nested_array", + doc={"val": [[1, 2], [3, 4]]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for nested array", + ), + ExpressionTestCase( + "mixed_type_array", + doc={"val": [1, "two", True, None, {"a": 1}]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for mixed-type array", + ), + ExpressionTestCase( + "array_of_objects", + doc={"val": [{"a": 1}, {"b": 2}]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for array of objects", + ), + ExpressionTestCase( + "array_of_nulls", + doc={"val": [None, None]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for array of nulls", + ), + ExpressionTestCase( + "string_array", + doc={"val": ["a", "b", "c"]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for string array", + ), + ExpressionTestCase( + "bool_array", + doc={"val": [True]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray should return true for bool array", + ), + ExpressionTestCase( + "large_array_10k", + doc={"val": list(range(10000))}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray 10K element array returns true", + ), + ExpressionTestCase( + "deeply_nested_array", + doc={"val": [[[[[[1]]]]]]}, + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray deeply nested array returns true", + ), + ExpressionTestCase( + "large_array_of_arrays", + doc=lazy(lambda: {"val": [[i] for i in range(10000)]}), + expression={"$isArray": "$val"}, + expected=True, + msg="$isArray 10K nested arrays returns true", + ), +] + +# Success: non-arrays → false +IS_ARRAY_FALSE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string", + doc={"val": "hello"}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for string", + ), + ExpressionTestCase( + "int", + doc={"val": 42}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for int", + ), + ExpressionTestCase( + "double", + doc={"val": 3.14}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for double", + ), + ExpressionTestCase( + "bool_true", + doc={"val": True}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for true", + ), + ExpressionTestCase( + "bool_false", + doc={"val": False}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for false", + ), + ExpressionTestCase( + "null", + doc={"val": None}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for null", + ), + ExpressionTestCase( + "object", + doc={"val": {"a": 1}}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for object", + ), + ExpressionTestCase( + "empty_string", + doc={"val": ""}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for empty string", + ), + ExpressionTestCase( + "empty_object", + doc={"val": {}}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for empty object", + ), + ExpressionTestCase( + "zero", + doc={"val": 0}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for zero", + ), + ExpressionTestCase( + "negative_int", + doc={"val": -123}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for negative int", + ), + ExpressionTestCase( + "negative_double", + doc={"val": -1.23}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for negative double", + ), +] + +# Array-like edge cases → false +ARRAY_LIKE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_brackets", + doc={"val": "[]"}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for string '[]'", + ), + ExpressionTestCase( + "string_array_repr", + doc={"val": "[1, 2, 3]"}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for string '[1, 2, 3]'", + ), + ExpressionTestCase( + "array_like_object", + doc={"val": {"0": "a", "1": "b"}}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray should return false for array-like object", + ), + ExpressionTestCase( + "length_object", + doc={"val": {"length": 3}}, + expression={"$isArray": "$val"}, + expected=False, + msg="$isArray 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..ade4b93ad --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_errors.py @@ -0,0 +1,50 @@ +""" +Error and argument handling tests for $isArray expression. + +Tests arity errors. +""" + +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 EXPRESSION_TYPE_MISMATCH_ERROR +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Arity]: $isArray requires exactly one argument. +ARITY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_args", + expression={"$isArray": []}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$isArray should reject zero arguments", + ), + ExpressionTestCase( + "two_args", + expression={"$isArray": [1, 2]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$isArray should reject two arguments", + ), + ExpressionTestCase( + "three_args", + expression={"$isArray": [1, 2, 3]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$isArray should reject three arguments", + ), +] + +ALL_ERROR_TESTS = ARITY_ERROR_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_ERROR_TESTS)) +def test_isArray_error(collection, test): + """Test $isArray error cases.""" + result = execute_expression(collection, test.expression) + 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_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/isArray/test_isArray_expressions.py new file mode 100644 index 000000000..f26f7b865 --- /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( + "self_nested", + expression={"$isArray": [{"$isArray": "$arr"}]}, + doc={"arr": [1, 2]}, + expected=False, + msg="$isArray inner returns bool, outer returns false", + ), +] + +# Field path lookups +FIELD_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_array", + expression={"$isArray": "$a.b"}, + doc={"a": {"b": [1, 2]}}, + expected=True, + msg="$isArray nested array field", + ), + ExpressionTestCase( + "deeply_nested_field_array", + expression={"$isArray": "$a.b.c"}, + doc={"a": {"b": {"c": [1]}}}, + expected=True, + msg="$isArray deeply nested array field", + ), + ExpressionTestCase( + "numeric_index_on_object_key", + expression={"$isArray": "$a.0.b"}, + doc={"a": {"0": {"b": [1]}}}, + expected=True, + msg="$isArray numeric key on object resolves to array", + ), +] + +# Composite array paths +COMPOSITE_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "composite_array_path", + expression={"$isArray": "$a.b"}, + doc={"a": [{"b": 1}, {"b": 2}]}, + expected=True, + msg="$isArray composite array path should resolve to array", + ), + ExpressionTestCase( + "composite_empty_array", + expression={"$isArray": "$a.b"}, + doc={"a": []}, + expected=True, + msg="$isArray composite on empty array resolves to empty array", + ), + ExpressionTestCase( + "composite_three_elements", + expression={"$isArray": "$a.b"}, + doc={"a": [{"b": 1}, {"b": 2}, {"b": 3}]}, + expected=True, + msg="$isArray three-element composite resolves to array", + ), +] + +# Deep composite array traversal +DEEP_COMPOSITE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "array_at_leaf", + expression={"$isArray": "$a.b.c.d"}, + doc={"a": {"b": {"c": {"d": [1, 2, 3]}}}}, + expected=True, + msg="$isArray array at leaf level", + ), + ExpressionTestCase( + "array_at_c", + expression={"$isArray": "$a.b.c.d"}, + doc={"a": {"b": {"c": [{"d": 1}]}}}, + expected=True, + msg="$isArray array at c level", + ), +] + +# Null and missing handling +NULL_MISSING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_field", + expression={"$isArray": "$nonexistent"}, + doc={"other": 1}, + expected=False, + msg="$isArray missing field should return false", + ), + ExpressionTestCase( + "missing_nested_partial_path", + expression={"$isArray": "$a.b"}, + doc={"a": 1}, + expected=False, + msg="$isArray nested field on scalar parent returns false", + ), + ExpressionTestCase( + "missing_nested_empty_doc", + expression={"$isArray": "$a.b"}, + doc={"x": 1}, + expected=False, + msg="$isArray missing nested field returns false", + ), +] + +# System variables +SYSTEM_VAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "root_variable", + expression={"$isArray": "$$ROOT"}, + doc={"a": 1}, + expected=False, + msg="$$ROOT is object, returns false", + ), + ExpressionTestCase( + "current_variable", + expression={"$isArray": "$$CURRENT"}, + doc={"a": 1}, + expected=False, + msg="$$CURRENT is object, returns false", + ), + ExpressionTestCase( + "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 88% 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 index c16ab7d55..e705f17a9 100644 --- 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 @@ -26,4 +26,4 @@ def test_smoke_expression_isArray(collection): ) expected = [{"_id": 1, "isArray": True}, {"_id": 2, "isArray": False}] - assertSuccess(result, expected, msg="Should support $isArray expression") + assertSuccess(result, expected, ignore_doc_order=True, msg="Should support $isArray expression") 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..e0ce66eaf --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_array_arrayElemAt_indexOfArray_in_slice.py @@ -0,0 +1,362 @@ +""" +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( + "arrayElemAt_index_from_indexOfArray", + expression={"$arrayElemAt": [[10, 20, 30], {"$indexOfArray": [[10, 20, 30], 30]}]}, + expected=30, + msg="$indexOfArray should use $indexOfArray result as index", + ), + ExpressionTestCase( + "arrayElemAt_last_element_via_size", + expression={"$arrayElemAt": [[10, 20, 30], {"$subtract": [{"$size": [[10, 20, 30]]}, 1]}]}, + expected=30, + msg="$indexOfArray should access last element via $size - 1", + ), + ExpressionTestCase( + "arrayElemAt_elem_from_slice", + expression={"$arrayElemAt": [{"$slice": [[10, 20, 30, 40], -2]}, 0]}, + expected=30, + msg="$indexOfArray should access element from $slice result", + ), + ExpressionTestCase( + "arrayElemAt_elem_from_slice_3arg", + expression={"$arrayElemAt": [{"$slice": [[10, 20, 30, 40], 1, 2]}, 1]}, + expected=30, + msg="$indexOfArray should access element from $slice 3-arg result", + ), + ExpressionTestCase( + "arrayElemAt_elem_from_reverseArray", + expression={"$arrayElemAt": [{"$reverseArray": [[10, 20, 30]]}, 0]}, + expected=30, + msg="$indexOfArray should access element from $reverseArray result", + ), + ExpressionTestCase( + "arrayElemAt_elem_from_concatArrays", + expression={"$arrayElemAt": [{"$concatArrays": [[10, 20], [30, 40]]}, 2]}, + expected=30, + msg="$indexOfArray should access element from $concatArrays result", + ), + ExpressionTestCase( + "arrayElemAt_computed_index", + expression={"$arrayElemAt": [[10, 20, 30], {"$subtract": [3, 1]}]}, + expected=30, + msg="$indexOfArray should use computed index from $subtract", + ), +] + +# $in combinations +IN_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "in_value_from_add", + expression={"$in": [{"$add": [1, 1]}, [1, 2, 3]]}, + expected=True, + msg="$indexOfArray should find value computed by $add", + ), + ExpressionTestCase( + "in_array_from_concatArrays", + expression={"$in": [3, {"$concatArrays": [[1, 2], [3, 4]]}]}, + expected=True, + msg="$indexOfArray should search in $concatArrays result", + ), + ExpressionTestCase( + "in_value_from_arrayElemAt", + expression={"$in": [{"$arrayElemAt": [[10, 20, 30], 1]}, [5, 20, 35]]}, + expected=True, + msg="$indexOfArray should find value from $arrayElemAt", + ), + ExpressionTestCase( + "in_array_from_filter", + expression={ + "$in": [ + 4, + { + "$filter": { + "input": [1, 2, 3, 4, 5], + "as": "n", + "cond": {"$gte": ["$$n", 3]}, + } + }, + ] + }, + expected=True, + msg="$indexOfArray should search in $filter result", + ), + ExpressionTestCase( + "in_array_from_map", + expression={ + "$in": [ + 20, + { + "$map": { + "input": [1, 2, 3], + "as": "n", + "in": {"$multiply": ["$$n", 10]}, + } + }, + ] + }, + expected=True, + msg="$indexOfArray should search in $map result", + ), + ExpressionTestCase( + "in_array_from_reverseArray", + expression={"$in": [1, {"$reverseArray": [[1, 2, 3]]}]}, + expected=True, + msg="$indexOfArray should search in $reverseArray result", + ), + ExpressionTestCase( + "in_cond_with_inner_in", + expression={"$in": [5, {"$cond": [{"$in": ["a", ["a", "b"]]}, [5, 6], [7, 8]]}]}, + expected=True, + msg="$indexOfArray should search in $cond-selected array", + ), + ExpressionTestCase( + "in_inside_cond", + expression={"$cond": [{"$in": [2, [1, 2, 3]]}, "found", "not_found"]}, + expected="found", + msg="$indexOfArray should use $in result in $cond", + ), + ExpressionTestCase( + "in_value_from_indexOfArray", + expression={"$in": [{"$indexOfArray": [[10, 20, 30], 20]}, [0, 1, 2]]}, + expected=True, + msg="$indexOfArray should find $indexOfArray result in array", + ), + ExpressionTestCase( + "in_nested_decimal128", + expression={ + "$in": [ + {"$arrayElemAt": [[Decimal128("1.1"), Decimal128("2.2")], 1]}, + [Decimal128("2.2"), Decimal128("3.3")], + ] + }, + expected=True, + msg="$indexOfArray should find Decimal128 from $arrayElemAt in array", + ), +] + +# $indexOfArray combinations +INDEX_OF_ARRAY_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "indexOfArray_result_as_arrayElemAt_index", + expression={"$arrayElemAt": [[10, 20, 30], {"$indexOfArray": [[10, 20, 30], 20]}]}, + expected=20, + msg="$indexOfArray should use $indexOfArray result as $arrayElemAt index", + ), + ExpressionTestCase( + "indexOfArray_search_from_add", + expression={"$indexOfArray": [[1, 2, 3], {"$add": [1, 1]}]}, + expected=1, + msg="$indexOfArray should search for value computed by $add", + ), + ExpressionTestCase( + "indexOfArray_array_from_concatArrays", + expression={"$indexOfArray": [{"$concatArrays": [[1, 2], [3, 4]]}, 3]}, + expected=2, + msg="$indexOfArray should search in $concatArrays result", + ), + ExpressionTestCase( + "indexOfArray_array_from_filter", + expression={ + "$indexOfArray": [ + {"$filter": {"input": [1, 2, 3, 4, 5], "cond": {"$gt": ["$$this", 2]}}}, + 4, + ] + }, + expected=1, + msg="$indexOfArray should search in $filter result", + ), + ExpressionTestCase( + "indexOfArray_result_in_cond", + expression={ + "$cond": [{"$gte": [{"$indexOfArray": [[1, 2, 3], 2]}, 0]}, "found", "not_found"] + }, + expected="found", + msg="$indexOfArray should use $indexOfArray result in $cond", + ), + ExpressionTestCase( + "indexOfArray_start_from_subtract", + expression={"$indexOfArray": [[1, 2, 1, 2], 1, {"$subtract": [3, 1]}]}, + expected=2, + msg="$indexOfArray should use $subtract result as start index", + ), + ExpressionTestCase( + "indexOfArray_via_arrayElemAt", + expression={ + "$indexOfArray": [ + ["a", "b", "c", "d"], + { + "$arrayElemAt": [ + ["a", "b", "c", "d"], + {"$indexOfArray": [[10, 20, 30], 20]}, + ] + }, + ] + }, + expected=1, + msg="$indexOfArray should search for value from nested $arrayElemAt/$indexOfArray", + ), + ExpressionTestCase( + "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="$indexOfArray should find mixed BSON subarray via nested operators", + ), + ExpressionTestCase( + "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="$indexOfArray should resolve triple-nested Decimal128 operators", + ), +] + +# $slice combinations +SLICE_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "slice_array_from_concatArrays", + expression={"$slice": [{"$concatArrays": [[1, 2], [3, 4, 5]]}, 3]}, + expected=[1, 2, 3], + msg="$indexOfArray should slice $concatArrays result", + ), + ExpressionTestCase( + "slice_n_from_subtract", + expression={"$slice": [[1, 2, 3, 4, 5], {"$subtract": [5, 2]}]}, + expected=[1, 2, 3], + msg="$indexOfArray should use $subtract result as n", + ), + ExpressionTestCase( + "slice_array_from_filter", + expression={ + "$slice": [ + { + "$filter": { + "input": [1, 2, 3, 4, 5], + "as": "n", + "cond": {"$gte": ["$$n", 3]}, + } + }, + 2, + ] + }, + expected=[3, 4], + msg="$indexOfArray should slice $filter result", + ), + ExpressionTestCase( + "slice_position_from_indexOfArray", + expression={ + "$slice": [ + [10, 20, 30, 40, 50], + {"$indexOfArray": [[10, 20, 30, 40, 50], 30]}, + 2, + ] + }, + expected=[30, 40], + msg="$indexOfArray should use $indexOfArray result as position", + ), + ExpressionTestCase( + "slice_array_from_map", + expression={ + "$slice": [ + { + "$map": { + "input": [1, 2, 3], + "as": "n", + "in": {"$multiply": ["$$n", 10]}, + } + }, + 2, + ] + }, + expected=[10, 20], + msg="$indexOfArray should slice $map result", + ), + ExpressionTestCase( + "slice_array_from_reverseArray", + expression={"$slice": [{"$reverseArray": [[1, 2, 3, 4, 5]]}, 3]}, + expected=[5, 4, 3], + msg="$indexOfArray should slice $reverseArray result", + ), + ExpressionTestCase( + "slice_n_from_size", + expression={ + "$slice": [[10, 20, 30, 40], {"$subtract": [{"$size": [[10, 20, 30, 40]]}, 1]}] + }, + expected=[10, 20, 30], + msg="$indexOfArray should use $size-based computation as n", + ), +] + +# Property [Type Preservation]: $arrayElemAt preserves element type and reports missing for OOB. +ARRAY_ELEM_AT_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arrayElemAt_oob_is_missing", + expression={"$type": {"$arrayElemAt": [[1, 2, 3], 10]}}, + expected="missing", + msg="$arrayElemAt out-of-bounds should produce missing, not null", + ), + ExpressionTestCase( + "arrayElemAt_regex_type_preserved", + expression={"$type": {"$arrayElemAt": [[Regex("abc")], 0]}}, + expected="regex", + msg="$arrayElemAt should preserve the regex element type", + ), +] + +# Aggregate all combination tests +ALL_COMBINATION_TESTS = ( + ARRAY_ELEM_AT_COMBINATION_TESTS + + IN_COMBINATION_TESTS + + INDEX_OF_ARRAY_COMBINATION_TESTS + + SLICE_COMBINATION_TESTS + + ARRAY_ELEM_AT_TYPE_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) 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..73a5b7873 --- /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( + "filter_then_size", + expression={"$size": {"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 2]}}}}, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=3, + msg="$filter size of filtered array", + ), + ExpressionTestCase( + "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="$filter should filter mapped array", + ), + ExpressionTestCase( + "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( + "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..bb74169da --- /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( + "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( + "isarray_on_concatArrays", + expression={"$isArray": {"$concatArrays": ["$a", "$b"]}}, + doc={"a": [1], "b": [2]}, + expected=True, + msg="$isArray on $concatArrays result should return true", + ), + ExpressionTestCase( + "isarray_on_objectToArray", + expression={"$isArray": {"$objectToArray": "$obj"}}, + doc={"obj": {"a": 1}}, + expected=True, + msg="$isArray on $objectToArray result should return true", + ), + ExpressionTestCase( + "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 + )