diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/set/setDifference/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setDifference/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/set/setDifference/test_setDifference_core.py b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setDifference/test_setDifference_core.py new file mode 100644 index 000000000..1cc68d3db --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setDifference/test_setDifference_core.py @@ -0,0 +1,533 @@ +"""Tests for $setDifference core behavior: difference, dedup, ordering, asymmetry, and nesting.""" + +from datetime import datetime, timezone + +import pytest +from bson import Decimal128, Int64, MaxKey, MinKey, 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_NEGATIVE_ONE_AND_HALF, + DECIMAL128_TWO_AND_HALF, + DECIMAL128_ZERO, + FLOAT_INFINITY, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT64_ZERO, +) + +# Property [Basic Difference]: the result holds the deduplicated elements of the +# first array that are absent from the second. +SETDIFFERENCE_BASIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "basic_difference", + doc={"arr1": ["a", "c"], "arr2": ["a", "b"]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=["c"], + msg="Should return elements in first not in second", + ), + ExpressionTestCase( + "complete_overlap", + doc={"arr1": ["a", "c"], "arr2": ["a", "b", "c"]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Should return empty when second is superset", + ), + ExpressionTestCase( + "no_overlap", + doc={"arr1": ["a", "b"], "arr2": ["c", "d"]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=["a", "b"], + msg="Should return all when no overlap", + ), + ExpressionTestCase( + "identical_arrays", + doc={"arr1": ["a", "b"], "arr2": ["a", "b"]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Should return empty for identical arrays", + ), + ExpressionTestCase( + "first_empty", + doc={"arr1": [], "arr2": ["a", "b"]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Should return empty when first is empty", + ), + ExpressionTestCase( + "second_empty", + doc={"arr1": ["a", "b"], "arr2": []}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=["a", "b"], + msg="Should return all when second is empty", + ), + ExpressionTestCase( + "both_empty", + doc={"arr1": [], "arr2": []}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Should return empty when both empty", + ), + ExpressionTestCase( + "single_present", + doc={"arr1": ["a"], "arr2": ["a"]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Single element present in second returns empty", + ), + ExpressionTestCase( + "single_absent", + doc={"arr1": ["a"], "arr2": ["b"]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=["a"], + msg="Single element absent from second returns it", + ), + ExpressionTestCase( + "result_always_array_single", + doc={"arr1": ["a", "b"], "arr2": ["b"]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=["a"], + msg="Single element result should be array", + ), + ExpressionTestCase( + "int_basic", + doc={"arr1": [1, 2, 3, 4], "arr2": [1, 2, 3]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[4], + msg="Should compute difference with ints", + ), + ExpressionTestCase( + "int_no_overlap", + doc={"arr1": [1, 2, 3, 4], "arr2": [1, 2, 3, 5, 6]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[4], + msg="Should return only elements unique to first with partial integer overlap", + ), + ExpressionTestCase( + "int_subset", + doc={"arr1": [2, 3], "arr2": [2, 3, 4]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Should return empty when first is subset of second", + ), + ExpressionTestCase( + "string_diff", + doc={"arr1": ["y", "z", "j"], "arr2": ["x", "y", "z"]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=["j"], + msg="Should compute difference with strings", + ), + ExpressionTestCase( + "int_vs_string_no_overlap", + doc={"arr1": [1, 2, 3], "arr2": ["x", "y", "z"]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[1, 2, 3], + msg="Should return all elements when integer and string types have no overlap", + ), +] + +# Property [Duplicate Handling]: set semantics deduplicate both arrays before +# computing the difference. +SETDIFFERENCE_DUPES_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dupes_in_first", + doc={"arr1": ["a", "a", "b"], "arr2": ["b"]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=["a"], + msg="Should deduplicate first array", + ), + ExpressionTestCase( + "dupes_in_second", + doc={"arr1": ["a", "b"], "arr2": ["b", "b", "b"]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=["a"], + msg="Should handle duplicates in second array without affecting result", + ), + ExpressionTestCase( + "dupes_in_both", + doc={"arr1": ["a", "a", "b", "b"], "arr2": ["b", "b"]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=["a"], + msg="Should deduplicate both", + ), + ExpressionTestCase( + "all_duped_removed", + doc={"arr1": ["a", "a"], "arr2": ["a"]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Should return empty when all removed", + ), + ExpressionTestCase( + "all_same_not_in_second", + doc={"arr1": ["a", "a", "a"], "arr2": []}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=["a"], + msg="Should deduplicate to single element", + ), + ExpressionTestCase( + "heavy_dupes_first", + doc={"arr1": [1, 2, 3, 2, 2, 3, 3, 3], "arr2": []}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[1, 2, 3], + msg="Should deduplicate heavy duplicates", + ), + ExpressionTestCase( + "heavy_dupes_both", + doc={"arr1": [1, 2, 3, 3, 2, 3, 2, 3], "arr2": [1, 2, 3, 2, 3]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Heavy duplicates in both returns empty", + ), + ExpressionTestCase( + "int_dupes_second", + doc={"arr1": [1, 2, 3], "arr2": [1, 1, 2, 2, 3, 3]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Should return empty when all elements match despite duplicates in second", + ), + ExpressionTestCase( + "order_independent", + doc={"arr1": ["c", "a", "b"], "arr2": ["b", "a"]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=["c"], + msg="Order should not matter for matching", + ), +] + +# Property [Asymmetry]: A minus B is not the same as B minus A. +SETDIFFERENCE_ASYMMETRY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "asym_partial_overlap_ab", + doc={"arr1": [1, 2, 3], "arr2": [2, 3, 4]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[1], + msg="Should return only first-unique elements for partial overlap forward", + ), + ExpressionTestCase( + "asym_partial_overlap_ba", + doc={"arr1": [2, 3, 4], "arr2": [1, 2, 3]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[4], + msg="Should return only first-unique elements for partial overlap reversed", + ), + ExpressionTestCase( + "asym_identical", + doc={"arr1": [1, 2], "arr2": [1, 2]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Should return empty for identical arrays", + ), + ExpressionTestCase( + "asym_first_nonempty_ab", + doc={"arr1": [1], "arr2": []}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[1], + msg="Should return all elements when subtracting empty array", + ), + ExpressionTestCase( + "asym_first_nonempty_ba", + doc={"arr1": [], "arr2": [1]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Should return empty when first array is empty", + ), + ExpressionTestCase( + "asym_multi_elem_ab", + doc={"arr1": [1, 2], "arr2": [3]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[1, 2], + msg="Should return all first elements when second has no overlap", + ), + ExpressionTestCase( + "asym_multi_elem_ba", + doc={"arr1": [3], "arr2": [1, 2]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[3], + msg="Should return first element when it has no overlap with second", + ), + ExpressionTestCase( + "asym_no_overlap_forward", + doc={"arr1": [1, 2, 3], "arr2": [4, 5, 6]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[1, 2, 3], + msg="Should return all first elements when no overlap forward", + ), + ExpressionTestCase( + "asym_no_overlap_reverse", + doc={"arr1": [4, 5, 6], "arr2": [1, 2, 3]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[4, 5, 6], + msg="Should return all first elements when no overlap reversed", + ), + ExpressionTestCase( + "asym_empty_first_forward", + doc={"arr1": [], "arr2": [1, 2, 3]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Should return empty when first is empty and second has elements", + ), + ExpressionTestCase( + "asym_empty_second_reverse", + doc={"arr1": [4, 5, 6], "arr2": []}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[4, 5, 6], + msg="Should return all elements when second is empty reversed", + ), +] + +# Property [Result Ordering]: the result preserves the first array's insertion order. +SETDIFFERENCE_ORDER_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_order", + doc={"arr1": ["red", "blue", "green"], "arr2": []}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=["red", "blue", "green"], + msg="Should preserve insertion order for string elements", + ), + ExpressionTestCase( + "int_order", + doc={"arr1": [INT32_MAX, 0, INT32_MIN], "arr2": []}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[INT32_MAX, 0, INT32_MIN], + msg="Should preserve insertion order for integer elements", + ), + ExpressionTestCase( + "long_order", + doc={"arr1": [Int64(INT32_MAX), INT64_ZERO, Int64(INT32_MIN)], "arr2": []}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[Int64(INT32_MAX), INT64_ZERO, Int64(INT32_MIN)], + msg="Should preserve insertion order for long elements", + ), + ExpressionTestCase( + "decimal_order", + doc={ + "arr1": [DECIMAL128_TWO_AND_HALF, DECIMAL128_ZERO, DECIMAL128_NEGATIVE_ONE_AND_HALF], + "arr2": [], + }, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[DECIMAL128_TWO_AND_HALF, DECIMAL128_ZERO, DECIMAL128_NEGATIVE_ONE_AND_HALF], + msg="Should preserve insertion order for decimal128 elements", + ), + ExpressionTestCase( + "bool_order", + doc={"arr1": [True, False], "arr2": []}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[True, False], + msg="Should preserve insertion order for boolean elements", + ), + ExpressionTestCase( + "date_order", + doc={ + "arr1": [ + datetime(2023, 1, 1, tzinfo=timezone.utc), + datetime(2018, 1, 1, tzinfo=timezone.utc), + datetime(2017, 1, 1, tzinfo=timezone.utc), + ], + "arr2": [], + }, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[ + datetime(2023, 1, 1, tzinfo=timezone.utc), + datetime(2018, 1, 1, tzinfo=timezone.utc), + datetime(2017, 1, 1, tzinfo=timezone.utc), + ], + msg="Should preserve insertion order for date elements", + ), + ExpressionTestCase( + "timestamp_order", + doc={"arr1": [Timestamp(1981, 0), Timestamp(1980, 0)], "arr2": []}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[Timestamp(1981, 0), Timestamp(1980, 0)], + msg="Should preserve insertion order for timestamp elements", + ), + ExpressionTestCase( + "special_bson_order", + doc={"arr1": [FLOAT_INFINITY, MaxKey(), MinKey(), FLOAT_NEGATIVE_INFINITY], "arr2": []}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[FLOAT_INFINITY, MaxKey(), MinKey(), FLOAT_NEGATIVE_INFINITY], + msg="Should preserve insertion order for special BSON values", + ), +] + +# Property [Atomic Nesting]: nested arrays are compared as whole elements, not +# descended into. +SETDIFFERENCE_NESTED_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_atomic", + doc={"arr1": ["a", "b"], "arr2": [["a", "b"]]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=["a", "b"], + msg="Should treat nested array as atomic element distinct from its contents", + ), + ExpressionTestCase( + "nested_match", + doc={"arr1": [["a", "b"], "c"], "arr2": [["a", "b"]]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=["c"], + msg="Should remove matching nested arrays from result", + ), + ExpressionTestCase( + "nested_different", + doc={"arr1": [["a"], ["b"]], "arr2": [["a"]]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[["b"]], + msg="Should preserve non-matching nested arrays", + ), + ExpressionTestCase( + "deeply_nested", + doc={"arr1": [[[1]], [[2]]], "arr2": [[[1]]]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[[[2]]], + msg="Should handle deeply nested arrays as atomic elements", + ), + ExpressionTestCase( + "nested_with_scalar", + doc={"arr1": [[1, 2], 3], "arr2": [1, 2, 3]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[[1, 2]], + msg="Should not descend into nested arrays when comparing with scalars", + ), + ExpressionTestCase( + "nested_identical", + doc={"arr1": [[1, 2], 3], "arr2": [[1, 2], 3]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Should return empty for identical arrays containing nested arrays", + ), + ExpressionTestCase( + "empty_nested", + doc={"arr1": [[]], "arr2": ["red"]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[[]], + msg="Should treat empty nested array as a distinct element from strings", + ), + ExpressionTestCase( + "multi_level_remove_scalars", + doc={"arr1": ["a", "b", ["a"], [["b"]], [[["c"]]]], "arr2": ["b", "a"]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[["a"], [["b"]], [[["c"]]]], + msg="Should preserve nested arrays when removing only scalar elements", + ), + ExpressionTestCase( + "multi_level_remove_nested", + doc={"arr1": ["a", "b", ["a"], [["b"]], [[["c"]]]], "arr2": ["b", ["a"]]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=["a", [["b"]], [[["c"]]]], + msg="Should remove single-nested array while preserving deeper nested arrays", + ), + ExpressionTestCase( + "multi_level_remove_double_nested", + doc={"arr1": ["a", "b", ["a"], [["b"]], [[["c"]]]], "arr2": [[["b"]], ["a"]]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=["a", "b", [[["c"]]]], + msg="Should remove double-nested arrays while preserving scalars and triple-nested", + ), + ExpressionTestCase( + "multi_level_remove_all_nested", + doc={"arr1": ["a", "b", ["a"], [["b"]], [[["c"]]]], "arr2": [[[["c"]]], [["b"]], ["a"]]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=["a", "b"], + msg="Should preserve only scalars when all nested arrays are removed", + ), + ExpressionTestCase( + "multi_level_no_match", + doc={"arr1": ["a", "b", ["a"], [["b"]], [[["c"]]]], "arr2": [[[["b"]]], [["a"]], ["c"]]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=["a", "b", ["a"], [["b"]], [[["c"]]]], + msg="Should return all elements when nesting depths differ between arrays", + ), + ExpressionTestCase( + "nested_num_remove_scalars", + doc={ + "arr1": [1, 2, [2], [[Int64(3)]], [[[Decimal128("4")]]]], + "arr2": [1, Decimal128("2")], + }, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[[2], [[Int64(3)]], [[[Decimal128("4")]]]], + msg="Should apply numeric equivalence only at top level for nested arrays", + ), + ExpressionTestCase( + "nested_num_equiv_in_nested", + doc={ + "arr1": [1, 2, [2], [[Int64(3)]], [[[Decimal128("4e0")]]]], + "arr2": [Int64(2), [Decimal128("200E-2")]], + }, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[1, [[Int64(3)]], [[[Decimal128("4e0")]]]], + msg="Should handle numeric equivalence within nested array elements", + ), + ExpressionTestCase( + "nested_num_no_match_different_nesting", + doc={ + "arr1": [1, Decimal128("2"), [2], [[Int64(3)]], [[[Decimal128("4e0")]]]], + "arr2": [[[[4]], [[3]], [2]]], + }, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[1, Decimal128("2"), [2], [[Int64(3)]], [[[Decimal128("4e0")]]]], + msg="Should return all elements when nesting structure differs for numeric values", + ), +] + +# Property [Large Arrays]: the operator scales to large inputs and still +# deduplicates and computes the difference correctly. +SETDIFFERENCE_LARGE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "large_identical", + doc={"arr1": list(range(1000)), "arr2": list(range(1000))}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Should return empty for two identical 1000-element arrays", + ), + ExpressionTestCase( + "large_first_small_second", + doc={"arr1": list(range(1000)), "arr2": list(range(10))}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=list(range(10, 1000)), + msg="Should return 990 elements when subtracting 10 from 1000-element array", + ), + ExpressionTestCase( + "small_first_large_second", + doc={"arr1": list(range(10)), "arr2": list(range(1000))}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Should return empty when 10-element array is subset of 1000-element array", + ), + ExpressionTestCase( + "large_duplicates", + doc={"arr1": ["a"] * 10_000, "arr2": []}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=["a"], + msg="Should deduplicate 10000 identical elements to a single element", + ), + ExpressionTestCase( + "scale_10k", + doc={"arr1": list(range(10_000)), "arr2": list(range(5_000, 15_000))}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=list(range(5_000)), + msg="Should return 5000 elements for 10K arrays with partial overlap", + ), +] + +SETDIFFERENCE_CORE_TESTS: list[ExpressionTestCase] = ( + SETDIFFERENCE_BASIC_TESTS + + SETDIFFERENCE_DUPES_TESTS + + SETDIFFERENCE_ASYMMETRY_TESTS + + SETDIFFERENCE_ORDER_TESTS + + SETDIFFERENCE_NESTED_TESTS + + SETDIFFERENCE_LARGE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(SETDIFFERENCE_CORE_TESTS)) +def test_setDifference_core(collection, test): + """Test $setDifference core behavior with field-reference array operands.""" + 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/set/setDifference/test_setDifference_data_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setDifference/test_setDifference_data_types.py new file mode 100644 index 000000000..0dec8a41f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setDifference/test_setDifference_data_types.py @@ -0,0 +1,576 @@ +"""Tests for $setDifference type distinction, numeric equivalence, and BSON element types.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, 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_TRAILING_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT64_ZERO, +) + +# Property [BSON Type Distinction]: values of different BSON types are never equal +# even when they look similar. +SETDIFFERENCE_BSON_DISTINCTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "false_vs_0", + doc={"arr1": [False, 0], "arr2": [False]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[0], + msg="Should treat false and zero as distinct BSON types", + ), + ExpressionTestCase( + "true_vs_1", + doc={"arr1": [True, 1], "arr2": [True]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[1], + msg="Should treat true and one as distinct BSON types", + ), + ExpressionTestCase( + "null_in_array", + doc={"arr1": [None, 1], "arr2": [1]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[None], + msg="Should preserve null element when it is not in the second array", + ), + ExpressionTestCase( + "empty_string_vs_null", + doc={"arr1": ["", None], "arr2": [None]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[""], + msg="Should treat empty string and null as distinct BSON types", + ), +] + +# Property [NaN Handling]: NaN is equal to NaN across float and decimal128 in the +# set comparison context, and is deduplicated. +SETDIFFERENCE_NAN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nan_in_second", + doc={"arr1": [1, 2], "arr2": [FLOAT_NAN, 1]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[2], + msg="Should handle NaN in second array and remove matching elements", + ), + ExpressionTestCase( + "nan_in_both", + doc={"arr1": [FLOAT_NAN, 1], "arr2": [FLOAT_NAN, 2]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[1], + msg="Should treat NaN as equal to NaN in set comparison context", + ), + ExpressionTestCase( + "nan_cross_type", + doc={"arr1": [FLOAT_NAN, 1], "arr2": [DECIMAL128_NAN, 2]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[1], + msg="Should treat float NaN as equal to decimal128 NaN", + ), + ExpressionTestCase( + "nan_preserved_in_first", + doc={"arr1": [FLOAT_NAN, 1, 2], "arr2": [1]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[pytest.approx(FLOAT_NAN, nan_ok=True), 2], + msg="Should preserve NaN element in result when not removed by second array", + ), + ExpressionTestCase( + "nan_dedup", + doc={"arr1": [FLOAT_NAN, FLOAT_NAN, 1], "arr2": []}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[pytest.approx(FLOAT_NAN, nan_ok=True), 1], + msg="Should deduplicate multiple NaN elements to a single NaN", + ), +] + +# Property [Infinity Handling]: positive and negative infinity are distinct, and +# match across float and decimal128. +SETDIFFERENCE_INFINITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "inf_in_first", + doc={"arr1": [FLOAT_INFINITY, 1], "arr2": [1]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[FLOAT_INFINITY], + msg="Should preserve infinity when it is not in the second array", + ), + ExpressionTestCase( + "neg_inf_in_first", + doc={"arr1": [FLOAT_NEGATIVE_INFINITY, 1], "arr2": [1]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[FLOAT_NEGATIVE_INFINITY], + msg="Should preserve negative infinity when it is not in the second array", + ), + ExpressionTestCase( + "inf_removed", + doc={"arr1": [FLOAT_INFINITY, 1], "arr2": [FLOAT_INFINITY]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[1], + msg="Should remove infinity when it appears in the second array", + ), + ExpressionTestCase( + "inf_vs_neg_inf", + doc={"arr1": [FLOAT_INFINITY], "arr2": [FLOAT_NEGATIVE_INFINITY]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[FLOAT_INFINITY], + msg="Should treat positive and negative infinity as distinct values", + ), + ExpressionTestCase( + "neg_inf_vs_neg_inf", + doc={"arr1": [FLOAT_NEGATIVE_INFINITY], "arr2": [FLOAT_NEGATIVE_INFINITY]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Should return empty when both arrays contain negative infinity", + ), + ExpressionTestCase( + "inf_cross_type", + doc={"arr1": [DECIMAL128_INFINITY, 1], "arr2": [FLOAT_INFINITY]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[1], + msg="Should treat decimal128 Infinity as equal to float Infinity", + ), + ExpressionTestCase( + "neg_inf_cross_type", + doc={"arr1": [DECIMAL128_NEGATIVE_INFINITY, 1], "arr2": [FLOAT_NEGATIVE_INFINITY]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[1], + msg="Should treat decimal128 -Infinity as equal to float -Infinity", + ), +] + +# Property [Numeric Equivalence]: int, long, double, and decimal128 that represent +# the same value are equal for set membership. +SETDIFFERENCE_NUMERIC_EQUIV_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int_vs_long", + doc={"arr1": [1, 2], "arr2": [Int64(1)]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[2], + msg="Should treat int and long as equivalent for same value", + ), + ExpressionTestCase( + "int_vs_double", + doc={"arr1": [1, 2], "arr2": [1.0]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[2], + msg="Should treat int and double as equivalent for same value", + ), + ExpressionTestCase( + "int_vs_decimal", + doc={"arr1": [1, 2], "arr2": [Decimal128("1")]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[2], + msg="Should treat int and decimal128 as equivalent for same value", + ), + ExpressionTestCase( + "long_vs_double", + doc={"arr1": [Int64(1), Int64(2)], "arr2": [1.0]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[Int64(2)], + msg="Should treat long and double as equivalent for same value", + ), + ExpressionTestCase( + "long_vs_decimal", + doc={"arr1": [Int64(1), Int64(2)], "arr2": [Decimal128("1")]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[Int64(2)], + msg="Should treat long and decimal128 as equivalent for same value", + ), + ExpressionTestCase( + "double_vs_decimal", + doc={"arr1": [1.0, 2.0], "arr2": [Decimal128("1")]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[2.0], + msg="Should treat double and decimal128 as equivalent for same value", + ), + ExpressionTestCase( + "all_four_types", + doc={"arr1": [1, Int64(1), 1.0, Decimal128("1")], "arr2": [1]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Should return empty when all four numeric types represent the same value", + ), + ExpressionTestCase( + "int0_vs_double0", + doc={"arr1": [0], "arr2": [DOUBLE_ZERO]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Should treat integer zero and double zero as equivalent", + ), + ExpressionTestCase( + "int0_vs_long0", + doc={"arr1": [0], "arr2": [INT64_ZERO]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Should treat integer zero and long zero as equivalent", + ), + ExpressionTestCase( + "int0_vs_decimal0", + doc={"arr1": [0], "arr2": [DECIMAL128_ZERO]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Should treat integer zero and decimal128 zero as equivalent", + ), + ExpressionTestCase( + "double_neg0_vs_0", + doc={"arr1": [DOUBLE_NEGATIVE_ZERO, 1], "arr2": [DOUBLE_ZERO]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[1], + msg="Should treat double negative zero as equivalent to positive zero", + ), + ExpressionTestCase( + "decimal_neg0_vs_0", + doc={"arr1": [DECIMAL128_NEGATIVE_ZERO, 1], "arr2": [DECIMAL128_ZERO]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[1], + msg="Should treat decimal128 negative zero as equivalent to positive zero", + ), + ExpressionTestCase( + "double_neg0_vs_int0", + doc={"arr1": [DOUBLE_NEGATIVE_ZERO, 1], "arr2": [0]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[1], + msg="Should treat double negative zero as equivalent to integer zero", + ), + ExpressionTestCase( + "decimal_neg0_vs_int0", + doc={"arr1": [DECIMAL128_NEGATIVE_ZERO, 1], "arr2": [0]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[1], + msg="Should treat decimal128 negative zero as equivalent to integer zero", + ), + ExpressionTestCase( + "long_vs_decimal_pair", + doc={"arr1": [Int64(1), Int64(2)], "arr2": [Decimal128("1"), Decimal128("2")]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Should return empty when long and decimal128 pairs are equivalent", + ), + ExpressionTestCase( + "all_numeric_types_equiv", + doc={"arr1": [1, 2, 3], "arr2": [1.0, Int64(2), 3]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Should return empty when mixed numeric types are all equivalent", + ), +] + +# Property [Object Comparison]: objects are equal only when keys, values, and key +# order all match. +SETDIFFERENCE_OBJECT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "identical_objects", + doc={"arr1": [{"a": 1, "b": 2}], "arr2": [{"a": 1, "b": 2}]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Should return empty for identical objects with same key order", + ), + ExpressionTestCase( + "same_keys_diff_values", + doc={"arr1": [{"a": 1}], "arr2": [{"a": 2}]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[{"a": 1}], + msg="Should treat objects with same keys but different values as distinct", + ), + ExpressionTestCase( + "different_keys", + doc={"arr1": [{"a": 1}], "arr2": [{"b": 1}]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[{"a": 1}], + msg="Should treat objects with different keys as distinct", + ), + ExpressionTestCase( + "nested_objects", + doc={"arr1": [{"a": {"b": 1}}], "arr2": [{"a": {"b": 1}}]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Should return empty for identical nested objects", + ), + ExpressionTestCase( + "nested_objects_diff", + doc={"arr1": [{"a": {"b": 1}}], "arr2": [{"a": {"b": 2}}]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[{"a": {"b": 1}}], + msg="Should treat nested objects with different values as distinct", + ), + ExpressionTestCase( + "empty_objects", + doc={"arr1": [{}], "arr2": [{}]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Should return empty for identical empty objects", + ), + ExpressionTestCase( + "empty_vs_nonempty", + doc={"arr1": [{}, {"a": 1}], "arr2": [{}]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[{"a": 1}], + msg="Should treat empty and non-empty objects as distinct elements", + ), + ExpressionTestCase( + "object_with_multiple_elements", + doc={"arr1": [{"a": 1}, {"a": 2}, {"a": 3}], "arr2": [{"a": 1}, {"a": 2}]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[{"a": 3}], + msg="Should handle difference with multiple object elements", + ), + ExpressionTestCase( + "object_key_order", + doc={"arr1": [{"a": 1, "b": 2}], "arr2": [{"b": 2, "a": 1}]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[{"a": 1, "b": 2}], + msg="Should treat objects with different key order as distinct", + ), +] + +# Property [BSON Element Types]: dates, ObjectIds, binary, timestamps, and +# min/max keys compare by their own type-specific equality. +SETDIFFERENCE_BSON_ELEMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "mixed_remove_subset", + doc={ + "arr1": [1, "a", True, None, datetime(2024, 1, 1, tzinfo=timezone.utc)], + "arr2": [1, True], + }, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=["a", None, datetime(2024, 1, 1, tzinfo=timezone.utc)], + msg="Should remove subset of mixed BSON types and preserve the rest", + ), + ExpressionTestCase( + "mixed_remove_all", + doc={"arr1": [1, "a", True], "arr2": [1, "a", True]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Should return empty when all mixed BSON type elements are removed", + ), + ExpressionTestCase( + "date_remove", + doc={ + "arr1": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 1, 2, tzinfo=timezone.utc), + ], + "arr2": [datetime(2024, 1, 1, tzinfo=timezone.utc)], + }, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[datetime(2024, 1, 2, tzinfo=timezone.utc)], + msg="Should handle date element comparison correctly", + ), + ExpressionTestCase( + "objectid_remove", + doc={ + "arr1": [ObjectId("aaaaaaaaaaaaaaaaaaaaaaaa"), ObjectId("bbbbbbbbbbbbbbbbbbbbbbbb")], + "arr2": [ObjectId("aaaaaaaaaaaaaaaaaaaaaaaa")], + }, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[ObjectId("bbbbbbbbbbbbbbbbbbbbbbbb")], + msg="Should handle ObjectId element comparison correctly", + ), + ExpressionTestCase( + "objectid_no_match", + doc={ + "arr1": [ObjectId("aaaaaaaaaaaaaaaaaaaaaaaa")], + "arr2": [ObjectId("bbbbbbbbbbbbbbbbbbbbbbbb")], + }, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[ObjectId("aaaaaaaaaaaaaaaaaaaaaaaa")], + msg="Should preserve ObjectId when it does not match any in second array", + ), + ExpressionTestCase( + "binary_remove", + doc={ + "arr1": [Binary(b"\x01\x02\x03", 0), Binary(b"\x04\x05\x06", 0)], + "arr2": [Binary(b"\x01\x02\x03", 0)], + }, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[b"\x04\x05\x06"], + msg="Should handle binary data element comparison correctly", + ), + ExpressionTestCase( + "timestamp_remove", + doc={"arr1": [Timestamp(1000, 1), Timestamp(2000, 1)], "arr2": [Timestamp(1000, 1)]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[Timestamp(2000, 1)], + msg="Should handle timestamp element comparison correctly", + ), + ExpressionTestCase( + "timestamp_diff_increment", + doc={"arr1": [Timestamp(1000, 1)], "arr2": [Timestamp(1000, 2)]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[Timestamp(1000, 1)], + msg="Should treat timestamps with different increments as distinct elements", + ), + ExpressionTestCase( + "minkey_remove", + doc={"arr1": [MinKey(), 1, "a"], "arr2": [MinKey()]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[1, "a"], + msg="Should remove MinKey element when it appears in second array", + ), + ExpressionTestCase( + "maxkey_remove", + doc={"arr1": [MaxKey(), 1, "a"], "arr2": [MaxKey()]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[1, "a"], + msg="Should remove MaxKey element when it appears in second array", + ), + ExpressionTestCase( + "minkey_vs_maxkey", + doc={"arr1": [MinKey(), MaxKey()], "arr2": [MinKey()]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[MaxKey()], + msg="Should treat MinKey and MaxKey as distinct elements", + ), + ExpressionTestCase( + "both_minmax", + doc={"arr1": [MinKey(), MaxKey()], "arr2": [MinKey(), MaxKey()]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Should return empty when both MinKey and MaxKey are removed", + ), + ExpressionTestCase( + "regex_remove", + doc={"arr1": [Regex("abc", "i"), Regex("def", "")], "arr2": [Regex("abc", "i")]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[Regex("def", "")], + msg="Should handle regex element comparison correctly", + ), + ExpressionTestCase( + "regex_dedup", + doc={"arr1": [Regex("abc", ""), Regex("abc", "")], "arr2": []}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[Regex("abc", "")], + msg="Should deduplicate identical regex elements", + ), + ExpressionTestCase( + "regex_different_flags", + doc={"arr1": [Regex("abc", "i"), Regex("abc", "")], "arr2": [Regex("abc", "")]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[Regex("abc", "i")], + msg="Should treat regex with different flags as distinct elements", + ), + ExpressionTestCase( + "javascript_remove", + doc={"arr1": [Code("function(){}"), Code("other()")], "arr2": [Code("function(){}")]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[Code("other()")], + msg="Should handle javascript code element comparison correctly", + ), + ExpressionTestCase( + "javascript_dedup", + doc={"arr1": [Code("function(){}"), Code("function(){}")], "arr2": []}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[Code("function(){}")], + msg="Should deduplicate identical javascript code elements", + ), + ExpressionTestCase( + "special_values_remove_null_nan", + doc={ + "arr1": [None, FLOAT_NAN, MinKey(), FLOAT_INFINITY, MaxKey()], + "arr2": [None, DECIMAL128_NAN], + }, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[MinKey(), FLOAT_INFINITY, MaxKey()], + msg="Should remove null and NaN while preserving other special values", + ), + ExpressionTestCase( + "mixed_null_timestamp", + doc={"arr1": [None, False, True, Timestamp(1, 1)], "arr2": [True, False, None]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[Timestamp(1, 1)], + msg="Should preserve timestamp when null and boolean elements are removed", + ), +] + +# Property [Decimal128 Equivalence]: decimal128 values compare by numeric value, +# so representation differences do not matter. +SETDIFFERENCE_DECIMAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "trailing_zeros", + doc={"arr1": [DECIMAL128_TRAILING_ZERO], "arr2": [Decimal128("1.00")]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Should treat decimal128 values with trailing zeros as equivalent", + ), + ExpressionTestCase( + "exponent_notation", + doc={"arr1": [Decimal128("1E+2")], "arr2": [Decimal128("100")]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Should treat decimal128 exponent notation as equivalent to expanded form", + ), + ExpressionTestCase( + "high_precision_match", + doc={ + "arr1": [Decimal128("1.000000000000000000000000000000001")], + "arr2": [Decimal128("1.000000000000000000000000000000001")], + }, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[], + msg="Should return empty for identical high-precision decimal128 values", + ), + ExpressionTestCase( + "high_precision_mismatch", + doc={ + "arr1": [Decimal128("1.000000000000000000000000000000001")], + "arr2": [Decimal128("1.000000000000000000000000000000002")], + }, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[Decimal128("1.000000000000000000000000000000001")], + msg="Should treat high-precision decimal128 values differing in last digit as distinct", + ), +] + +# Property [String Comparison]: strings compare by exact code points, including +# unicode and emoji. +SETDIFFERENCE_STRING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "unicode", + doc={"arr1": ["café", "naïve"], "arr2": ["café", "résumé"]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=["naïve"], + msg="Should handle unicode string comparison correctly", + ), + ExpressionTestCase( + "emoji", + doc={"arr1": ["🎉", "🎊"], "arr2": ["🎉", "🎈"]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=["🎊"], + msg="Should handle emoji string comparison correctly", + ), +] + +SETDIFFERENCE_TYPE_TESTS: list[ExpressionTestCase] = ( + SETDIFFERENCE_BSON_DISTINCTION_TESTS + + SETDIFFERENCE_NAN_TESTS + + SETDIFFERENCE_INFINITY_TESTS + + SETDIFFERENCE_NUMERIC_EQUIV_TESTS + + SETDIFFERENCE_OBJECT_TESTS + + SETDIFFERENCE_BSON_ELEMENT_TESTS + + SETDIFFERENCE_DECIMAL_TESTS + + SETDIFFERENCE_STRING_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(SETDIFFERENCE_TYPE_TESTS)) +def test_setDifference_types(collection, test): + """Test $setDifference type handling with field-reference array operands.""" + 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/set/setDifference/test_setDifference_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setDifference/test_setDifference_expressions.py new file mode 100644 index 000000000..02b992519 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setDifference/test_setDifference_expressions.py @@ -0,0 +1,118 @@ +"""Tests for $setDifference operand input shapes, inline and resolved via paths and variables.""" + +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_TYPE_MISMATCH_ERROR +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Inline Operand Input]: literal arrays and expression-operator operands +# evaluate inline without a field reference. +SETDIFFERENCE_INLINE_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal", + expression={"$setDifference": [["a", "b", "c"], ["b"]]}, + expected=["a", "c"], + msg="Should return difference for literal array inputs", + ), + ExpressionTestCase( + "expression_operator", + expression={"$setDifference": [{"$literal": [1, 2, 3]}, {"$literal": [2, 3]}]}, + expected=[1], + msg="Should accept expression operator inputs like literal", + ), +] + +# Property [Operand Structure]: an operand list that is not two arrays is rejected. +SETDIFFERENCE_INLINE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "array_expression_input", + expression={"$setDifference": [["$arr1", "$arr2"]]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="Should return error for single array argument", + ), + ExpressionTestCase( + "object_expression_input", + expression={"$setDifference": {"a": "$arr1"}}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="Should return error for object expression input", + ), +] + +SETDIFFERENCE_INLINE_TESTS: list[ExpressionTestCase] = ( + SETDIFFERENCE_INLINE_INPUT_TESTS + SETDIFFERENCE_INLINE_ERROR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(SETDIFFERENCE_INLINE_TESTS)) +def test_setDifference_expression_inline(collection, test): + """Test $setDifference with inline literal and expression-operator operands.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) + + +# Property [Resolved Input Shapes]: operands reached through a nested-object path, +# an array-of-objects path, a numeric-index path, or a system variable resolve and +# feed the operator. +SETDIFFERENCE_INPUT_SHAPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_object_field", + doc={"a": {"b": [1, 2, 3]}}, + expression={"$setDifference": ["$a.b", [2]]}, + expected=[1, 3], + msg="Should resolve nested object field path for first array", + ), + ExpressionTestCase( + "composite_array_field", + doc={"a": [{"b": 1}, {"b": 2}]}, + expression={"$setDifference": ["$a.b", [1]]}, + expected=[2], + msg="Should resolve composite array field path through array of objects", + ), + # In an aggregation field path a numeric component is a field name, not an + # array index, so it resolves to an empty array over the array of arrays. + ExpressionTestCase( + "array_index_path", + doc={"a": [[1, 2], [3]]}, + expression={"$setDifference": ["$a.0", []]}, + expected=[], + msg="Should resolve numeric path component to an empty array in aggregation context", + ), + ExpressionTestCase( + "root_variable", + doc={"arr": [1, 2]}, + expression={"$setDifference": ["$$ROOT.arr", [2]]}, + expected=[1], + msg="Should resolve ROOT system variable for array field", + ), + ExpressionTestCase( + "current_variable", + doc={"arr": [1, 2]}, + expression={"$setDifference": ["$$CURRENT.arr", [2]]}, + expected=[1], + msg="Should resolve CURRENT system variable for array field", + ), + ExpressionTestCase( + "operand_array_of_field_refs", + doc={"x": 1, "y": 2}, + expression={"$setDifference": [["$x", "$y"], [2]]}, + expected=[1], + msg="Should resolve an operand array whose elements are field references", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SETDIFFERENCE_INPUT_SHAPE_TESTS)) +def test_setDifference_input_shapes(collection, test): + """Test $setDifference with nested, composite, numeric-index, and system-variable operands.""" + 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/set/setDifference/test_setDifference_invalid.py b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setDifference/test_setDifference_invalid.py new file mode 100644 index 000000000..778717bec --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setDifference/test_setDifference_invalid.py @@ -0,0 +1,316 @@ +"""Tests for $setDifference null propagation, argument validation, and non-array 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_with_insert, +) +from documentdb_tests.framework.error_codes import ( + EXPRESSION_TYPE_MISMATCH_ERROR, + SET_DIFFERENCE_FIRST_NOT_ARRAY_ERROR, + SET_DIFFERENCE_SECOND_NOT_ARRAY_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + FLOAT_INFINITY, + FLOAT_NAN, + MISSING, +) + +# Property [Null Propagation]: a null array argument makes the whole result null. +SETDIFFERENCE_NULL_PROP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_first", + doc={"arr1": None, "arr2": [1, 2]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=None, + msg="Should return null when first argument is null", + ), + ExpressionTestCase( + "null_second", + doc={"arr1": [1, 2], "arr2": None}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=None, + msg="Should return null when second argument is null", + ), + ExpressionTestCase( + "null_both", + doc={"arr1": None, "arr2": None}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=None, + msg="Should return null when both arguments are null", + ), +] + +# Property [Null Elements]: a null element inside an array is an ordinary value +# subject to set membership and dedup. +SETDIFFERENCE_NULL_ELEMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_elem_in_first", + doc={"arr1": [None, 1, 2], "arr2": [1]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[None, 2], + msg="Should preserve null element in result when not in second array", + ), + ExpressionTestCase( + "null_elem_removed", + doc={"arr1": [None, 1], "arr2": [None]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[1], + msg="Should remove null element when it appears in second array", + ), + ExpressionTestCase( + "null_elem_both", + doc={"arr1": [None, 1], "arr2": [None, 2]}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[1], + msg="Should remove null element when present in both arrays", + ), + ExpressionTestCase( + "null_elem_dedup", + doc={"arr1": [None, None, 1], "arr2": []}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[None, 1], + msg="Should deduplicate multiple null elements in first array", + ), +] + +# Property [Valid Element Types]: an array of any BSON element type is accepted +# and returned unchanged when the second array is empty. +SETDIFFERENCE_VALID_ELEMENT_TESTS: list[ExpressionTestCase] = [ + *[ + ExpressionTestCase( + f"{tid}_elements", + doc={"arr1": arr, "arr2": []}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=arr, + msg=f"Should handle an array of {tid} elements", + ) + for tid, arr in [ + ("string", ["x", "y", "z"]), + ("int32", [1, 2, 3]), + ("int64", [Int64(1), Int64(2)]), + ("double", [1.5, 2.5]), + ("decimal128", [Decimal128("1"), Decimal128("2")]), + ("boolean", [False, True]), + ("null", [None]), + ( + "date", + [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 1, 2, tzinfo=timezone.utc), + ], + ), + ( + "objectid", + [ObjectId("aaaaaaaaaaaaaaaaaaaaaaaa"), ObjectId("bbbbbbbbbbbbbbbbbbbbbbbb")], + ), + ("regex", [Regex("abc", ""), Regex("def", "")]), + ("timestamp", [Timestamp(1, 1), Timestamp(2, 1)]), + ("minkey", [MinKey()]), + ("maxkey", [MaxKey()]), + ("object", [{"a": 1}, {"b": 2}]), + ("nested_array", [[1, 2], [3, 4]]), + ] + ], + ExpressionTestCase( + "binary_elements", + doc={"arr1": [Binary(b"\x01\x02\x03", 0), Binary(b"\x04\x05\x06", 0)], "arr2": []}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[b"\x01\x02\x03", b"\x04\x05\x06"], + msg="Should handle an array of binary elements", + ), + ExpressionTestCase( + "mixed_types", + doc={"arr1": [1, "a", True, None], "arr2": []}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + expected=[1, "a", True, None], + msg="Should handle an array of mixed BSON type elements", + ), +] + +# Property [Null Precedence]: a null operand propagates null before a non-array +# operand in the other position is validated. +SETDIFFERENCE_NULL_PRECEDENCE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_first_non_array_second", + expression={"$setDifference": [None, 1]}, + expected=None, + msg="Null first arg should propagate null, not error on non-array second", + ), +] + +# Property [Missing Field]: a field path to an absent field resolves to null and +# propagates null through the operator. +SETDIFFERENCE_MISSING_FIELD_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_field_first", + doc={"a": [1, 2]}, + expression={"$setDifference": [MISSING, "$a"]}, + expected=None, + msg="Should return null when first field is missing", + ), + ExpressionTestCase( + "missing_field_second", + doc={"a": [1, 2]}, + expression={"$setDifference": ["$a", MISSING]}, + expected=None, + msg="Should return null when second field is missing", + ), + ExpressionTestCase( + "missing_field_both", + doc={"z": 1}, + expression={"$setDifference": ["$x", "$y"]}, + expected=None, + msg="Should return null when both fields are missing", + ), +] + +SETDIFFERENCE_SUCCESS_TESTS: list[ExpressionTestCase] = ( + SETDIFFERENCE_NULL_PROP_TESTS + + SETDIFFERENCE_NULL_ELEMENT_TESTS + + SETDIFFERENCE_VALID_ELEMENT_TESTS + + SETDIFFERENCE_NULL_PRECEDENCE_TESTS + + SETDIFFERENCE_MISSING_FIELD_TESTS +) + +# Property [Non-Array First]: a non-array first argument errors, and every +# non-array BSON type is rejected. +SETDIFFERENCE_NON_ARRAY_FIRST_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"{tid}_first", + doc={"arr1": val, "arr2": []}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + error_code=SET_DIFFERENCE_FIRST_NOT_ARRAY_ERROR, + msg=f"Should return error for {tid} as first argument", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("string", "hello"), + ("bool", True), + ("object", {"a": 1}), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("aaaaaaaaaaaaaaaaaaaaaaaa")), + ("regex", Regex("abc", "")), + ("binary", Binary(b"\x01", 0)), + ("timestamp", Timestamp(1, 1)), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("nan", FLOAT_NAN), + ("infinity", FLOAT_INFINITY), + ("decimal_nan", DECIMAL128_NAN), + ("decimal_infinity", DECIMAL128_INFINITY), + ] +] + +# Property [Non-Array Second]: a non-array second argument errors, and every +# non-array BSON type is rejected. +SETDIFFERENCE_NON_ARRAY_SECOND_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"{tid}_second", + doc={"arr1": ["a"], "arr2": val}, + expression={"$setDifference": ["$arr1", "$arr2"]}, + error_code=SET_DIFFERENCE_SECOND_NOT_ARRAY_ERROR, + msg=f"Should return error for {tid} as second argument", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("string", "hello"), + ("bool", True), + ("object", {"a": 1}), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("aaaaaaaaaaaaaaaaaaaaaaaa")), + ("regex", Regex("abc", "")), + ("binary", Binary(b"\x01", 0)), + ("timestamp", Timestamp(1, 1)), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("nan", FLOAT_NAN), + ("infinity", FLOAT_INFINITY), + ("decimal_nan", DECIMAL128_NAN), + ("decimal_infinity", DECIMAL128_INFINITY), + ] +] + +# Property [Arity]: $setDifference requires exactly two arguments. +SETDIFFERENCE_ARITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arity_zero", + expression={"$setDifference": []}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="Should return error for zero arguments", + ), + ExpressionTestCase( + "arity_one", + expression={"$setDifference": [[1]]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="Should return error for one argument", + ), + ExpressionTestCase( + "arity_three", + expression={"$setDifference": [[1], [2], [3]]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="Should return error for three arguments", + ), +] + +# Property [Field Resolves To Non-Array]: a field path that resolves to a +# non-array value errors on the position it occupies. +SETDIFFERENCE_FIELD_NON_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "field_resolves_int_first", + doc={"a": 5}, + expression={"$setDifference": ["$a", [1]]}, + error_code=SET_DIFFERENCE_FIRST_NOT_ARRAY_ERROR, + msg="Field resolving to int should error on first position", + ), + ExpressionTestCase( + "field_resolves_string_first", + doc={"a": "hello"}, + expression={"$setDifference": ["$a", [1]]}, + error_code=SET_DIFFERENCE_FIRST_NOT_ARRAY_ERROR, + msg="Field resolving to string should error on first position", + ), + ExpressionTestCase( + "field_resolves_int_second", + doc={"a": 5}, + expression={"$setDifference": [[1], "$a"]}, + error_code=SET_DIFFERENCE_SECOND_NOT_ARRAY_ERROR, + msg="Field resolving to int should error on second position", + ), +] + +SETDIFFERENCE_ERROR_TESTS: list[ExpressionTestCase] = ( + SETDIFFERENCE_NON_ARRAY_FIRST_TESTS + + SETDIFFERENCE_NON_ARRAY_SECOND_TESTS + + SETDIFFERENCE_ARITY_TESTS + + SETDIFFERENCE_FIELD_NON_ARRAY_TESTS +) + +SETDIFFERENCE_INVALID_TESTS: list[ExpressionTestCase] = ( + SETDIFFERENCE_SUCCESS_TESTS + SETDIFFERENCE_ERROR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(SETDIFFERENCE_INVALID_TESTS)) +def test_setDifference_invalid(collection, test): + """Test $setDifference null, missing, arity, and non-array handling.""" + 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/set/setEquals/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setEquals/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/set/setEquals/test_setEquals_core.py b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setEquals/test_setEquals_core.py new file mode 100644 index 000000000..230e69325 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setEquals/test_setEquals_core.py @@ -0,0 +1,525 @@ +"""Tests for $setEquals core set-equality behavior: dedup, ordering, nesting, and arity.""" + +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 + +# Property [Basic Equality]: two operands are equal when they hold the same set +# of distinct elements, independent of order or repetition. +SETEQUALS_BASIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "identical_ints", + doc={"a": [1, 2, 3], "b": [1, 2, 3]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true for identical arrays", + ), + ExpressionTestCase( + "different_elements", + doc={"a": [1, 2, 3], "b": [1, 2, 4]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for different elements", + ), + ExpressionTestCase( + "partial_overlap", + doc={"a": [1, 2, 3], "b": [2, 3, 4]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for partial overlap", + ), + ExpressionTestCase( + "disjoint", + doc={"a": [1, 2, 3], "b": [4, 5, 6]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for disjoint arrays", + ), + ExpressionTestCase( + "first_superset", + doc={"a": [1, 2, 3], "b": [2, 3]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false when a superset is compared to a subset", + ), + ExpressionTestCase( + "first_subset", + doc={"a": [2, 3], "b": [2, 3, 4]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false when a subset is compared to a superset", + ), + ExpressionTestCase( + "strings_equal", + doc={"a": ["red", "blue"], "b": ["blue", "red"]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true for string arrays with the same elements", + ), + ExpressionTestCase( + "strings_not_equal", + doc={"a": ["red", "blue"], "b": ["green", "red"]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for different string elements", + ), + ExpressionTestCase( + "strings_extra_in_second", + doc={"a": ["red", "blue"], "b": ["red", "blue", "green"]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for an extra element in the second array", + ), + ExpressionTestCase( + "strings_extra_in_first", + doc={"a": ["red", "blue", "green"], "b": ["blue", "red"]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for an extra element in the first array", + ), + ExpressionTestCase( + "second_empty", + doc={"a": ["red", "blue"], "b": []}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for a non-empty array versus an empty array", + ), + ExpressionTestCase( + "mixed_types_equal", + doc={"a": [1, "a", True], "b": ["a", True, 1]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true for mixed types with the same elements", + ), + ExpressionTestCase( + "mixed_types_not_equal", + doc={"a": [1, "a", True], "b": ["a", True, 2]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for mixed types with different elements", + ), +] + +# Property [Empty Operands]: empty arrays are equal only to other empty arrays. +SETEQUALS_EMPTY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "both_empty", + doc={"a": [], "b": []}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true for two empty arrays", + ), + ExpressionTestCase( + "first_empty", + doc={"a": [], "b": [1]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for an empty array versus a non-empty array", + ), + ExpressionTestCase( + "second_empty_int", + doc={"a": [1], "b": []}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for a non-empty array versus an empty array", + ), + ExpressionTestCase( + "three_all_empty", + doc={"a": [], "b": [], "c": []}, + expression={"$setEquals": ["$a", "$b", "$c"]}, + expected=True, + msg="$setEquals should return true for three empty arrays", + ), +] + +# Property [Duplicate Handling]: set semantics ignore repeated elements within +# each operand. +SETEQUALS_DUPLICATE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dupes_in_second", + doc={"a": ["a", "b"], "b": ["a", "b", "a"]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true when duplicates in the second array are ignored", + ), + ExpressionTestCase( + "dupes_in_first", + doc={"a": ["a", "a", "a"], "b": ["a"]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true when duplicates in the first array are ignored", + ), + ExpressionTestCase( + "heavy_dupes_both", + doc={"a": [1, 1, 2, 2, 3, 3], "b": [3, 2, 1]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true when heavy duplicates are ignored", + ), + ExpressionTestCase( + "same_dupes_both", + doc={"a": [1, 1, 1], "b": [1, 1, 1]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true for the same duplicates in both arrays", + ), + ExpressionTestCase( + "different_dupe_counts", + doc={"a": [1, 2, 2, 3], "b": [1, 2, 3, 3]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true for different duplicate counts of the same set", + ), + ExpressionTestCase( + "string_heavy_dupes", + doc={"a": ["red", "red", "red"], "b": ["red"]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true when string duplicates are ignored", + ), + ExpressionTestCase( + "string_dupes_three_args", + doc={ + "a": ["red", "blue", "blue"], + "b": ["red", "red", "red", "blue", "blue"], + "c": ["red", "blue", "red", "blue"], + }, + expression={"$setEquals": ["$a", "$b", "$c"]}, + expected=True, + msg="$setEquals should return true when string duplicates across three arrays are ignored", + ), + ExpressionTestCase( + "dupes_not_equal", + doc={ + "a": ["red", "red", "red", "blue", "blue"], + "b": ["red", "red"], + "c": ["blue", "blue"], + }, + expression={"$setEquals": ["$a", "$b", "$c"]}, + expected=False, + msg="$setEquals should return false for different distinct elements despite duplicates", + ), + ExpressionTestCase( + "field_dupes_equal", + doc={"a": [1, 2, 2, 2, 2], "b": [1, 2, 1, 1, 1]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true when duplicates reduce to the same distinct elements", + ), + ExpressionTestCase( + "field_paths_with_dupes", + doc={"a": [1, 2, 3], "b": [1, 1, 2, 2, 3, 3]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true when duplicates in one operand reduce to the other set", + ), +] + +# Property [Order Independence]: the result does not depend on element order +# within an operand. +SETEQUALS_ORDER_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int_order", + doc={"a": [1, 2, 3], "b": [3, 2, 1]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true regardless of integer order", + ), + ExpressionTestCase( + "string_order", + doc={"a": ["c", "b", "a"], "b": ["a", "b", "c"]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true regardless of string order", + ), + ExpressionTestCase( + "mixed_order", + doc={"a": [True, 1, "a"], "b": ["a", True, 1]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true regardless of mixed-type order", + ), +] + +# Property [Atomic Nesting]: nested array elements are compared as whole values +# and are not descended into. +SETEQUALS_NESTED_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "same_nested", + doc={"a": [[1, 2]], "b": [[1, 2]]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true for the same nested array element", + ), + ExpressionTestCase( + "nested_inner_order_differs", + doc={"a": [[1, 2]], "b": [[2, 1]]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for different inner order in an atomic comparison", + ), + ExpressionTestCase( + "nested_set_of_arrays", + doc={"a": [[1], [2]], "b": [[2], [1]]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true for the same set of nested arrays", + ), + ExpressionTestCase( + "nested_mixed_scalar", + doc={"a": [[1, 2], 3], "b": [3, [1, 2]]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true for mixed nested and scalar elements", + ), + ExpressionTestCase( + "nested_vs_flat", + doc={"a": [[1, 2]], "b": [1, 2]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for a nested array versus flat elements", + ), + ExpressionTestCase( + "nested_reorder", + doc={"a": [1, [2, 3]], "b": [[2, 3], 1]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true for reordered nested and scalar elements", + ), + ExpressionTestCase( + "nested_strings_same", + doc={"a": [["red", "blue"]], "b": [["red", "blue"]]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true for the same nested string array", + ), + ExpressionTestCase( + "nested_strings_inner_order", + doc={"a": [["red", "blue"]], "b": [["blue", "red"]]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for different inner string order", + ), + ExpressionTestCase( + "nested_vs_flat_strings", + doc={"a": ["red", "blue"], "b": [["red"], ["blue"]]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for flat strings versus nested arrays", + ), + ExpressionTestCase( + "dup_nested", + doc={"a": [[1, 2], [1, 2]], "b": [[1, 2]]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true when duplicate nested arrays are deduplicated", + ), + ExpressionTestCase( + "dup_nested_multi", + doc={"a": [[1, 2], [3, 4]], "b": [[3, 4], [1, 2], [1, 2]]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should deduplicate duplicate nested arrays in the second array", + ), + ExpressionTestCase( + "deeply_nested_same", + doc={"a": [[[1]]], "b": [[[1]]]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true for deeply nested same elements", + ), + ExpressionTestCase( + "deeply_nested_diff", + doc={"a": [[[1]]], "b": [[[2]]]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for deeply nested different elements", + ), + ExpressionTestCase( + "very_deeply_nested", + doc={"a": [[[[1]]]], "b": [[[[1]]]]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true for very deeply nested same elements", + ), + ExpressionTestCase( + "different_nesting_depth", + doc={"a": [["red", "blue"]], "b": [[["red", "blue"]]]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for different nesting depth", + ), +] + +# Property [Single-Element Arrays]: single-element operands compare by their one +# element, including special scalar values. +SETEQUALS_SINGLE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "single_equal", + doc={"a": [1], "b": [1]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true for single equal elements", + ), + ExpressionTestCase( + "single_not_equal", + doc={"a": [1], "b": [2]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for single different elements", + ), + ExpressionTestCase( + "single_null", + doc={"a": [None], "b": [None]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true for single null elements", + ), + ExpressionTestCase( + "single_false", + doc={"a": [False], "b": [False]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true for single false elements", + ), + ExpressionTestCase( + "single_empty_string", + doc={"a": [""], "b": [""]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true for single empty-string elements", + ), +] + +# Property [Multiple Arguments]: equality generalizes to three or more operands, +# holding only when every operand shares the same set. +SETEQUALS_MULTI_ARG_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "three_equal", + doc={"a": [1, 2], "b": [2, 1], "c": [1, 2]}, + expression={"$setEquals": ["$a", "$b", "$c"]}, + expected=True, + msg="$setEquals should return true for three equal arrays", + ), + ExpressionTestCase( + "three_one_differs", + doc={"a": [1, 2], "b": [2, 1], "c": [1, 3]}, + expression={"$setEquals": ["$a", "$b", "$c"]}, + expected=False, + msg="$setEquals should return false for three arrays when one differs", + ), + ExpressionTestCase( + "five_equal", + doc={"a": [1], "b": [1], "c": [1], "d": [1], "e": [1]}, + expression={"$setEquals": ["$a", "$b", "$c", "$d", "$e"]}, + expected=True, + msg="$setEquals should return true for five equal arrays", + ), + ExpressionTestCase( + "five_last_differs", + doc={"a": [1], "b": [1], "c": [1], "d": [1], "e": [2]}, + expression={"$setEquals": ["$a", "$b", "$c", "$d", "$e"]}, + expected=False, + msg="$setEquals should return false for five arrays when the last differs", + ), + ExpressionTestCase( + "three_with_dupes", + doc={"a": [1, 2], "b": [2, 1], "c": [1, 2, 1]}, + expression={"$setEquals": ["$a", "$b", "$c"]}, + expected=True, + msg="$setEquals should return true for three arrays with duplicates", + ), + ExpressionTestCase( + "three_second_differs", + doc={"a": [1, 2], "b": [2, 3], "c": [1, 2]}, + expression={"$setEquals": ["$a", "$b", "$c"]}, + expected=False, + msg="$setEquals should return false for three arrays when the second differs", + ), + ExpressionTestCase( + "three_empty_one_not", + doc={"a": [], "b": [], "c": [1]}, + expression={"$setEquals": ["$a", "$b", "$c"]}, + expected=False, + msg="$setEquals should return false for two empty arrays and one non-empty array", + ), + ExpressionTestCase( + "four_all_reduce_same", + doc={"a": [1, 2], "b": [2, 1], "c": [1, 1, 2], "d": [1, 2, 2]}, + expression={"$setEquals": ["$a", "$b", "$c", "$d"]}, + expected=True, + msg="$setEquals should return true for four arrays all reducing to the same set", + ), + ExpressionTestCase( + "four_args_not_equal", + doc={"a": [1, 2], "b": [1, 2, 3, 4], "c": [5, 6], "d": [1, 8]}, + expression={"$setEquals": ["$a", "$b", "$c", "$d"]}, + expected=False, + msg="$setEquals should return false for four different arrays", + ), +] + +# Property [Large Arrays]: equality scales to large operands and still applies +# set semantics. +SETEQUALS_LARGE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "large_identical", + doc={"a": list(range(1000)), "b": list(range(999, -1, -1))}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true for large identical arrays in different order", + ), + ExpressionTestCase( + "large_last_differs", + doc={"a": list(range(1000)), "b": list(range(999)) + [9999]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for large arrays with one different element", + ), + ExpressionTestCase( + "large_heavy_dupes", + doc={"a": [1] * 10_000, "b": [1]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true for 10000 duplicates equal to a single element", + ), + ExpressionTestCase( + "scale_10k", + doc={"a": list(range(10_000)), "b": list(range(9999, -1, -1))}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true for 10K elements reversed", + ), + ExpressionTestCase( + "large_variadic", + doc={"a": [1, 2], "b": [2, 1], "c": [1, 2], "d": [2, 1], "e": [1, 2]}, + expression={"$setEquals": ["$a", "$b", "$c", "$d", "$e"]}, + expected=True, + msg="$setEquals should return true for many variadic equal arrays", + ), +] + +SETEQUALS_CORE_TESTS: list[ExpressionTestCase] = ( + SETEQUALS_BASIC_TESTS + + SETEQUALS_EMPTY_TESTS + + SETEQUALS_DUPLICATE_TESTS + + SETEQUALS_ORDER_TESTS + + SETEQUALS_NESTED_TESTS + + SETEQUALS_SINGLE_TESTS + + SETEQUALS_MULTI_ARG_TESTS + + SETEQUALS_LARGE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(SETEQUALS_CORE_TESTS)) +def test_setEquals_core(collection, test): + """Test $setEquals core set-equality behavior with field-reference array operands.""" + 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/set/setEquals/test_setEquals_data_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setEquals/test_setEquals_data_types.py new file mode 100644 index 000000000..75d2070c1 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setEquals/test_setEquals_data_types.py @@ -0,0 +1,612 @@ +"""Tests for $setEquals element type handling, numeric equivalence, and special values.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, 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_TRAILING_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT64_ZERO, +) + +# Property [Element Type Coverage]: arrays of any single BSON element type +# compare equal when they hold the same elements. +SETEQUALS_ELEMENT_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "integers", + doc={"a": [4, 5, 6], "b": [6, 5, 4]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should compare integer arrays", + ), + ExpressionTestCase( + "longs", + doc={"a": [Int64(1), Int64(2)], "b": [Int64(2), Int64(1)]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should compare long arrays", + ), + ExpressionTestCase( + "doubles", + doc={"a": [1.5, 2.5], "b": [2.5, 1.5]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should compare double arrays", + ), + ExpressionTestCase( + "decimal128", + doc={"a": [Decimal128("1"), Decimal128("2")], "b": [Decimal128("2"), Decimal128("1")]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should compare decimal128 arrays", + ), + ExpressionTestCase( + "strings", + doc={"a": ["a", "b"], "b": ["b", "a"]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should compare string arrays", + ), + ExpressionTestCase( + "booleans", + doc={"a": [True, False], "b": [False, True]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should compare boolean arrays", + ), + ExpressionTestCase( + "dates", + doc={ + "a": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 1, 2, tzinfo=timezone.utc), + ], + "b": [ + datetime(2024, 1, 2, tzinfo=timezone.utc), + datetime(2024, 1, 1, tzinfo=timezone.utc), + ], + }, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should compare date arrays", + ), + ExpressionTestCase( + "null_elem", + doc={"a": [None, 7], "b": [7, None]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should compare null-element arrays", + ), + ExpressionTestCase( + "objectid", + doc={ + "a": [ObjectId("507f1f77bcf86cd799439011")], + "b": [ObjectId("507f1f77bcf86cd799439011")], + }, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should compare ObjectId arrays", + ), + ExpressionTestCase( + "objects", + doc={"a": [{"a": 1}], "b": [{"a": 1}]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should compare object arrays", + ), + ExpressionTestCase( + "nested_arrays", + doc={"a": [["x", "y"]], "b": [["x", "y"]]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should compare nested-array arrays", + ), + ExpressionTestCase( + "regex", + doc={"a": [Regex("abc", "")], "b": [Regex("abc", "")]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should compare regex arrays", + ), + ExpressionTestCase( + "javascript", + doc={"a": [Code("function(){}")], "b": [Code("function(){}")]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should compare javascript code arrays", + ), + ExpressionTestCase( + "binary", + doc={"a": [Binary(b"\x01", 0)], "b": [Binary(b"\x01", 0)]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should compare binary arrays", + ), + ExpressionTestCase( + "timestamp", + doc={"a": [Timestamp(1, 1)], "b": [Timestamp(1, 1)]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should compare timestamp arrays", + ), + ExpressionTestCase( + "minkey", + doc={"a": [MinKey()], "b": [MinKey()]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should compare MinKey arrays", + ), + ExpressionTestCase( + "maxkey", + doc={"a": [MaxKey()], "b": [MaxKey()]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should compare MaxKey arrays", + ), +] + +# Property [Numeric Equivalence]: numeric values equal across int, long, double, +# and decimal128 are treated as the same element. +SETEQUALS_NUMERIC_EQUIV_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int_long", + doc={"a": [1], "b": [Int64(1)]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should treat int and long as equivalent", + ), + ExpressionTestCase( + "int_double", + doc={"a": [1], "b": [1.0]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should treat int and double as equivalent", + ), + ExpressionTestCase( + "int_decimal", + doc={"a": [1], "b": [Decimal128("1")]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should treat int and decimal128 as equivalent", + ), + ExpressionTestCase( + "long_double", + doc={"a": [Int64(1)], "b": [1.0]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should treat long and double as equivalent", + ), + ExpressionTestCase( + "long_decimal", + doc={"a": [Int64(1)], "b": [Decimal128("1")]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should treat long and decimal128 as equivalent", + ), + ExpressionTestCase( + "double_decimal", + doc={"a": [1.0], "b": [Decimal128("1")]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should treat double and decimal128 as equivalent", + ), + ExpressionTestCase( + "zero_int_long", + doc={"a": [0], "b": [INT64_ZERO]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should treat zero int and long as equivalent", + ), + ExpressionTestCase( + "zero_int_double", + doc={"a": [0], "b": [DOUBLE_ZERO]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should treat zero int and double as equivalent", + ), + ExpressionTestCase( + "zero_int_decimal", + doc={"a": [0], "b": [DECIMAL128_ZERO]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should treat zero int and decimal128 as equivalent", + ), + ExpressionTestCase( + "dedup_cross_type", + doc={"a": [1, 1, 1], "b": [Int64(1)]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should deduplicate with cross-type equivalence", + ), + ExpressionTestCase( + "all_ones", + doc={"a": [1, Int64(1), 1.0], "b": [Decimal128("1")]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should treat all numeric ones as equivalent", + ), + ExpressionTestCase( + "mixed_numeric", + doc={"a": [1, Int64(2), 3.0], "b": [Decimal128("3"), 2, Int64(1)]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should treat mixed numeric types as equivalent", + ), + ExpressionTestCase( + "all_ones_dedup", + doc={"a": [1, Int64(1), 1.0, Decimal128("1")], "b": [1]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should collapse all numeric ones to a single element", + ), + ExpressionTestCase( + "decimal128_equiv", + doc={ + "a": [Decimal128("1e0"), Decimal128("2")], + "b": [Decimal128("1.00"), Decimal128("200E-2")], + }, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should treat equivalent decimal128 representations as equal", + ), +] + +# Property [Negative Zero]: negative zero equals positive zero, including across +# the double and decimal128 types. +SETEQUALS_NEG_ZERO_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "double_neg_zero", + doc={"a": [DOUBLE_ZERO], "b": [DOUBLE_NEGATIVE_ZERO]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should treat double zero and negative zero as equivalent", + ), + ExpressionTestCase( + "decimal_neg_zero", + doc={"a": [DECIMAL128_ZERO], "b": [DECIMAL128_NEGATIVE_ZERO]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should treat decimal128 zero and negative zero as equivalent", + ), + ExpressionTestCase( + "neg_zero_with_other", + doc={"a": [DOUBLE_NEGATIVE_ZERO, 1], "b": [DOUBLE_ZERO, 1]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should treat negative zero as equivalent alongside other elements", + ), +] + +# Property [BSON Type Distinction]: values of different BSON types never match, +# even when they appear equivalent in some languages. +SETEQUALS_BSON_DISTINCTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "false_vs_zero", + doc={"a": [False], "b": [0]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for false versus zero as different BSON types", + ), + ExpressionTestCase( + "true_vs_one", + doc={"a": [True], "b": [1]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for true versus one as different BSON types", + ), + ExpressionTestCase( + "false_zero_both", + doc={"a": [False, 0], "b": [0, False]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true when both operands contain false and zero", + ), + ExpressionTestCase( + "empty_str_vs_null", + doc={"a": [""], "b": [None]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for empty string versus null", + ), + ExpressionTestCase( + "bool_vs_numeric_mixed", + doc={"a": [True, False], "b": [1, 0]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for booleans versus numerics", + ), + ExpressionTestCase( + "obj_vs_array", + doc={"a": [{}], "b": [[]]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for an empty object versus an empty array", + ), +] + +# Property [NaN Handling]: NaN matches NaN in set context, including across the +# double and decimal128 types. +SETEQUALS_NAN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nan_both", + doc={"a": [FLOAT_NAN], "b": [FLOAT_NAN]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should treat NaN as equal to NaN in set context", + ), + ExpressionTestCase( + "nan_with_int", + doc={"a": [FLOAT_NAN, 1], "b": [1, FLOAT_NAN]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should treat NaN as equal alongside an int element", + ), + ExpressionTestCase( + "nan_cross_type", + doc={"a": [DECIMAL128_NAN], "b": [FLOAT_NAN]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should match NaN across decimal128 and double", + ), + ExpressionTestCase( + "nan_dedup", + doc={"a": [FLOAT_NAN, FLOAT_NAN, 1], "b": [FLOAT_NAN, 1]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should deduplicate NaN elements", + ), +] + +# Property [Infinity Handling]: infinities match, including across the double and +# decimal128 types. +SETEQUALS_INFINITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "inf_both", + doc={"a": [FLOAT_INFINITY], "b": [FLOAT_INFINITY]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should treat positive infinity as equal to positive infinity", + ), + ExpressionTestCase( + "neg_inf_both", + doc={"a": [FLOAT_NEGATIVE_INFINITY], "b": [FLOAT_NEGATIVE_INFINITY]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should treat negative infinity as equal to negative infinity", + ), + ExpressionTestCase( + "inf_vs_neg_inf", + doc={"a": [FLOAT_INFINITY], "b": [FLOAT_NEGATIVE_INFINITY]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for positive infinity versus negative infinity", + ), + ExpressionTestCase( + "inf_cross_type", + doc={"a": [DECIMAL128_INFINITY], "b": [FLOAT_INFINITY]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should match infinity across decimal128 and double", + ), + ExpressionTestCase( + "neg_inf_cross_type", + doc={"a": [DECIMAL128_NEGATIVE_INFINITY], "b": [FLOAT_NEGATIVE_INFINITY]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should match negative infinity across decimal128 and double", + ), +] + +# Property [Object Elements]: objects match only when their keys, values, and key +# order are identical. +SETEQUALS_OBJECT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "same_obj", + doc={"a": [{"a": 1, "b": 2}], "b": [{"a": 1, "b": 2}]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true for the same object", + ), + ExpressionTestCase( + "diff_key_order", + doc={"a": [{"a": 1, "b": 2}], "b": [{"b": 2, "a": 1}]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for different key order", + ), + ExpressionTestCase( + "diff_values", + doc={"a": [{"a": 1}], "b": [{"a": 2}]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for different object values", + ), + ExpressionTestCase( + "extra_keys", + doc={"a": [{"a": 1}], "b": [{"a": 1, "b": 2}]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for extra object keys", + ), + ExpressionTestCase( + "nested_obj", + doc={"a": [{"a": {"b": 1}}], "b": [{"a": {"b": 1}}]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true for nested objects", + ), + ExpressionTestCase( + "empty_obj", + doc={"a": [{}], "b": [{}]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true for empty objects", + ), + ExpressionTestCase( + "empty_vs_nonempty", + doc={"a": [{}], "b": [{"a": 1}]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for an empty versus a non-empty object", + ), +] + +# Property [String Elements]: string comparison is exact, case-sensitive, and +# unicode-aware. +SETEQUALS_STRING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "unicode", + doc={"a": ["café", "naïve"], "b": ["naïve", "café"]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should compare unicode strings", + ), + ExpressionTestCase( + "empty_str", + doc={"a": ["", 9], "b": [9, ""]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should compare empty strings", + ), + ExpressionTestCase( + "case_sensitive", + doc={"a": ["abc"], "b": ["ABC"]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should treat strings as case-sensitive", + ), +] + +# Property [Mixed Type Arrays]: equality over arrays holding several BSON types +# holds only when every distinct element matches. +SETEQUALS_MIXED_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "mixed_equal", + doc={"a": [2, "x", False], "b": ["x", False, 2]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true for mixed types with the same elements", + ), + ExpressionTestCase( + "mixed_not_equal", + doc={"a": [2, "x", False], "b": ["x", False, 3]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for mixed types with a different element", + ), + ExpressionTestCase( + "null_int_str", + doc={"a": [None, 1, "a"], "b": [1, "a", None]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true for null with int and string elements", + ), + ExpressionTestCase( + "mixed_str_none_bool_int", + doc={"a": ["string", None, True, 1], "b": ["string", None, True, 1]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true for identical mixed-type arrays", + ), + ExpressionTestCase( + "obj_vs_array_in_mixed", + doc={"a": ["string", None, True, 1, {}], "b": ["string", None, True, 1, []]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for an object versus an array within mixed types", + ), +] + +# Property [Special Values Combined]: null, NaN, infinity, MinKey, and MaxKey +# match as elements regardless of order. +SETEQUALS_SPECIAL_COMBINED_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "special_all", + doc={ + "a": [None, FLOAT_NAN, FLOAT_INFINITY, MinKey(), MaxKey()], + "b": [MaxKey(), None, FLOAT_INFINITY, FLOAT_NAN, MinKey()], + }, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true for the same set of special values", + ), +] + +# Property [Decimal128 Representation]: decimal128 values equal in magnitude match +# regardless of trailing zeros or exponent notation. +SETEQUALS_DECIMAL128_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal_trailing_zeros", + doc={"a": [DECIMAL128_TRAILING_ZERO], "b": [Decimal128("1.00")]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should match decimal128 values differing only by trailing zeros", + ), + ExpressionTestCase( + "decimal_exponent_notation", + doc={"a": [Decimal128("1E+2")], "b": [Decimal128("100")]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should match decimal128 values in exponent notation", + ), + ExpressionTestCase( + "decimal_high_precision_match", + doc={ + "a": [Decimal128("1.000000000000000000000000000000001")], + "b": [Decimal128("1.000000000000000000000000000000001")], + }, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should match high-precision decimal128 values", + ), + ExpressionTestCase( + "decimal_high_precision_mismatch", + doc={ + "a": [Decimal128("1.000000000000000000000000000000001")], + "b": [Decimal128("1.000000000000000000000000000000002")], + }, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for a high-precision decimal128 mismatch", + ), +] + +SETEQUALS_TYPE_TESTS: list[ExpressionTestCase] = ( + SETEQUALS_ELEMENT_TYPE_TESTS + + SETEQUALS_NUMERIC_EQUIV_TESTS + + SETEQUALS_NEG_ZERO_TESTS + + SETEQUALS_BSON_DISTINCTION_TESTS + + SETEQUALS_NAN_TESTS + + SETEQUALS_INFINITY_TESTS + + SETEQUALS_OBJECT_TESTS + + SETEQUALS_STRING_TESTS + + SETEQUALS_MIXED_TYPE_TESTS + + SETEQUALS_SPECIAL_COMBINED_TESTS + + SETEQUALS_DECIMAL128_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(SETEQUALS_TYPE_TESTS)) +def test_setEquals_types(collection, test): + """Test $setEquals element type handling, numeric equivalence, and special values.""" + 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/set/setEquals/test_setEquals_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setEquals/test_setEquals_expressions.py new file mode 100644 index 000000000..d6e8397ef --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setEquals/test_setEquals_expressions.py @@ -0,0 +1,117 @@ +"""Tests for $setEquals operand input shapes, inline and resolved via paths and variables.""" + +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 [Inline Operand Input]: literal arrays and expression-operator operands +# evaluate inline without a field reference. +SETEQUALS_INLINE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal", + expression={"$setEquals": [[1, 2], [2, 1]]}, + expected=True, + msg="$setEquals should compare literal array inputs", + ), + ExpressionTestCase( + "expression_operator", + expression={"$setEquals": [{"$literal": [1, 2, 3]}, {"$literal": [3, 2, 1]}]}, + expected=True, + msg="$setEquals should accept an expression-operator result as an operand", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SETEQUALS_INLINE_TESTS)) +def test_setEquals_expression_inline(collection, test): + """Test $setEquals with inline literal and expression-operator operands.""" + result = execute_expression(collection, test.expression) + assert_expression_result(result, expected=test.expected, msg=test.msg) + + +# Property [Resolved Input Shapes]: operands reached through a shorthand field +# reference, a nested-object path, an array-of-objects path, a numeric-index path, +# or a system variable resolve and feed the operator. +SETEQUALS_INPUT_SHAPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "field_ref_equal", + doc={"a": [1, 2], "b": [2, 1]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should resolve shorthand field references for both operands", + ), + ExpressionTestCase( + "field_ref_not_equal", + doc={"a": [1, 2], "b": [3, 4]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for unequal shorthand field references", + ), + ExpressionTestCase( + "nested_object_field", + doc={"x": {"a": [1, 2]}, "y": {"b": [2, 1]}}, + expression={"$setEquals": ["$x.a", "$y.b"]}, + expected=True, + msg="$setEquals should resolve nested object field paths for both operands", + ), + ExpressionTestCase( + "composite_array_field", + doc={"a": [{"b": 1}, {"b": 2}]}, + expression={"$setEquals": ["$a.b", [1, 2]]}, + expected=True, + msg="$setEquals should resolve a composite array field path through an array of objects", + ), + # In an aggregation field path a numeric component is a field name, not an + # array index, so it resolves to an empty array over the array of arrays, + # which is not equal to the literal operand. + ExpressionTestCase( + "array_index_path", + doc={"a": [[1, 2], [3]]}, + expression={"$setEquals": ["$a.0", [1, 2]]}, + expected=False, + msg="$setEquals should resolve a numeric path component to an empty array, yielding false", + ), + ExpressionTestCase( + "deeply_nested_field", + doc={"a": {"b": {"c": [1, 2]}}, "d": {"e": {"f": [2, 1]}}}, + expression={"$setEquals": ["$a.b.c", "$d.e.f"]}, + expected=True, + msg="$setEquals should resolve deeply nested field paths for both operands", + ), + ExpressionTestCase( + "root_variable", + doc={"arr": [1, 2]}, + expression={"$setEquals": ["$$ROOT.arr", [2, 1]]}, + expected=True, + msg="$setEquals should resolve the ROOT system variable for an array field", + ), + ExpressionTestCase( + "current_variable", + doc={"arr": [1, 2]}, + expression={"$setEquals": ["$$CURRENT.arr", [2, 1]]}, + expected=True, + msg="$setEquals should resolve the CURRENT system variable for an array field", + ), + ExpressionTestCase( + "operand_array_of_field_refs", + doc={"x": 1, "y": 2}, + expression={"$setEquals": [["$x", "$y"], [1, 2]]}, + expected=True, + msg="$setEquals should resolve an operand array whose elements are field references", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SETEQUALS_INPUT_SHAPE_TESTS)) +def test_setEquals_input_shapes(collection, test): + """Test $setEquals with field-reference, nested, composite, and variable operands.""" + 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/set/setEquals/test_setEquals_invalid.py b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setEquals/test_setEquals_invalid.py new file mode 100644 index 000000000..e71f676cf --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setEquals/test_setEquals_invalid.py @@ -0,0 +1,309 @@ +"""Tests for $setEquals null handling, argument-count errors, and non-array operand errors.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, 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 ( + SET_EQUALS_LITERAL_NON_ARRAY_ERROR, + SET_EQUALS_RUNTIME_NON_ARRAY_ERROR, + SET_EQUALS_WRONG_ARG_COUNT_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + FLOAT_INFINITY, + FLOAT_NAN, + MISSING, +) + +# Property [Null Elements]: a null element inside an array is an ordinary value +# subject to set membership, so the operator succeeds. +SETEQUALS_NULL_ELEMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_elem_both", + doc={"a": [None, None], "b": [None]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true for null elements in both arrays", + ), + ExpressionTestCase( + "null_elem_mixed", + doc={"a": [None, 1], "b": [1, None]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=True, + msg="$setEquals should return true for a null element alongside other elements", + ), + ExpressionTestCase( + "null_elem_vs_int", + doc={"a": [None], "b": [1]}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for a null element versus an int element", + ), + ExpressionTestCase( + "null_elem_vs_empty", + doc={"a": [None], "b": []}, + expression={"$setEquals": ["$a", "$b"]}, + expected=False, + msg="$setEquals should return false for a null element versus an empty array", + ), +] + +# Property [Argument Count]: $setEquals requires two or more array operands, so a +# non-array operand list or fewer than two operands errors. +SETEQUALS_WRONG_ARG_COUNT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_args", + expression={"$setEquals": []}, + error_code=SET_EQUALS_WRONG_ARG_COUNT_ERROR, + msg="$setEquals should return error for zero arguments", + ), + ExpressionTestCase( + "one_arg", + expression={"$setEquals": [[1, 2]]}, + error_code=SET_EQUALS_WRONG_ARG_COUNT_ERROR, + msg="$setEquals should return error for a single argument", + ), + ExpressionTestCase( + "non_array_wrapper", + expression={"$setEquals": "not_an_array"}, + error_code=SET_EQUALS_WRONG_ARG_COUNT_ERROR, + msg="$setEquals should return error for a string operand list", + ), + ExpressionTestCase( + "scalar_wrapper", + expression={"$setEquals": 1}, + error_code=SET_EQUALS_WRONG_ARG_COUNT_ERROR, + msg="$setEquals should return error for a scalar operand list", + ), + ExpressionTestCase( + "object_operand", + expression={"$setEquals": {"a": [1, 2]}}, + error_code=SET_EQUALS_WRONG_ARG_COUNT_ERROR, + msg="$setEquals should return error for an object operand list", + ), +] + +# Property [Null Literal Operand]: a null literal operand is not an array and +# errors in any position, including among three or more operands. +SETEQUALS_NULL_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_first", + expression={"$setEquals": [None, [1, 2]]}, + error_code=SET_EQUALS_LITERAL_NON_ARRAY_ERROR, + msg="$setEquals should return error for a null first operand", + ), + ExpressionTestCase( + "null_second", + expression={"$setEquals": [[1, 2], None]}, + error_code=SET_EQUALS_LITERAL_NON_ARRAY_ERROR, + msg="$setEquals should return error for a null second operand", + ), + ExpressionTestCase( + "null_both", + expression={"$setEquals": [None, None]}, + error_code=SET_EQUALS_LITERAL_NON_ARRAY_ERROR, + msg="$setEquals should return error for two null operands", + ), + ExpressionTestCase( + "null_middle", + expression={"$setEquals": [[1, 2], None, [1, 2]]}, + error_code=SET_EQUALS_LITERAL_NON_ARRAY_ERROR, + msg="$setEquals should return error for a null operand in the middle of three", + ), + ExpressionTestCase( + "null_first_of_three", + expression={"$setEquals": [None, [1], [2]]}, + error_code=SET_EQUALS_LITERAL_NON_ARRAY_ERROR, + msg="$setEquals should return error for a null first operand of three", + ), + ExpressionTestCase( + "null_last_of_three", + expression={"$setEquals": [[1], [1], None]}, + error_code=SET_EQUALS_LITERAL_NON_ARRAY_ERROR, + msg="$setEquals should return error for a null last operand of three", + ), +] + +# Property [Non-Array Literal First]: a non-array literal first operand errors, +# and every non-array BSON type is rejected. +SETEQUALS_NON_ARRAY_FIRST_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"{tid}_first", + expression={"$setEquals": [val, [1, 2]]}, + error_code=SET_EQUALS_LITERAL_NON_ARRAY_ERROR, + msg=f"$setEquals should return error for {tid} as the first operand", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("string", "hello"), + ("bool", True), + ("object", {"a": 1}), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("aaaaaaaaaaaaaaaaaaaaaaaa")), + ("regex", Regex("abc", "")), + ("javascript", Code("function(){}")), + ("binary", Binary(b"\x01", 0)), + ("timestamp", Timestamp(1, 1)), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("nan", FLOAT_NAN), + ("infinity", FLOAT_INFINITY), + ("decimal_nan", DECIMAL128_NAN), + ("decimal_infinity", DECIMAL128_INFINITY), + ] +] + +# Property [Non-Array Literal Second]: a non-array literal second operand errors, +# and every non-array BSON type is rejected. +SETEQUALS_NON_ARRAY_SECOND_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"{tid}_second", + expression={"$setEquals": [[1, 2], val]}, + error_code=SET_EQUALS_LITERAL_NON_ARRAY_ERROR, + msg=f"$setEquals should return error for {tid} as the second operand", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("string", "hello"), + ("bool", True), + ("object", {"a": 1}), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("aaaaaaaaaaaaaaaaaaaaaaaa")), + ("regex", Regex("abc", "")), + ("javascript", Code("function(){}")), + ("binary", Binary(b"\x01", 0)), + ("timestamp", Timestamp(1, 1)), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("nan", FLOAT_NAN), + ("infinity", FLOAT_INFINITY), + ("decimal_nan", DECIMAL128_NAN), + ("decimal_infinity", DECIMAL128_INFINITY), + ] +] + +# Property [Missing Field]: a field path to an absent field resolves to missing +# and errors at runtime rather than being treated as an array. +SETEQUALS_MISSING_FIELD_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_first", + doc={"b": [1, 2]}, + expression={"$setEquals": [MISSING, "$b"]}, + error_code=SET_EQUALS_RUNTIME_NON_ARRAY_ERROR, + msg="$setEquals should return error when the first field is missing", + ), + ExpressionTestCase( + "missing_second", + doc={"a": [1, 2]}, + expression={"$setEquals": ["$a", MISSING]}, + error_code=SET_EQUALS_RUNTIME_NON_ARRAY_ERROR, + msg="$setEquals should return error when the second field is missing", + ), + ExpressionTestCase( + "missing_both", + doc={"z": 1}, + expression={"$setEquals": ["$x", "$y"]}, + error_code=SET_EQUALS_RUNTIME_NON_ARRAY_ERROR, + msg="$setEquals should return error when both fields are missing", + ), +] + +# Property [Field Resolves To Non-Array]: a field path that resolves to a +# non-array value errors at runtime, including a field present as null. +SETEQUALS_FIELD_NON_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "field_resolves_int", + doc={"a": 5}, + expression={"$setEquals": ["$a", [1]]}, + error_code=SET_EQUALS_RUNTIME_NON_ARRAY_ERROR, + msg="$setEquals should error when a field resolves to int", + ), + ExpressionTestCase( + "field_resolves_int_second", + doc={"a": 5}, + expression={"$setEquals": [[1], "$a"]}, + error_code=SET_EQUALS_RUNTIME_NON_ARRAY_ERROR, + msg="$setEquals should error when a second-position field resolves to int", + ), + ExpressionTestCase( + "field_resolves_string", + doc={"a": "hello"}, + expression={"$setEquals": ["$a", [1]]}, + error_code=SET_EQUALS_RUNTIME_NON_ARRAY_ERROR, + msg="$setEquals should error when a field resolves to string", + ), + ExpressionTestCase( + "field_resolves_string_second", + doc={"a": "hello"}, + expression={"$setEquals": [[1], "$a"]}, + error_code=SET_EQUALS_RUNTIME_NON_ARRAY_ERROR, + msg="$setEquals should error when a second-position field resolves to string", + ), + ExpressionTestCase( + "field_resolves_object", + doc={"a": {"b": 1}}, + expression={"$setEquals": ["$a", [1]]}, + error_code=SET_EQUALS_RUNTIME_NON_ARRAY_ERROR, + msg="$setEquals should error when a field resolves to object", + ), + ExpressionTestCase( + "field_resolves_object_second", + doc={"a": {"b": 1}}, + expression={"$setEquals": [[1], "$a"]}, + error_code=SET_EQUALS_RUNTIME_NON_ARRAY_ERROR, + msg="$setEquals should error when a second-position field resolves to object", + ), + ExpressionTestCase( + "field_resolves_null", + doc={"a": None}, + expression={"$setEquals": ["$a", [1]]}, + error_code=SET_EQUALS_RUNTIME_NON_ARRAY_ERROR, + msg="$setEquals should error when a field resolves to null", + ), + ExpressionTestCase( + "field_resolves_null_second", + doc={"a": None}, + expression={"$setEquals": [[1], "$a"]}, + error_code=SET_EQUALS_RUNTIME_NON_ARRAY_ERROR, + msg="$setEquals should error when a second-position field resolves to null", + ), +] + +SETEQUALS_ERROR_TESTS: list[ExpressionTestCase] = ( + SETEQUALS_WRONG_ARG_COUNT_TESTS + + SETEQUALS_NULL_LITERAL_TESTS + + SETEQUALS_NON_ARRAY_FIRST_TESTS + + SETEQUALS_NON_ARRAY_SECOND_TESTS + + SETEQUALS_MISSING_FIELD_TESTS + + SETEQUALS_FIELD_NON_ARRAY_TESTS +) + +SETEQUALS_INVALID_TESTS: list[ExpressionTestCase] = ( + SETEQUALS_NULL_ELEMENT_TESTS + SETEQUALS_ERROR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(SETEQUALS_INVALID_TESTS)) +def test_setEquals_invalid(collection, test): + """Test $setEquals null, argument-count, missing-field, and non-array handling.""" + 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/set/setIntersection/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setIntersection/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/set/setIntersection/test_setIntersection_core.py b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setIntersection/test_setIntersection_core.py new file mode 100644 index 000000000..6fb710551 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setIntersection/test_setIntersection_core.py @@ -0,0 +1,617 @@ +"""Tests for $setIntersection core behavior: dedup, ordering, nesting, arity, and large arrays.""" + +import pytest +from bson import Decimal128 + +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 + +# Property [Basic Intersection]: the result holds the deduplicated elements +# common to every operand array. +SETINTERSECTION_BASIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "partial_overlap", + doc={"a": [1, 2, 3], "b": [2, 3, 4]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[2, 3], + msg="$setIntersection should return common elements for partial overlap", + ), + ExpressionTestCase( + "no_overlap", + doc={"a": [1, 2, 3], "b": [4, 5, 6]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[], + msg="$setIntersection should return empty for disjoint arrays", + ), + ExpressionTestCase( + "identical", + doc={"a": ["a", "b"], "b": ["a", "b"]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=["a", "b"], + msg="$setIntersection should return all elements for identical arrays", + ), + ExpressionTestCase( + "second_subset_of_first", + doc={"a": [1, 2, 3], "b": [2, 3]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[2, 3], + msg="$setIntersection should return subset elements when second is subset of first", + ), + ExpressionTestCase( + "first_subset_of_second", + doc={"a": [2, 3], "b": [2, 3, 4]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[2, 3], + msg="$setIntersection should return first elements when first is subset of second", + ), + ExpressionTestCase( + "superset_second", + doc={"a": ["red", "blue"], "b": ["red", "blue", "green"]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=["red", "blue"], + msg="$setIntersection should return common elements when second is superset", + ), + ExpressionTestCase( + "superset_first", + doc={"a": ["red", "blue", "green"], "b": ["blue", "red"]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=["blue", "red"], + msg="$setIntersection should return common elements when first is superset", + ), + ExpressionTestCase( + "partial_overlap_strings", + doc={"a": ["red", "blue"], "b": ["green", "red"]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=["red"], + msg="$setIntersection should return single common string element", + ), + ExpressionTestCase( + "single_match", + doc={"a": [1], "b": [1]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[1], + msg="$setIntersection should return single element for matching single-element arrays", + ), + ExpressionTestCase( + "single_no_match", + doc={"a": [1], "b": [2]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[], + msg="$setIntersection should return empty for non-matching single-element arrays", + ), + ExpressionTestCase( + "int_vs_string_no_overlap", + doc={"a": [1, 2, 3], "b": ["x", "y", "z"]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[], + msg="$setIntersection should return empty for different types with no overlap", + ), + ExpressionTestCase( + "mixed_types_partial", + doc={"a": [1, "a", True, None], "b": [1, "b", True, None]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[1, True, None], + msg="$setIntersection should return matching elements across mixed types", + ), +] + +# Property [Empty Operands]: an empty operand yields an empty intersection +# because nothing is common to every array. +SETINTERSECTION_EMPTY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "both_empty", + doc={"a": [], "b": []}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[], + msg="$setIntersection should return empty for two empty arrays", + ), + ExpressionTestCase( + "first_empty", + doc={"a": [], "b": ["a", "b"]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[], + msg="$setIntersection should return empty when first is empty", + ), + ExpressionTestCase( + "second_empty", + doc={"a": ["a", "b"], "b": []}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[], + msg="$setIntersection should return empty when second is empty", + ), + ExpressionTestCase( + "first_empty_ints", + doc={"a": [], "b": [1, 2, 3]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[], + msg="$setIntersection should return empty when first is an empty int array", + ), + ExpressionTestCase( + "second_empty_strings", + doc={"a": ["red", "blue"], "b": []}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[], + msg="$setIntersection should return empty when second is an empty string array", + ), + ExpressionTestCase( + "three_all_empty", + doc={"a": [], "b": [], "c": []}, + expression={"$setIntersection": ["$a", "$b", "$c"]}, + expected=[], + msg="$setIntersection should return empty for three empty arrays", + ), + ExpressionTestCase( + "one_of_three_empty", + doc={"a": ["a"], "b": [], "c": ["a"]}, + expression={"$setIntersection": ["$a", "$b", "$c"]}, + expected=[], + msg="$setIntersection should return empty when one of three arrays is empty", + ), +] + +# Property [Duplicate Handling]: set semantics deduplicate every operand before +# computing the intersection. +SETINTERSECTION_DUPES_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dupes_in_first", + doc={"a": ["a", "a", "b"], "b": ["a", "b", "b"]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=["a", "b"], + msg="$setIntersection should deduplicate both arrays", + ), + ExpressionTestCase( + "dupes_in_second", + doc={"a": ["a", "b"], "b": ["a", "a", "c"]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=["a"], + msg="$setIntersection should return common elements ignoring duplicates in second", + ), + ExpressionTestCase( + "dupes_in_both", + doc={"a": ["a", "a"], "b": ["a", "a"]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=["a"], + msg="$setIntersection should deduplicate to a single element", + ), + ExpressionTestCase( + "multiple_dupes", + doc={"a": ["a", "a", "b", "b"], "b": ["b", "b", "c", "c"]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=["b"], + msg="$setIntersection should return the single common element deduplicated", + ), + ExpressionTestCase( + "heavy_dupes_first", + doc={"a": ["red", "red", "red"], "b": ["red"]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=["red"], + msg="$setIntersection should return single element for heavy duplicates in first", + ), + ExpressionTestCase( + "heavy_dupes_second", + doc={"a": ["red"], "b": ["red", "red", "red", "red"]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=["red"], + msg="$setIntersection should return single element for heavy duplicates in second", + ), + ExpressionTestCase( + "single_array_with_dupes_strings", + doc={"a": ["red", "red", "red", "blue", "blue"]}, + expression={"$setIntersection": ["$a"]}, + expected=["blue", "red"], + msg="$setIntersection should deduplicate a single string array with duplicates", + ), + ExpressionTestCase( + "numeric_dupes", + doc={"a": [1, 2, 2, 2, 2], "b": [1, 1, 2, 2, 2, 2]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[1, 2], + msg="$setIntersection should deduplicate numeric arrays with duplicates", + ), + ExpressionTestCase( + "no_overlap_with_dupes", + doc={"a": [1, 2, 2, 2, 2], "b": [3, 4, 3, 3]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[], + msg="$setIntersection should return empty for no overlap even with duplicates", + ), + ExpressionTestCase( + "dupes_three_arrays", + doc={ + "a": ["red", "blue", "blue"], + "b": ["red", "red", "red", "blue", "blue"], + "c": ["red", "blue", "red", "blue"], + }, + expression={"$setIntersection": ["$a", "$b", "$c"]}, + expected=["blue", "red"], + msg="$setIntersection should deduplicate across three arrays", + ), + ExpressionTestCase( + "dupes_four_arrays_single_common", + doc={ + "a": ["red", "red", "blue", "blue"], + "b": ["red", "blue", "blue", "blue"], + "c": ["red", "blue"], + "d": ["blue"], + }, + expression={"$setIntersection": ["$a", "$b", "$c", "$d"]}, + expected=["blue"], + msg="$setIntersection should return single common element for four arrays with duplicates", + ), +] + +# Property [Order Independence]: the result set does not depend on the order of +# elements within an operand or the order of the operands themselves. +SETINTERSECTION_ORDER_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "elements_reordered", + doc={"a": ["a", "b", "c"], "b": ["c", "b", "a"]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=["a", "b", "c"], + msg="$setIntersection should return same result regardless of element order", + ), + ExpressionTestCase( + "dupes_different_order", + doc={"a": ["red", "blue"], "b": ["blue", "red", "blue"]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=["blue", "red"], + msg="$setIntersection should return same result regardless of order and duplicates", + ), + ExpressionTestCase( + "args_abc", + doc={"a": [1, 2, 3], "b": [2, 3, 4], "c": [3, 4, 5]}, + expression={"$setIntersection": ["$a", "$b", "$c"]}, + expected=[3], + msg="$setIntersection should return same result for a, b, c argument order", + ), + ExpressionTestCase( + "args_bac", + doc={"a": [2, 3, 4], "b": [1, 2, 3], "c": [3, 4, 5]}, + expression={"$setIntersection": ["$a", "$b", "$c"]}, + expected=[3], + msg="$setIntersection should return same result for b, a, c argument order", + ), + ExpressionTestCase( + "args_cba", + doc={"a": [3, 4, 5], "b": [2, 3, 4], "c": [1, 2, 3]}, + expression={"$setIntersection": ["$a", "$b", "$c"]}, + expected=[3], + msg="$setIntersection should return same result for c, b, a argument order", + ), +] + +# Property [Atomic Nesting]: array elements are compared as whole values and are +# not descended into. +SETINTERSECTION_NESTED_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_vs_array", + doc={"a": ["a", "b"], "b": [["a", "b"]]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[], + msg="$setIntersection should return empty when a string differs from an array element", + ), + ExpressionTestCase( + "nested_vs_scalar", + doc={"a": ["red", "blue"], "b": [["red"], ["blue"]]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[], + msg="$setIntersection should return empty without descending into nested arrays", + ), + ExpressionTestCase( + "nested_vs_scalar_wrapping", + doc={"a": ["red", "blue"], "b": [["red", "blue"]]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[], + msg="$setIntersection should return empty when a string differs from a nested array", + ), + ExpressionTestCase( + "matching_nested", + doc={"a": [[1, 2], [3, 4]], "b": [[1, 2], [5, 6]]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[[1, 2]], + msg="$setIntersection should match nested arrays at the top level", + ), + ExpressionTestCase( + "nested_order_matters", + doc={"a": [[1, 2]], "b": [[2, 1]]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[], + msg="$setIntersection should return empty when nested array element order differs", + ), + ExpressionTestCase( + "deeply_nested", + doc={"a": [[[1]]], "b": [[[1]]]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[[[1]]], + msg="$setIntersection should match deeply nested arrays", + ), + ExpressionTestCase( + "mixed_nesting", + doc={"a": [1, [1]], "b": [1, [1]]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[1, [1]], + msg="$setIntersection should match both scalar and nested elements", + ), + ExpressionTestCase( + "mixed_nesting_partial", + doc={"a": [1, [1]], "b": [[1], 2]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[[1]], + msg="$setIntersection should match only the nested array element", + ), + ExpressionTestCase( + "deeply_nested_strings", + doc={"a": [["red", "blue"]], "b": [["red", "blue"]]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[["red", "blue"]], + msg="$setIntersection should match nested string arrays", + ), + ExpressionTestCase( + "deeply_nested_diff_order", + doc={"a": [["red", "blue"]], "b": [["blue", "red"]]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[], + msg="$setIntersection should return empty when nested string array order differs", + ), + ExpressionTestCase( + "nested_three_arrays_match", + doc={"a": [[1, 2]], "b": [[1, 2]], "c": [[1, 2]]}, + expression={"$setIntersection": ["$a", "$b", "$c"]}, + expected=[[1, 2]], + msg="$setIntersection should return the nested match across three arrays", + ), + ExpressionTestCase( + "nested_three_arrays_no_match", + doc={"a": [["red", "blue"]], "b": [["red", "blue"]], "c": [["blue", "red"]]}, + expression={"$setIntersection": ["$a", "$b", "$c"]}, + expected=[], + msg="$setIntersection should return empty when the third array breaks the nested match", + ), + ExpressionTestCase( + "different_nesting_levels", + doc={"a": [["red", "blue"]], "b": [[["red", "blue"]]]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[], + msg="$setIntersection should return empty for different nesting levels", + ), + ExpressionTestCase( + "nested_array_vs_scalars", + doc={"a": [[1, 2]], "b": [1, 2, 3]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[], + msg="$setIntersection should return empty when a nested array differs from scalar elements", + ), + ExpressionTestCase( + "scalar_vs_nested_element", + doc={"a": [1, 2], "b": [[1, 2], 3]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[], + msg="$setIntersection should return empty when scalars differ from a nested array element", + ), + ExpressionTestCase( + "nested_match_among_mixed", + doc={"a": [[1, 2]], "b": [[1, 2], 3]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[[1, 2]], + msg="$setIntersection should return the matching nested array among mixed elements", + ), + ExpressionTestCase( + "nested_numeric_equivalence", + doc={"a": [[Decimal128("2")]], "b": [[2]]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[[Decimal128("2")]], + msg="$setIntersection should apply numeric equivalence to elements inside nested arrays", + ), +] + +# Property [Multiple Arguments]: the intersection generalizes to three or more +# operands, filtering to elements common to all. +SETINTERSECTION_MULTI_ARG_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "three_arrays", + doc={"a": ["a", "b"], "b": ["b", "c"], "c": ["b", "d"]}, + expression={"$setIntersection": ["$a", "$b", "$c"]}, + expected=["b"], + msg="$setIntersection should return single common element for three arrays", + ), + ExpressionTestCase( + "three_arrays_common", + doc={"a": ["red", "blue"], "b": ["green", "blue", "red"], "c": ["red", "blue"]}, + expression={"$setIntersection": ["$a", "$b", "$c"]}, + expected=["blue", "red"], + msg="$setIntersection should return two common elements for three arrays", + ), + ExpressionTestCase( + "three_field_refs", + doc={"a": ["red", "blue"], "b": ["blue", "red", "green"], "c": ["red", "green"]}, + expression={"$setIntersection": ["$a", "$b", "$c"]}, + expected=["red"], + msg="$setIntersection should return single common element across three field references", + ), + ExpressionTestCase( + "three_arrays_no_common", + doc={"a": ["a"], "b": ["b"], "c": ["c"]}, + expression={"$setIntersection": ["$a", "$b", "$c"]}, + expected=[], + msg="$setIntersection should return empty for three arrays with no common elements", + ), + ExpressionTestCase( + "three_arrays_progressive", + doc={"a": [1, 2, 3, 4, 5], "b": [2, 3, 4, 5, 6], "c": [3, 4, 5, 6, 7]}, + expression={"$setIntersection": ["$a", "$b", "$c"]}, + expected=[3, 4, 5], + msg="$setIntersection should return progressively filtered common elements", + ), + ExpressionTestCase( + "four_arrays", + doc={ + "a": [1, 2, 3, 4, 5], + "b": [2, 3, 4, 5, 6], + "c": [3, 4, 5, 6, 7], + "d": [4, 5, 6, 7, 8], + }, + expression={"$setIntersection": ["$a", "$b", "$c", "$d"]}, + expected=[4, 5], + msg="$setIntersection should return progressively filtered common elements for four arrays", + ), + ExpressionTestCase( + "four_arrays_single_common", + doc={ + "a": ["red", "green"], + "b": ["blue", "green"], + "c": ["green", "yellow"], + "d": ["green"], + }, + expression={"$setIntersection": ["$a", "$b", "$c", "$d"]}, + expected=["green"], + msg="$setIntersection should return single common element for four arrays", + ), + ExpressionTestCase( + "four_field_refs_one_empty", + doc={"a": [], "b": ["blue"], "c": ["red", "blue"], "d": ["red"]}, + expression={"$setIntersection": ["$a", "$b", "$c", "$d"]}, + expected=[], + msg="$setIntersection should return empty when one of four field references is empty", + ), + ExpressionTestCase( + "five_arrays_no_common", + doc={ + "a": ["red", "green"], + "b": ["blue", "green"], + "c": ["green", "yellow"], + "d": ["red"], + "e": ["blue"], + }, + expression={"$setIntersection": ["$a", "$b", "$c", "$d", "$e"]}, + expected=[], + msg="$setIntersection should return empty for five arrays with no common element", + ), + ExpressionTestCase( + "five_arrays_all_same", + doc={"a": ["a"], "b": ["a"], "c": ["a"], "d": ["a"], "e": ["a"]}, + expression={"$setIntersection": ["$a", "$b", "$c", "$d", "$e"]}, + expected=["a"], + msg="$setIntersection should return a single element for five single-element arrays", + ), + ExpressionTestCase( + "one_empty_among_many", + doc={"a": [1, 2], "b": [1, 2], "c": []}, + expression={"$setIntersection": ["$a", "$b", "$c"]}, + expected=[], + msg="$setIntersection should return empty when one array among many is empty", + ), + ExpressionTestCase( + "three_different_nesting", + doc={"a": [["red"], ["blue"]], "b": [["red"], ["blue"]], "c": [["red"]]}, + expression={"$setIntersection": ["$a", "$b", "$c"]}, + expected=[["red"]], + msg="$setIntersection should return the partial nested match across three arrays", + ), + ExpressionTestCase( + "three_no_common_nesting", + doc={"a": ["red", "green"], "b": ["blue", "green"], "c": [["green"]]}, + expression={"$setIntersection": ["$a", "$b", "$c"]}, + expected=[], + msg="$setIntersection should return empty for a nesting-level mismatch", + ), + ExpressionTestCase( + "four_no_common_all_arrays", + doc={"a": [1, 2], "b": [3, 4], "c": [5, 6], "d": [7, 8]}, + expression={"$setIntersection": ["$a", "$b", "$c", "$d"]}, + expected=[], + msg="$setIntersection should return empty for four arrays with no overlap", + ), + ExpressionTestCase( + "four_with_one_common", + doc={"a": [1, 2], "b": [1, 4], "c": [1, 3], "d": [1, 4]}, + expression={"$setIntersection": ["$a", "$b", "$c", "$d"]}, + expected=[1], + msg="$setIntersection should return single common integer for four arrays", + ), +] + +# Property [Arity]: $setIntersection accepts zero or more operands, with zero +# operands yielding an empty result and a single operand yielding its own set. +SETINTERSECTION_ARITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_args", + expression={"$setIntersection": []}, + expected=[], + msg="$setIntersection should return empty for zero arguments", + ), + ExpressionTestCase( + "single_array", + doc={"a": ["a", "b"]}, + expression={"$setIntersection": ["$a"]}, + expected=["a", "b"], + msg="$setIntersection should return the array itself for a single argument", + ), + ExpressionTestCase( + "single_array_with_dupes", + doc={"a": [1, 2, 2, 3, 3, 3]}, + expression={"$setIntersection": ["$a"]}, + expected=[1, 2, 3], + msg="$setIntersection should deduplicate a single array argument", + ), +] + +# Property [Large Arrays]: the operator scales to large operands and still +# deduplicates and intersects correctly. +SETINTERSECTION_LARGE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "large_two", + doc={"a": list(range(1000)), "b": list(range(50, 1050))}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=list(range(50, 1000)), + msg="$setIntersection should intersect two 1000-element arrays", + ), + ExpressionTestCase( + "large_three", + doc={ + "a": list(range(1000)), + "b": list(range(50, 1050)), + "c": list(range(100, 1100)), + }, + expression={"$setIntersection": ["$a", "$b", "$c"]}, + expected=list(range(100, 1000)), + msg="$setIntersection should intersect three 1000-element arrays", + ), + ExpressionTestCase( + "large_duplicates", + doc={"a": ["a"] * 10_000, "b": ["a"]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=["a"], + msg="$setIntersection should deduplicate 10000 identical elements to a single element", + ), + ExpressionTestCase( + "scale_10k", + doc={"a": list(range(10_000)), "b": list(range(5_000, 15_000))}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=list(range(5_000, 10_000)), + msg="$setIntersection should intersect two 10K arrays with 5K overlap", + ), +] + +SETINTERSECTION_CORE_TESTS: list[ExpressionTestCase] = ( + SETINTERSECTION_BASIC_TESTS + + SETINTERSECTION_EMPTY_TESTS + + SETINTERSECTION_DUPES_TESTS + + SETINTERSECTION_ORDER_TESTS + + SETINTERSECTION_NESTED_TESTS + + SETINTERSECTION_MULTI_ARG_TESTS + + SETINTERSECTION_ARITY_TESTS + + SETINTERSECTION_LARGE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(SETINTERSECTION_CORE_TESTS)) +def test_setIntersection_core(collection, test): + """Test $setIntersection core behavior with field-reference array operands.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result(result, expected=test.expected, msg=test.msg, ignore_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/set/setIntersection/test_setIntersection_data_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setIntersection/test_setIntersection_data_types.py new file mode 100644 index 000000000..27f9a8291 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setIntersection/test_setIntersection_data_types.py @@ -0,0 +1,568 @@ +"""Tests for $setIntersection element type handling, numeric equivalence, and special values.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, 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_TRAILING_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT64_ZERO, +) + +# Property [Element Type Coverage]: arrays of any single BSON element type +# intersect by value. +SETINTERSECTION_ELEMENT_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int_elements", + doc={"a": [10, 20, 30], "b": [20, 30, 40]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[20, 30], + msg="$setIntersection should intersect int arrays", + ), + ExpressionTestCase( + "long_elements", + doc={"a": [Int64(1), Int64(2)], "b": [Int64(2), Int64(3)]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[Int64(2)], + msg="$setIntersection should intersect long arrays", + ), + ExpressionTestCase( + "double_elements", + doc={"a": [1.0, 2.0], "b": [2.0, 3.0]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[2.0], + msg="$setIntersection should intersect double arrays", + ), + ExpressionTestCase( + "decimal128_elements", + doc={"a": [Decimal128("1"), Decimal128("2")], "b": [Decimal128("2"), Decimal128("3")]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[Decimal128("2")], + msg="$setIntersection should intersect decimal128 arrays", + ), + ExpressionTestCase( + "string_elements", + doc={"a": ["a", "b", "c"], "b": ["b", "c", "d"]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=["b", "c"], + msg="$setIntersection should intersect string arrays", + ), + ExpressionTestCase( + "bool_true", + doc={"a": [True, False], "b": [True]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[True], + msg="$setIntersection should intersect boolean true", + ), + ExpressionTestCase( + "bool_false", + doc={"a": [True, False], "b": [False]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[False], + msg="$setIntersection should intersect boolean false", + ), + ExpressionTestCase( + "null_elements", + doc={"a": [None, 1], "b": [None, 2]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[None], + msg="$setIntersection should intersect null elements", + ), + ExpressionTestCase( + "minkey_elements", + doc={"a": [MinKey(), 1], "b": [MinKey(), 2]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[MinKey()], + msg="$setIntersection should intersect MinKey elements", + ), + ExpressionTestCase( + "maxkey_elements", + doc={"a": [MaxKey(), 1], "b": [MaxKey(), 2]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[MaxKey()], + msg="$setIntersection should intersect MaxKey elements", + ), + ExpressionTestCase( + "date_elements", + doc={ + "a": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 1, 2, tzinfo=timezone.utc), + ], + "b": [datetime(2024, 1, 1, tzinfo=timezone.utc)], + }, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[datetime(2024, 1, 1, tzinfo=timezone.utc)], + msg="$setIntersection should intersect matching date elements", + ), + ExpressionTestCase( + "objectid_elements", + doc={ + "a": [ + ObjectId("507f1f77bcf86cd799439011"), + ObjectId("507f191e810c19729de860ea"), + ], + "b": [ObjectId("507f1f77bcf86cd799439011")], + }, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[ObjectId("507f1f77bcf86cd799439011")], + msg="$setIntersection should intersect matching ObjectId elements", + ), + ExpressionTestCase( + "timestamp_elements", + doc={"a": [Timestamp(100, 1), Timestamp(200, 1)], "b": [Timestamp(100, 1)]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[Timestamp(100, 1)], + msg="$setIntersection should intersect matching Timestamp elements", + ), + ExpressionTestCase( + "regex_elements", + doc={"a": [Regex("abc", "i"), Regex("def", "")], "b": [Regex("abc", "i")]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[Regex("abc", "i")], + msg="$setIntersection should intersect matching regex elements", + ), + ExpressionTestCase( + "javascript_elements", + doc={"a": [Code("function(){}"), Code("other()")], "b": [Code("function(){}")]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[Code("function(){}")], + msg="$setIntersection should intersect matching javascript code elements", + ), + ExpressionTestCase( + "binary_elements", + doc={"a": [Binary(b"\x00\x00\x00", 0)], "b": [Binary(b"\x00\x00\x00", 0)]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[b"\x00\x00\x00"], + msg="$setIntersection should intersect matching binary elements", + ), + ExpressionTestCase( + "object_same", + doc={"a": [{"a": 1}, {"b": 2}], "b": [{"a": 1}, {"c": 3}]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[{"a": 1}], + msg="$setIntersection should match identical objects", + ), + ExpressionTestCase( + "object_nested", + doc={"a": [{"a": {"b": 1}}], "b": [{"a": {"b": 1}}]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[{"a": {"b": 1}}], + msg="$setIntersection should match nested objects", + ), + ExpressionTestCase( + "empty_objects", + doc={"a": [{}], "b": [{}]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[{}], + msg="$setIntersection should match empty objects", + ), +] + +# Property [Within-Type Distinction]: near-equal values of the same type are +# distinct and do not match. +SETINTERSECTION_DISTINCTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "date_diff_ms", + doc={ + "a": [datetime(2024, 1, 1, 0, 0, 0, 0, tzinfo=timezone.utc)], + "b": [datetime(2024, 1, 1, 0, 0, 0, 1000, tzinfo=timezone.utc)], + }, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[], + msg="$setIntersection should return empty for dates differing by milliseconds", + ), + ExpressionTestCase( + "timestamp_diff_ordinal", + doc={"a": [Timestamp(100, 1)], "b": [Timestamp(100, 2)]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[], + msg="$setIntersection should return empty for timestamps differing by ordinal", + ), + ExpressionTestCase( + "regex_diff_flags", + doc={"a": [Regex("abc", "i")], "b": [Regex("abc", "")]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[], + msg="$setIntersection should return empty for regex differing by flags", + ), + ExpressionTestCase( + "object_diff_values", + doc={"a": [{"a": 1}], "b": [{"a": 2}]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[], + msg="$setIntersection should return empty for objects with different values", + ), + ExpressionTestCase( + "object_extra_keys", + doc={"a": [{"a": 1}], "b": [{"a": 1, "b": 2}]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[], + msg="$setIntersection should return empty when objects have extra keys", + ), + ExpressionTestCase( + "object_key_order", + doc={"a": [{"a": 1, "b": 2}], "b": [{"b": 2, "a": 1}]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[], + msg="$setIntersection should return empty for objects with different key order", + ), +] + +# Property [Numeric Equivalence]: numeric values equal across int, long, double, +# and decimal128 match, keeping the first operand's type. +SETINTERSECTION_NUMERIC_EQUIV_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int_long", + doc={"a": [1], "b": [Int64(1)]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[1], + msg="$setIntersection should match int and long equivalence keeping int", + ), + ExpressionTestCase( + "int_double", + doc={"a": [1], "b": [1.0]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[1], + msg="$setIntersection should match int and double equivalence keeping int", + ), + ExpressionTestCase( + "int_decimal128", + doc={"a": [1], "b": [Decimal128("1")]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[1], + msg="$setIntersection should match int and decimal128 equivalence keeping int", + ), + ExpressionTestCase( + "long_double", + doc={"a": [Int64(1)], "b": [1.0]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[Int64(1)], + msg="$setIntersection should match long and double equivalence keeping long", + ), + ExpressionTestCase( + "long_decimal128", + doc={"a": [Int64(1)], "b": [Decimal128("1")]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[Int64(1)], + msg="$setIntersection should match long and decimal128 equivalence keeping long", + ), + ExpressionTestCase( + "double_decimal128", + doc={"a": [1.0], "b": [Decimal128("1")]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[1.0], + msg="$setIntersection should match double and decimal128 equivalence keeping double", + ), + ExpressionTestCase( + "cross_type_decimal_int", + doc={"a": [DECIMAL128_TRAILING_ZERO], "b": [1]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[DECIMAL128_TRAILING_ZERO], + msg="$setIntersection should match decimal128 and int equivalence keeping decimal128", + ), + ExpressionTestCase( + "zero_int_long", + doc={"a": [0], "b": [INT64_ZERO]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[0], + msg="$setIntersection should match zero int and long equivalence", + ), + ExpressionTestCase( + "zero_int_double", + doc={"a": [0], "b": [DOUBLE_ZERO]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[0], + msg="$setIntersection should match zero int and double equivalence", + ), + ExpressionTestCase( + "zero_int_decimal128", + doc={"a": [0], "b": [DECIMAL128_ZERO]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[0], + msg="$setIntersection should match zero int and decimal128 equivalence", + ), + ExpressionTestCase( + "all_numeric_types_dedup", + doc={"a": [1, 1.0, Int64(1)], "b": [Decimal128("1")]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[1], + msg="$setIntersection should collapse all numeric types to a single element", + ), +] + +# Property [BSON Type Distinction]: values of different BSON types never match, +# even when Python treats them as equal. +SETINTERSECTION_BSON_DISTINCTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "false_vs_zero", + doc={"a": [False], "b": [0]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[], + msg="$setIntersection should return empty for false versus zero as different BSON types", + ), + ExpressionTestCase( + "true_vs_one", + doc={"a": [True], "b": [1]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[], + msg="$setIntersection should return empty for true versus one as different BSON types", + ), + ExpressionTestCase( + "false_and_zero_both", + doc={"a": [False, 0], "b": [False, 0]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[False, 0], + msg="$setIntersection should return both false and zero when both are present", + ), + ExpressionTestCase( + "bool_vs_numeric", + doc={"a": [True, False], "b": [1, 2, 4]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[], + msg="$setIntersection should return empty for boolean versus numeric comparison", + ), + ExpressionTestCase( + "empty_string_vs_null", + doc={"a": ["", None], "b": [""]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[""], + msg="$setIntersection should match empty string while excluding null", + ), + ExpressionTestCase( + "empty_string_vs_null_2", + doc={"a": ["", None], "b": [None]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[None], + msg="$setIntersection should match null while excluding empty string", + ), +] + +# Property [NaN Handling]: NaN matches NaN in set context, including across the +# double and decimal128 types. +SETINTERSECTION_NAN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "float_nan", + doc={"a": [FLOAT_NAN, 1], "b": [FLOAT_NAN, 2]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[pytest.approx(FLOAT_NAN, nan_ok=True)], + msg="$setIntersection should match float NaN in set context", + ), + ExpressionTestCase( + "decimal128_nan", + doc={"a": [DECIMAL128_NAN, 1], "b": [DECIMAL128_NAN, 2]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[DECIMAL128_NAN], + msg="$setIntersection should match decimal128 NaN in set context", + ), + ExpressionTestCase( + "cross_type_nan", + doc={"a": [FLOAT_NAN], "b": [DECIMAL128_NAN]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[pytest.approx(FLOAT_NAN, nan_ok=True)], + msg="$setIntersection should match NaN across double and decimal128, keeping double", + ), + ExpressionTestCase( + "nan_dedup_single_array", + doc={"a": [FLOAT_NAN, FLOAT_NAN, 1]}, + expression={"$setIntersection": ["$a"]}, + expected=[pytest.approx(FLOAT_NAN, nan_ok=True), 1], + msg="$setIntersection should deduplicate NaN within a single array", + ), +] + +# Property [Infinity Handling]: infinities match, including across the double and +# decimal128 types. +SETINTERSECTION_INFINITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "float_inf", + doc={"a": [FLOAT_INFINITY, 1], "b": [FLOAT_INFINITY, 2]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[FLOAT_INFINITY], + msg="$setIntersection should match positive infinity", + ), + ExpressionTestCase( + "float_neg_inf", + doc={"a": [FLOAT_NEGATIVE_INFINITY, 1], "b": [FLOAT_NEGATIVE_INFINITY, 2]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[FLOAT_NEGATIVE_INFINITY], + msg="$setIntersection should match negative infinity", + ), + ExpressionTestCase( + "cross_type_inf", + doc={"a": [DECIMAL128_INFINITY], "b": [FLOAT_INFINITY]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[DECIMAL128_INFINITY], + msg="$setIntersection should match infinity across decimal128 and double", + ), + ExpressionTestCase( + "cross_type_neg_inf", + doc={"a": [DECIMAL128_NEGATIVE_INFINITY], "b": [FLOAT_NEGATIVE_INFINITY]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[DECIMAL128_NEGATIVE_INFINITY], + msg="$setIntersection should match negative infinity across decimal128 and double", + ), +] + +# Property [Negative Zero]: negative zero equals positive zero, including across +# the double and decimal128 types. +SETINTERSECTION_NEG_ZERO_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "double_neg_zero_vs_zero", + doc={"a": [DOUBLE_NEGATIVE_ZERO, 1], "b": [DOUBLE_ZERO, 1]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[DOUBLE_NEGATIVE_ZERO, 1], + msg="$setIntersection should treat double negative zero and zero as equivalent", + ), + ExpressionTestCase( + "decimal128_neg_zero_vs_zero", + doc={"a": [DECIMAL128_NEGATIVE_ZERO], "b": [DECIMAL128_ZERO]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[DECIMAL128_NEGATIVE_ZERO], + msg="$setIntersection should treat decimal128 negative zero and zero as equivalent", + ), + ExpressionTestCase( + "cross_type_neg_zero", + doc={"a": [DOUBLE_NEGATIVE_ZERO], "b": [DECIMAL128_ZERO]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[DOUBLE_NEGATIVE_ZERO], + msg="$setIntersection should match negative zero across double and decimal128", + ), +] + +# Property [Decimal128 Representation]: decimal128 values equal in magnitude +# match regardless of trailing zeros or exponent notation. +SETINTERSECTION_DECIMAL128_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "trailing_zeros", + doc={"a": [DECIMAL128_TRAILING_ZERO], "b": [Decimal128("1.00")]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[DECIMAL128_TRAILING_ZERO], + msg="$setIntersection should match decimal128 values differing only by trailing zeros", + ), + ExpressionTestCase( + "exponent_notation", + doc={"a": [Decimal128("1E+2")], "b": [Decimal128("100")]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[Decimal128("1E+2")], + msg="$setIntersection should match decimal128 values in exponent notation", + ), + ExpressionTestCase( + "high_precision_match", + doc={ + "a": [Decimal128("1.000000000000000000000000000000001")], + "b": [Decimal128("1.000000000000000000000000000000001")], + }, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[Decimal128("1.000000000000000000000000000000001")], + msg="$setIntersection should match high-precision decimal128 values", + ), + ExpressionTestCase( + "high_precision_mismatch", + doc={ + "a": [Decimal128("1.000000000000000000000000000000001")], + "b": [Decimal128("1.000000000000000000000000000000002")], + }, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[], + msg="$setIntersection should return empty for high-precision decimal128 mismatch", + ), +] + +# Property [Mixed Type Arrays]: intersection over arrays holding several BSON +# types returns only the common elements. +SETINTERSECTION_MIXED_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "bool_true_from_mixed", + doc={"a": [True, True], "b": [True, False]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[True], + msg="$setIntersection should return true from mixed boolean arrays", + ), + ExpressionTestCase( + "mixed_string_null_bool_int", + doc={"a": ["string", None, True, 1, {}], "b": ["string", None, True, 1, []]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=["string", None, True, 1], + msg="$setIntersection should return common elements across mixed types", + ), +] + +# Property [String Elements]: string comparison is exact and case-sensitive over +# unicode, emoji, empty, and control-character strings. +SETINTERSECTION_STRING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "unicode", + doc={"a": ["café", "naïve"], "b": ["café", "résumé"]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=["café"], + msg="$setIntersection should intersect unicode strings", + ), + ExpressionTestCase( + "emoji", + doc={"a": ["🎉", "🎊"], "b": ["🎉", "🎈"]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=["🎉"], + msg="$setIntersection should intersect emoji strings", + ), + ExpressionTestCase( + "empty_string", + doc={"a": ["", "a"], "b": ["", "b"]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[""], + msg="$setIntersection should intersect the empty string element", + ), + ExpressionTestCase( + "newline_string", + doc={"a": ["a\nb", "c"], "b": ["a\nb", "d"]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=["a\nb"], + msg="$setIntersection should intersect strings containing a newline", + ), + ExpressionTestCase( + "case_sensitive", + doc={"a": ["abc", "def"], "b": ["ABC", "def"]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=["def"], + msg="$setIntersection should treat strings as case-sensitive", + ), +] + +SETINTERSECTION_TYPE_TESTS: list[ExpressionTestCase] = ( + SETINTERSECTION_ELEMENT_TYPE_TESTS + + SETINTERSECTION_DISTINCTION_TESTS + + SETINTERSECTION_NUMERIC_EQUIV_TESTS + + SETINTERSECTION_BSON_DISTINCTION_TESTS + + SETINTERSECTION_NAN_TESTS + + SETINTERSECTION_INFINITY_TESTS + + SETINTERSECTION_NEG_ZERO_TESTS + + SETINTERSECTION_DECIMAL128_TESTS + + SETINTERSECTION_STRING_TESTS + + SETINTERSECTION_MIXED_TYPE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(SETINTERSECTION_TYPE_TESTS)) +def test_setIntersection_types(collection, test): + """Test $setIntersection element type handling, numeric equivalence, and special values.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result(result, expected=test.expected, msg=test.msg, ignore_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/set/setIntersection/test_setIntersection_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setIntersection/test_setIntersection_expressions.py new file mode 100644 index 000000000..c018ce262 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setIntersection/test_setIntersection_expressions.py @@ -0,0 +1,112 @@ +"""Tests for $setIntersection operand input shapes, inline and resolved via paths and variables.""" + +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 SET_INTERSECTION_NON_ARRAY_ERROR +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Inline Operand Input]: literal arrays and expression-operator operands +# evaluate inline without a field reference. +SETINTERSECTION_INLINE_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal", + expression={"$setIntersection": [[1, 2, 3], [2, 3, 4]]}, + expected=[2, 3], + msg="$setIntersection should return intersection for literal array inputs", + ), + ExpressionTestCase( + "expression_operator", + expression={"$setIntersection": [{"$literal": [1, 2, 3]}, {"$literal": [2, 3, 4]}]}, + expected=[2, 3], + msg="$setIntersection should accept expression operator inputs like literal", + ), +] + +# Property [Object Operand]: an object operand is not an array and is rejected. +SETINTERSECTION_INLINE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "object_operand", + expression={"$setIntersection": {"a": [1, 2]}}, + error_code=SET_INTERSECTION_NON_ARRAY_ERROR, + msg="$setIntersection should return error for an object operand", + ), +] + +SETINTERSECTION_INLINE_TESTS: list[ExpressionTestCase] = ( + SETINTERSECTION_INLINE_INPUT_TESTS + SETINTERSECTION_INLINE_ERROR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(SETINTERSECTION_INLINE_TESTS)) +def test_setIntersection_expression_inline(collection, test): + """Test $setIntersection with inline literal and expression-operator operands.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg, ignore_order=True + ) + + +# Property [Resolved Input Shapes]: operands reached through a nested-object path, +# an array-of-objects path, a numeric-index path, or a system variable resolve and +# feed the operator. +SETINTERSECTION_INPUT_SHAPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_object_field", + doc={"x": {"a": [1, 2]}, "y": {"b": [2, 3]}}, + expression={"$setIntersection": ["$x.a", "$y.b"]}, + expected=[2], + msg="$setIntersection should resolve nested object field paths for both operands", + ), + ExpressionTestCase( + "composite_array_field", + doc={"a": [{"b": 1}, {"b": 2}, {"b": 3}]}, + expression={"$setIntersection": ["$a.b", [2, 3]]}, + expected=[2, 3], + msg="$setIntersection should resolve composite array field path through array of objects", + ), + # In an aggregation field path a numeric component is a field name, not an + # array index, so it resolves to an empty array over the array of arrays. + ExpressionTestCase( + "array_index_path", + doc={"a": [[1, 2], [3]]}, + expression={"$setIntersection": ["$a.0", [1, 2]]}, + expected=[], + msg="$setIntersection should resolve a numeric path component to an empty array", + ), + ExpressionTestCase( + "root_variable", + doc={"arr": [1, 2, 3]}, + expression={"$setIntersection": ["$$ROOT.arr", [2, 3, 4]]}, + expected=[2, 3], + msg="$setIntersection should resolve ROOT system variable for array field", + ), + ExpressionTestCase( + "current_variable", + doc={"arr": [1, 2, 3]}, + expression={"$setIntersection": ["$$CURRENT.arr", [2, 3, 4]]}, + expected=[2, 3], + msg="$setIntersection should resolve CURRENT system variable for array field", + ), + ExpressionTestCase( + "operand_array_of_field_refs", + doc={"x": 1, "y": 2}, + expression={"$setIntersection": [["$x", "$y"], [2, 3]]}, + expected=[2], + msg="$setIntersection should resolve an operand array whose elements are field references", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SETINTERSECTION_INPUT_SHAPE_TESTS)) +def test_setIntersection_input_shapes(collection, test): + """Test $setIntersection with nested, composite, numeric-index, and system-variable operands.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result(result, expected=test.expected, msg=test.msg, ignore_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/set/setIntersection/test_setIntersection_invalid.py b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setIntersection/test_setIntersection_invalid.py new file mode 100644 index 000000000..5d6e136e8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setIntersection/test_setIntersection_invalid.py @@ -0,0 +1,270 @@ +"""Tests for $setIntersection null propagation, missing fields, and non-array errors.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, 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 SET_INTERSECTION_NON_ARRAY_ERROR +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + FLOAT_INFINITY, + FLOAT_NAN, + MISSING, +) + +# Property [Null Propagation]: a null operand makes the whole result null, +# regardless of position or number of operands. +SETINTERSECTION_NULL_PROP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_first", + doc={"a": None, "b": [1, 2, 3]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=None, + msg="$setIntersection should return null when the first operand is null", + ), + ExpressionTestCase( + "null_second", + doc={"a": [1, 2, 3], "b": None}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=None, + msg="$setIntersection should return null when the second operand is null", + ), + ExpressionTestCase( + "null_both", + doc={"a": None, "b": None}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=None, + msg="$setIntersection should return null when both operands are null", + ), + ExpressionTestCase( + "null_among_many", + doc={"a": [1, 2], "b": None, "c": [1, 2]}, + expression={"$setIntersection": ["$a", "$b", "$c"]}, + expected=None, + msg="$setIntersection should return null when a null operand appears among many", + ), + ExpressionTestCase( + "null_and_empty", + doc={"a": [1, 2], "b": None, "c": []}, + expression={"$setIntersection": ["$a", "$b", "$c"]}, + expected=None, + msg="$setIntersection should return null when null takes precedence over an empty operand", + ), +] + +# Property [Null Elements]: a null element inside an array is an ordinary value +# subject to set membership and dedup. +SETINTERSECTION_NULL_ELEMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_in_both", + doc={"a": [None, 1, 2], "b": [None, 2, 3]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[None, 2], + msg="$setIntersection should return the null element when present in both arrays", + ), + ExpressionTestCase( + "null_only_element", + doc={"a": [None], "b": [None]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[None], + msg="$setIntersection should return null as the only common element", + ), + ExpressionTestCase( + "null_in_one_only", + doc={"a": [None, 1], "b": [2, 3]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[], + msg="$setIntersection should return empty when null is in one array only", + ), + ExpressionTestCase( + "null_element_not_in_second", + doc={"a": [None, 1], "b": [1, 2]}, + expression={"$setIntersection": ["$a", "$b"]}, + expected=[1], + msg="$setIntersection should return the non-null common when null is not in second", + ), +] + +# Property [Missing Field]: a field path to an absent field resolves to null and +# propagates null through the operator. +SETINTERSECTION_MISSING_FIELD_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_first", + doc={"b": [1, 2, 3]}, + expression={"$setIntersection": [MISSING, "$b"]}, + expected=None, + msg="$setIntersection should return null when the first field is missing", + ), + ExpressionTestCase( + "missing_second", + doc={"a": [1, 2, 3]}, + expression={"$setIntersection": ["$a", MISSING]}, + expected=None, + msg="$setIntersection should return null when the second field is missing", + ), + ExpressionTestCase( + "missing_both", + doc={"z": 1}, + expression={"$setIntersection": ["$x", "$y"]}, + expected=None, + msg="$setIntersection should return null when both fields are missing", + ), +] + +# Property [Null Precedence]: a null operand propagates null before a non-array +# operand in another position is validated. +SETINTERSECTION_NULL_PRECEDENCE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_first_non_array_second", + expression={"$setIntersection": [None, 1]}, + expected=None, + msg="$setIntersection should propagate null from a null operand rather than " + "error on a non-array sibling", + ), +] + +SETINTERSECTION_SUCCESS_TESTS: list[ExpressionTestCase] = ( + SETINTERSECTION_NULL_PROP_TESTS + + SETINTERSECTION_NULL_ELEMENT_TESTS + + SETINTERSECTION_MISSING_FIELD_TESTS + + SETINTERSECTION_NULL_PRECEDENCE_TESTS +) + +# Property [Non-Array First]: a non-array first operand errors, and every +# non-array BSON type is rejected. +SETINTERSECTION_NON_ARRAY_FIRST_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"{tid}_first", + doc={"a": val, "b": [1, 2]}, + expression={"$setIntersection": ["$a", "$b"]}, + error_code=SET_INTERSECTION_NON_ARRAY_ERROR, + msg=f"$setIntersection should return error for {tid} as first operand", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("string", "hello"), + ("bool", True), + ("object", {"a": 1}), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("aaaaaaaaaaaaaaaaaaaaaaaa")), + ("regex", Regex("abc", "")), + ("javascript", Code("function(){}")), + ("binary", Binary(b"\x01", 0)), + ("timestamp", Timestamp(1, 1)), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("nan", FLOAT_NAN), + ("infinity", FLOAT_INFINITY), + ("decimal_nan", DECIMAL128_NAN), + ("decimal_infinity", DECIMAL128_INFINITY), + ] +] + +# Property [Non-Array Second]: a non-array second operand errors, and every +# non-array BSON type is rejected. +SETINTERSECTION_NON_ARRAY_SECOND_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"{tid}_second", + doc={"a": [1, 2], "b": val}, + expression={"$setIntersection": ["$a", "$b"]}, + error_code=SET_INTERSECTION_NON_ARRAY_ERROR, + msg=f"$setIntersection should return error for {tid} as second operand", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("string", "hello"), + ("bool", True), + ("object", {"a": 1}), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("aaaaaaaaaaaaaaaaaaaaaaaa")), + ("regex", Regex("abc", "")), + ("javascript", Code("function(){}")), + ("binary", Binary(b"\x01", 0)), + ("timestamp", Timestamp(1, 1)), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("nan", FLOAT_NAN), + ("infinity", FLOAT_INFINITY), + ("decimal_nan", DECIMAL128_NAN), + ("decimal_infinity", DECIMAL128_INFINITY), + ] +] + +# Property [Non-Array Literal Operand]: a literal operand list that is not made of +# arrays is rejected. +SETINTERSECTION_NON_ARRAY_LITERAL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int_literal", + expression={"$setIntersection": 1}, + error_code=SET_INTERSECTION_NON_ARRAY_ERROR, + msg="$setIntersection should return error for an integer literal operand", + ), + ExpressionTestCase( + "non_array_elements", + expression={"$setIntersection": [1, 2, 3]}, + error_code=SET_INTERSECTION_NON_ARRAY_ERROR, + msg="$setIntersection should return error for non-array elements as operands", + ), +] + +# Property [Field Resolves To Non-Array]: a field path that resolves to a +# non-array value errors. +SETINTERSECTION_FIELD_NON_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "field_resolves_int", + doc={"a": 5}, + expression={"$setIntersection": ["$a", [1]]}, + error_code=SET_INTERSECTION_NON_ARRAY_ERROR, + msg="$setIntersection should error when a field resolves to int", + ), + ExpressionTestCase( + "field_resolves_string", + doc={"a": "hello"}, + expression={"$setIntersection": ["$a", [1]]}, + error_code=SET_INTERSECTION_NON_ARRAY_ERROR, + msg="$setIntersection should error when a field resolves to string", + ), + ExpressionTestCase( + "field_resolves_object", + doc={"a": {"b": 1}}, + expression={"$setIntersection": ["$a", [1]]}, + error_code=SET_INTERSECTION_NON_ARRAY_ERROR, + msg="$setIntersection should error when a field resolves to object", + ), +] + +SETINTERSECTION_ERROR_TESTS: list[ExpressionTestCase] = ( + SETINTERSECTION_NON_ARRAY_FIRST_TESTS + + SETINTERSECTION_NON_ARRAY_SECOND_TESTS + + SETINTERSECTION_NON_ARRAY_LITERAL_TESTS + + SETINTERSECTION_FIELD_NON_ARRAY_TESTS +) + +SETINTERSECTION_INVALID_TESTS: list[ExpressionTestCase] = ( + SETINTERSECTION_SUCCESS_TESTS + SETINTERSECTION_ERROR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(SETINTERSECTION_INVALID_TESTS)) +def test_setIntersection_invalid(collection, test): + """Test $setIntersection null, missing, and non-array handling.""" + 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, ignore_order=True + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/set/setIsSubset/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setIsSubset/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/set/setIsSubset/test_setIsSubset_core.py b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setIsSubset/test_setIsSubset_core.py new file mode 100644 index 000000000..98d092b52 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setIsSubset/test_setIsSubset_core.py @@ -0,0 +1,462 @@ +"""Tests for $setIsSubset core subset logic: membership, dedup, ordering, nesting, and scale.""" + +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 + +# Property [Subset Membership]: the first operand is a subset when every distinct +# element it holds also appears in the second operand. +SETISSUBSET_BASIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "equal_sets", + doc={"a": ["a", "b"], "b": ["a", "b"]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for equal sets", + ), + ExpressionTestCase( + "proper_subset", + doc={"a": ["a"], "b": ["a", "b"]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for a proper subset", + ), + ExpressionTestCase( + "not_subset_extra_elem", + doc={"a": ["a", "c"], "b": ["a", "b"]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for an extra element in the first array", + ), + ExpressionTestCase( + "superset", + doc={"a": ["a", "b", "c"], "b": ["a", "b"]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for a superset as the first argument", + ), + ExpressionTestCase( + "disjoint", + doc={"a": [1, 2, 3], "b": [4, 5, 6]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for disjoint sets", + ), + ExpressionTestCase( + "int_subset", + doc={"a": [2, 3], "b": [2, 3, 4]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for an int proper subset", + ), + ExpressionTestCase( + "int_equal", + doc={"a": [1, 2, 3], "b": [1, 2, 3]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for int equal sets", + ), + ExpressionTestCase( + "int_dupes_second", + doc={"a": [1, 2, 3], "b": [1, 1, 2, 2, 3, 3]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true when duplicates in the second array are ignored", + ), +] + +# Property [Empty Operands]: the empty set is a subset of every set, and only the +# empty set is a subset of the empty set. +SETISSUBSET_EMPTY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "empty_subset_nonempty", + doc={"a": [], "b": ["a", "b"]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for the empty set as a subset of any set", + ), + ExpressionTestCase( + "empty_subset_empty", + doc={"a": [], "b": []}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for the empty set as a subset of the empty set", + ), + ExpressionTestCase( + "nonempty_not_subset_empty", + doc={"a": ["a"], "b": []}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for a non-empty set as a subset of the empty set", + ), + ExpressionTestCase( + "empty_subset_int", + doc={"a": [], "b": [1, 2, 3]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for the empty set as a subset of an int array", + ), + ExpressionTestCase( + "int_not_subset_empty", + doc={"a": [1, 2, 3], "b": []}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for a non-empty int set as a subset of the empty set", + ), +] + +# Property [Duplicate Handling]: set semantics ignore repeated elements within +# each operand. +SETISSUBSET_DUPLICATE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "dupes_first", + doc={"a": ["a", "a", "b"], "b": ["a", "b"]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true when duplicates in the first array are ignored", + ), + ExpressionTestCase( + "dupes_second", + doc={"a": ["a", "b"], "b": ["a", "a", "b", "b"]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true when duplicates in the second array are ignored", + ), + ExpressionTestCase( + "dupes_both", + doc={"a": ["a", "a"], "b": ["a", "a", "b"]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true when duplicates in both arrays are ignored", + ), + ExpressionTestCase( + "heavy_dupes_first", + doc={"a": [1, 2, 3, 3, 2, 3, 2, 3], "b": [1, 2, 3]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for heavy duplication in the first array", + ), + ExpressionTestCase( + "heavy_dupes_both", + doc={"a": [1, 2, 3, 3, 2, 3, 2, 3], "b": [1, 2, 3, 2, 3]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for heavy duplication in both arrays", + ), + ExpressionTestCase( + "all_same_subset", + doc={"a": ["a", "a", "a"], "b": ["a", "a", "a"]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for arrays of the same repeated element", + ), + ExpressionTestCase( + "dupes_superset", + doc={"a": ["b", "a"], "b": ["a", "b", "a"]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for duplicates in the second array as a superset", + ), + ExpressionTestCase( + "dupes_both_subset", + doc={"a": ["b", "b"], "b": ["a", "b", "b"]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for duplicates in both arrays as a subset", + ), +] + +# Property [Order Independence]: the result does not depend on element order +# within either operand. +SETISSUBSET_ORDER_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "reverse_order_equal", + doc={"a": ["b", "a"], "b": ["a", "b"]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for reverse-order equal sets", + ), + ExpressionTestCase( + "reverse_order_subset", + doc={"a": ["c", "a"], "b": ["a", "b", "c"]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for a reverse-order subset", + ), + ExpressionTestCase( + "shuffled_int", + doc={"a": [2, 3, 1], "b": [1, 2, 3]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for a shuffled int order", + ), + ExpressionTestCase( + "shuffled_int_2", + doc={"a": [2, 1, 3], "b": [1, 2, 3]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for another shuffled int order", + ), +] + +# Property [Directionality]: subset is not commutative, so swapping the operands +# can change the result while symmetric cases still agree. +SETISSUBSET_DIRECTIONALITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "subset_ab", + doc={"a": [1, 2], "b": [1, 2, 3]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true when the first array is a subset of the second", + ), + ExpressionTestCase( + "subset_ba", + doc={"a": [1, 2, 3], "b": [1, 2]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false when the operands of a subset pair are swapped", + ), + ExpressionTestCase( + "partial_overlap_ab", + doc={"a": [1, 2, 3], "b": [2, 3, 4]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for a partial overlap in one direction", + ), + ExpressionTestCase( + "partial_overlap_ba", + doc={"a": [2, 3, 4], "b": [1, 2, 3]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for a partial overlap in the other direction", + ), + ExpressionTestCase( + "identical_ab", + doc={"a": [1, 2], "b": [1, 2]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for identical sets in either direction", + ), + ExpressionTestCase( + "disjoint_ab", + doc={"a": [1, 2], "b": [3, 4]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for disjoint sets in one direction", + ), + ExpressionTestCase( + "disjoint_ba", + doc={"a": [3, 4], "b": [1, 2]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for disjoint sets in the other direction", + ), +] + +# Property [Single-Element Arrays]: single-element operands compare by their one +# element. +SETISSUBSET_SINGLE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "single_match", + doc={"a": [1], "b": [1]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for a single matching element", + ), + ExpressionTestCase( + "single_no_match", + doc={"a": [1], "b": [2]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for a single non-matching element", + ), + ExpressionTestCase( + "single_in_larger", + doc={"a": [1], "b": [1, 2, 3, 4, 5]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for a single element in a larger set", + ), + ExpressionTestCase( + "identical_strings", + doc={"a": ["a", "b", "c"], "b": ["a", "b", "c"]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for identical string arrays", + ), + ExpressionTestCase( + "identical_diff_order", + doc={"a": ["c", "b", "a"], "b": ["a", "b", "c"]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for identical arrays in a different order", + ), + ExpressionTestCase( + "identical_with_dupes", + doc={"a": ["a", "a", "b"], "b": ["b", "b", "a"]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for identical arrays with duplicates", + ), +] + +# Property [Atomic Nesting]: nested array elements are compared as whole values +# and are not descended into. +SETISSUBSET_NESTED_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_match", + doc={"a": [[1, 2]], "b": [[1, 2], [3, 4]]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for a nested array element match", + ), + ExpressionTestCase( + "nested_no_match", + doc={"a": [[1, 2]], "b": [1, 2]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for a nested array not in a flat array", + ), + ExpressionTestCase( + "flat_not_in_nested", + doc={"a": [1, 2], "b": [[1, 2]]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for flat elements not in a nested array", + ), + ExpressionTestCase( + "deep_nested", + doc={"a": [[[1]]], "b": [[[1]], [[2]]]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for a deeply nested match", + ), + ExpressionTestCase( + "depth_mismatch", + doc={"a": [[[1]]], "b": [[1]]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for a nesting depth mismatch", + ), + ExpressionTestCase( + "depth_mismatch_2", + doc={"a": [[1]], "b": [1]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for a nested element not matching a scalar", + ), + ExpressionTestCase( + "nested_order_matters", + doc={"a": [[1, 2]], "b": [[2, 1]]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false when the inner array order differs", + ), + ExpressionTestCase( + "multi_level_scalar", + doc={"a": ["b", "a"], "b": ["a", "b", ["a"], [["b"]], [[["c"]]]]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for scalar elements in a multi-level array", + ), + ExpressionTestCase( + "multi_level_nested", + doc={"a": ["b", ["a"]], "b": ["a", "b", ["a"], [["b"]], [[["c"]]]]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for a nested array element in a multi-level array", + ), + ExpressionTestCase( + "multi_level_deep", + doc={"a": [[["c"]], [["b"]], ["a"]], "b": ["a", "b", ["a"], [["b"]], [[["c"]]]]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for elements at the wrong nesting depth", + ), + ExpressionTestCase( + "multi_level_wrong_depth", + doc={"a": [[["b"]], [["a"]], ["c"]], "b": ["a", "b", ["a"], [["b"]], [[["c"]]]]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for elements at the wrong nesting levels", + ), +] + +# Property [Large Arrays]: subset semantics scale to large operands and still +# deduplicate. +SETISSUBSET_LARGE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "large_subset", + doc={"a": list(range(50)), "b": list(range(100))}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for the first 50 elements as a subset of 100", + ), + ExpressionTestCase( + "large_one_missing", + doc={"a": list(range(100)), "b": list(range(99))}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false when one element is missing from the second array", + ), + ExpressionTestCase( + "large_identical", + doc={"a": list(range(1000)), "b": list(range(1000))}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for large identical arrays", + ), + ExpressionTestCase( + "large_scale", + doc={"a": list(range(500)), "b": list(range(1000))}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for 500 elements as a subset of 1000", + ), + ExpressionTestCase( + "heavy_duplication", + doc={"a": [1] * 10_000, "b": [1]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for 10000 duplicates reduced to a single element", + ), + ExpressionTestCase( + "scale_10k", + doc={"a": list(range(5000)), "b": list(range(10_000))}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for 5K elements as a subset of 10K", + ), + ExpressionTestCase( + "heavy_duplication_both", + doc={"a": [1] * 5000 + [2] * 5000, "b": [1, 2, 3]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for heavy duplication on both sides", + ), +] + +SETISSUBSET_CORE_TESTS: list[ExpressionTestCase] = ( + SETISSUBSET_BASIC_TESTS + + SETISSUBSET_EMPTY_TESTS + + SETISSUBSET_DUPLICATE_TESTS + + SETISSUBSET_ORDER_TESTS + + SETISSUBSET_DIRECTIONALITY_TESTS + + SETISSUBSET_SINGLE_TESTS + + SETISSUBSET_NESTED_TESTS + + SETISSUBSET_LARGE_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(SETISSUBSET_CORE_TESTS)) +def test_setIsSubset_core(collection, test): + """Test $setIsSubset core subset logic with field-reference array operands.""" + 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/set/setIsSubset/test_setIsSubset_data_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setIsSubset/test_setIsSubset_data_types.py new file mode 100644 index 000000000..4e3322496 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setIsSubset/test_setIsSubset_data_types.py @@ -0,0 +1,765 @@ +"""Tests for $setIsSubset element type handling, numeric equivalence, and special values.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, 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_TRAILING_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT64_ZERO, +) + +# Property [Element Type Coverage]: arrays of any single BSON element type +# resolve membership by element value. +SETISSUBSET_ELEMENT_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "strings", + doc={"a": ["a", "b"], "b": ["a", "b", "c"]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should resolve membership for string elements", + ), + ExpressionTestCase( + "ints", + doc={"a": [1, 2, 3], "b": [1, 2, 3, 4]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should resolve membership for int elements", + ), + ExpressionTestCase( + "longs", + doc={"a": [Int64(1), Int64(2)], "b": [Int64(1), Int64(2), Int64(3)]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should resolve membership for long elements", + ), + ExpressionTestCase( + "doubles", + doc={"a": [1.1, 2.2], "b": [1.1, 2.2, 3.3]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should resolve membership for double elements", + ), + ExpressionTestCase( + "decimals", + doc={ + "a": [Decimal128("1"), Decimal128("2")], + "b": [Decimal128("1"), Decimal128("2"), Decimal128("3")], + }, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should resolve membership for decimal128 elements", + ), + ExpressionTestCase( + "booleans", + doc={"a": [True], "b": [True, False]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should resolve membership for boolean elements", + ), + ExpressionTestCase( + "dates", + doc={ + "a": [datetime(2024, 1, 1, tzinfo=timezone.utc)], + "b": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 1, 2, tzinfo=timezone.utc), + ], + }, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should resolve membership for date elements", + ), + ExpressionTestCase( + "nulls", + doc={"a": [None], "b": [None, 1]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should resolve membership for null elements", + ), + ExpressionTestCase( + "objects", + doc={"a": [{"a": 1}], "b": [{"a": 1}, {"b": 2}]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should resolve membership for object elements", + ), + ExpressionTestCase( + "objectids", + doc={ + "a": [ObjectId("aaaaaaaaaaaaaaaaaaaaaaaa")], + "b": [ObjectId("aaaaaaaaaaaaaaaaaaaaaaaa"), ObjectId("bbbbbbbbbbbbbbbbbbbbbbbb")], + }, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should resolve membership for objectid elements", + ), + ExpressionTestCase( + "regex", + doc={"a": [Regex("abc", "")], "b": [Regex("abc", ""), Regex("def", "")]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should resolve membership for regex elements", + ), + ExpressionTestCase( + "binary", + doc={ + "a": [Binary(b"\x01\x02\x03", 0)], + "b": [Binary(b"\x01\x02\x03", 0), Binary(b"\x04\x05\x06", 0)], + }, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should resolve membership for binary elements", + ), + ExpressionTestCase( + "timestamps", + doc={"a": [Timestamp(1, 1)], "b": [Timestamp(1, 1), Timestamp(2, 2)]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should resolve membership for timestamp elements", + ), + ExpressionTestCase( + "minkey", + doc={"a": [MinKey()], "b": [MinKey(), 1]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should resolve membership for minkey elements", + ), + ExpressionTestCase( + "maxkey", + doc={"a": [MaxKey()], "b": [MaxKey(), 1]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should resolve membership for maxkey elements", + ), + ExpressionTestCase( + "javascript", + doc={"a": [Code("function(){}")], "b": [Code("function(){}"), Code("other()")]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should resolve membership for javascript code elements", + ), + ExpressionTestCase( + "nested_arrays", + doc={"a": [["x", "y"]], "b": [["x", "y"], ["z"]]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should resolve membership for nested array elements", + ), +] + +# Property [Mixed Types]: an operand may hold elements of different BSON types +# and membership is resolved per element. +SETISSUBSET_MIXED_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "mixed_subset", + doc={"a": [1, "a", True], "b": [1, "a", True, None]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for a mixed-type subset", + ), + ExpressionTestCase( + "mixed_not_subset", + doc={"a": [1, "a", True, None], "b": [1, "a", True]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for a mixed-type non-subset", + ), + ExpressionTestCase( + "mixed_with_null_bool_obj", + doc={"a": ["string", None, 2], "b": ["string", None, 2, False, {}]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for a mix with null, bool, and object", + ), + ExpressionTestCase( + "int_vs_string_no_overlap", + doc={"a": [1, 2, 3], "b": ["x", "y", "z"]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for different types with no overlap", + ), + ExpressionTestCase( + "object_and_numeric", + doc={"a": [{"a": 2}, Int64(1)], "b": [1, {"a": 2}, 1]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for an object and cross-type numeric mix", + ), + ExpressionTestCase( + "bool_null_timestamp", + doc={"a": [True, False, None], "b": [None, False, True, Timestamp(1980, 0)]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for a bool, null, and timestamp mix", + ), + ExpressionTestCase( + "all_numeric_types", + doc={"a": [1.0, Int64(2), 3], "b": [1, 2, 3]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true when all numeric types are treated as equivalent", + ), +] + +# Property [Numeric Equivalence]: numeric elements compare by value across int, +# long, double, and decimal128 types. +SETISSUBSET_NUMERIC_EQUIV_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int_long", + doc={"a": [1], "b": [Int64(1)]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should treat an int as equal to a long", + ), + ExpressionTestCase( + "int_double", + doc={"a": [1], "b": [1.0]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should treat an int as equal to a double", + ), + ExpressionTestCase( + "int_decimal", + doc={"a": [1], "b": [Decimal128("1")]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should treat an int as equal to a decimal128", + ), + ExpressionTestCase( + "long_double", + doc={"a": [Int64(1)], "b": [1.0]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should treat a long as equal to a double", + ), + ExpressionTestCase( + "long_decimal", + doc={"a": [Int64(1)], "b": [Decimal128("1")]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should treat a long as equal to a decimal128", + ), + ExpressionTestCase( + "double_decimal", + doc={"a": [1.0], "b": [Decimal128("1")]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should treat a double as equal to a decimal128", + ), + ExpressionTestCase( + "all_four_types", + doc={ + "a": [1, Int64(2), 3.0, Decimal128("4")], + "b": [Decimal128("1"), 2.0, Int64(3), 4], + }, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should cross-match across all four numeric types", + ), + ExpressionTestCase( + "zero_equiv", + doc={"a": [0], "b": [INT64_ZERO]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should treat int zero as equal to long zero", + ), + ExpressionTestCase( + "int_vs_double_not_equal", + doc={"a": [1], "b": [1.1]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for an int not equal to a fractional double", + ), + ExpressionTestCase( + "int32_max_cross", + doc={"a": [INT32_MAX], "b": [Int64(INT32_MAX)]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should treat int32 max as equal to the long of the same value", + ), + ExpressionTestCase( + "cross_type_nested", + doc={"a": [["a", Decimal128("2"), Decimal128("4"), 6]], "b": [["a", 2, 4, 6]]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should cross-match numeric types inside a nested array element", + ), +] + +# Property [Type Distinction]: elements of distinct BSON types are not equal even +# when they look similar. +SETISSUBSET_BSON_DISTINCTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "false_vs_zero", + doc={"a": [False], "b": [0]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for false versus int zero", + ), + ExpressionTestCase( + "true_vs_one", + doc={"a": [True], "b": [1]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for true versus int one", + ), + ExpressionTestCase( + "empty_string_vs_null", + doc={"a": [""], "b": [None]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for an empty string versus null", + ), + ExpressionTestCase( + "date_vs_numeric", + doc={"a": [datetime(1970, 1, 1, 0, 0, 0, 1000, tzinfo=timezone.utc)], "b": [1]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for a date versus its epoch-millis numeric value", + ), +] + +# Property [NaN Handling]: NaN is equal to NaN in set-membership context and is +# distinct from ordinary numbers, across double and decimal128. +SETISSUBSET_NAN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nan_both", + doc={"a": [FLOAT_NAN], "b": [FLOAT_NAN]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should treat NaN as equal to NaN in set context", + ), + ExpressionTestCase( + "decimal_nan_vs_float_nan", + doc={"a": [DECIMAL128_NAN], "b": [FLOAT_NAN]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should treat decimal128 NaN as equal to double NaN", + ), + ExpressionTestCase( + "float_nan_vs_decimal_nan", + doc={"a": [FLOAT_NAN], "b": [DECIMAL128_NAN]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should treat double NaN as equal to decimal128 NaN", + ), + ExpressionTestCase( + "nan_not_in_numbers", + doc={"a": [FLOAT_NAN], "b": [1, 2, 3]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for NaN not present in a number array", + ), + ExpressionTestCase( + "nan_with_other_specials", + doc={ + "a": [None, DECIMAL128_NAN], + "b": [None, FLOAT_NAN, FLOAT_INFINITY, MinKey(), MaxKey()], + }, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should cross-match NaN inside a mixed special-value array", + ), +] + +# Property [Infinity Handling]: infinities compare by value and sign across double +# and decimal128. +SETISSUBSET_INFINITY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "inf_subset", + doc={"a": [FLOAT_INFINITY], "b": [FLOAT_INFINITY, 1]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for infinity present in the superset", + ), + ExpressionTestCase( + "neg_inf_subset", + doc={"a": [FLOAT_NEGATIVE_INFINITY], "b": [FLOAT_NEGATIVE_INFINITY, 1]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for negative infinity present in the superset", + ), + ExpressionTestCase( + "decimal_inf_vs_float_inf", + doc={"a": [DECIMAL128_INFINITY], "b": [FLOAT_INFINITY]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should treat decimal128 infinity as equal to double infinity", + ), + ExpressionTestCase( + "decimal_neg_inf_vs_float_neg_inf", + doc={"a": [DECIMAL128_NEGATIVE_INFINITY], "b": [FLOAT_NEGATIVE_INFINITY]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should treat decimal128 negative infinity as double negative infinity", + ), + ExpressionTestCase( + "inf_not_neg_inf", + doc={"a": [FLOAT_INFINITY], "b": [FLOAT_NEGATIVE_INFINITY]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for infinity not equal to negative infinity", + ), + ExpressionTestCase( + "float_inf_cross_decimal", + doc={ + "a": [None, FLOAT_NAN, FLOAT_INFINITY], + "b": [None, DECIMAL128_NAN, DECIMAL128_INFINITY, MinKey(), MaxKey()], + }, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should cross-match infinity and NaN across types", + ), +] + +# Property [Negative Zero]: negative zero equals positive zero across double, +# decimal128, and int. +SETISSUBSET_NEG_ZERO_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "double_neg_zero_vs_zero", + doc={"a": [DOUBLE_NEGATIVE_ZERO], "b": [DOUBLE_ZERO]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should treat double negative zero as equal to double zero", + ), + ExpressionTestCase( + "double_neg_zero_vs_int_zero", + doc={"a": [DOUBLE_NEGATIVE_ZERO], "b": [0]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should treat double negative zero as equal to int zero", + ), + ExpressionTestCase( + "decimal_neg_zero_vs_zero", + doc={"a": [DECIMAL128_NEGATIVE_ZERO], "b": [DECIMAL128_ZERO]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should treat decimal128 negative zero as equal to decimal128 zero", + ), + ExpressionTestCase( + "decimal_neg_zero_vs_int_zero", + doc={"a": [DECIMAL128_NEGATIVE_ZERO], "b": [0]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should treat decimal128 negative zero as equal to int zero", + ), +] + +# Property [Object Elements]: object elements compare by exact field order and +# value, without descending for set semantics. +SETISSUBSET_OBJECT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "obj_diff_field_order", + doc={"a": [{"a": 1, "b": 2}], "b": [{"b": 2, "a": 1}]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false when object field order differs", + ), + ExpressionTestCase( + "obj_extra_fields", + doc={"a": [{"a": 1}], "b": [{"a": 1, "b": 2}]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for an object with extra fields", + ), + ExpressionTestCase( + "nested_obj", + doc={"a": [{"a": {"b": 1}}], "b": [{"a": {"b": 1}}, {"a": {"b": 2}}]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for a nested object match", + ), + ExpressionTestCase( + "empty_obj_match", + doc={"a": [{}], "b": [{}, {"a": 1}]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for an empty object match", + ), + ExpressionTestCase( + "empty_obj_no_match", + doc={"a": [{}], "b": [{"a": 1}]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for an empty object not in a non-empty set", + ), +] + +# Property [String Elements]: string elements compare exactly, including empty, +# case, unicode, and control characters. +SETISSUBSET_STRING_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "empty_string", + doc={"a": [""], "b": ["", "a"]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for an empty-string element", + ), + ExpressionTestCase( + "case_sensitive", + doc={"a": ["A"], "b": ["a"]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for a case-sensitive mismatch", + ), + ExpressionTestCase( + "unicode", + doc={"a": ["café"], "b": ["café", "naïve"]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for unicode string elements", + ), + ExpressionTestCase( + "special_chars", + doc={"a": ["a\nb"], "b": ["a\nb", "c"]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for strings with special characters", + ), +] + +# Property [Null Elements]: a null element is an ordinary value subject to set +# membership. +SETISSUBSET_NULL_ELEM_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_in_subset", + doc={"a": [None], "b": [None, 1, 2]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for a null element present in the superset", + ), + ExpressionTestCase( + "null_not_in_superset", + doc={"a": [None], "b": [1, 2, 3]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for a null element not in the superset", + ), + ExpressionTestCase( + "null_in_both", + doc={"a": [None, 1], "b": [None, 1, 2]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for a null element present in both arrays", + ), + ExpressionTestCase( + "only_nulls", + doc={"a": [None, None], "b": [None]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for an array of only nulls after dedup", + ), +] + +# Property [Boolean Elements]: boolean membership respects subset direction. +SETISSUBSET_BOOL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "bool_not_subset", + doc={"a": [True, False], "b": [True]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for a boolean non-subset", + ), +] + +# Property [Regex Elements]: regex elements compare by both pattern and flags. +SETISSUBSET_REGEX_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "same_regex", + doc={"a": [Regex("^abc", "i")], "b": [Regex("^abc", "i"), Regex("def", "")]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for a matching regex element", + ), + ExpressionTestCase( + "diff_flags", + doc={"a": [Regex("abc", "")], "b": [Regex("abc", "i")]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for different regex flags", + ), + ExpressionTestCase( + "diff_pattern", + doc={"a": [Regex("abc", "")], "b": [Regex("def", "")]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for different regex patterns", + ), +] + +# Property [BinData Elements]: binary elements compare by both bytes and subtype. +SETISSUBSET_BINDATA_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "diff_bindata", + doc={"a": [Binary(b"\x01\x02\x03", 0)], "b": [Binary(b"\x04\x05\x06", 0)]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for different binary data", + ), + ExpressionTestCase( + "diff_subtype", + doc={"a": [Binary(b"\x01\x02\x03", 0)], "b": [Binary(b"\x01\x02\x03", 4)]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for the same bytes with a different subtype", + ), +] + +# Property [Date And Timestamp Elements]: dates and timestamps compare by their +# full value. +SETISSUBSET_DATE_TIMESTAMP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "same_date", + doc={ + "a": [datetime(2024, 1, 1, tzinfo=timezone.utc)], + "b": [datetime(2024, 1, 1, tzinfo=timezone.utc)], + }, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for a matching date", + ), + ExpressionTestCase( + "diff_date", + doc={ + "a": [datetime(2024, 1, 1, tzinfo=timezone.utc)], + "b": [datetime(2024, 1, 2, tzinfo=timezone.utc)], + }, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for different dates", + ), + ExpressionTestCase( + "same_timestamp", + doc={"a": [Timestamp(1000, 1)], "b": [Timestamp(1000, 1), Timestamp(2000, 1)]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for a matching timestamp", + ), + ExpressionTestCase( + "diff_timestamp", + doc={"a": [Timestamp(1000, 1)], "b": [Timestamp(1000, 2)]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for different timestamps", + ), +] + +# Property [MinKey And MaxKey Elements]: the boundary keys compare as their own +# distinct values. +SETISSUBSET_MINMAX_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "minkey_not_maxkey", + doc={"a": [MinKey()], "b": [MaxKey()]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for minkey not equal to maxkey", + ), +] + +# Property [ObjectId Elements]: objectid elements compare by their full value. +SETISSUBSET_OBJECTID_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "diff_oid", + doc={ + "a": [ObjectId("aaaaaaaaaaaaaaaaaaaaaaaa")], + "b": [ObjectId("bbbbbbbbbbbbbbbbbbbbbbbb")], + }, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for different objectids", + ), +] + +# Property [Decimal128 Precision]: decimal128 elements compare by value with +# trailing-zero and exponent normalization. +SETISSUBSET_DECIMAL_PRECISION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal_vs_int", + doc={"a": [Decimal128("1")], "b": [1]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should treat decimal128 one as equal to int one", + ), + ExpressionTestCase( + "trailing_zeros", + doc={"a": [DECIMAL128_TRAILING_ZERO], "b": [Decimal128("1.00")]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should treat decimal128 trailing zeros as equivalent", + ), + ExpressionTestCase( + "exponent_notation", + doc={"a": [Decimal128("1E+2")], "b": [Decimal128("100")]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should treat decimal128 exponent notation as equivalent", + ), + ExpressionTestCase( + "high_precision_match", + doc={ + "a": [Decimal128("1.000000000000000000000000000000001")], + "b": [Decimal128("1.000000000000000000000000000000001")], + }, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should return true for a high-precision decimal128 match", + ), + ExpressionTestCase( + "high_precision_mismatch", + doc={ + "a": [Decimal128("1.000000000000000000000000000000001")], + "b": [Decimal128("1.000000000000000000000000000000002")], + }, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for a high-precision decimal128 mismatch", + ), +] + +SETISSUBSET_TYPE_TESTS: list[ExpressionTestCase] = ( + SETISSUBSET_ELEMENT_TYPE_TESTS + + SETISSUBSET_MIXED_TYPE_TESTS + + SETISSUBSET_NUMERIC_EQUIV_TESTS + + SETISSUBSET_BSON_DISTINCTION_TESTS + + SETISSUBSET_NAN_TESTS + + SETISSUBSET_INFINITY_TESTS + + SETISSUBSET_NEG_ZERO_TESTS + + SETISSUBSET_OBJECT_TESTS + + SETISSUBSET_STRING_TESTS + + SETISSUBSET_NULL_ELEM_TESTS + + SETISSUBSET_BOOL_TESTS + + SETISSUBSET_REGEX_TESTS + + SETISSUBSET_BINDATA_TESTS + + SETISSUBSET_DATE_TIMESTAMP_TESTS + + SETISSUBSET_MINMAX_TESTS + + SETISSUBSET_OBJECTID_TESTS + + SETISSUBSET_DECIMAL_PRECISION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(SETISSUBSET_TYPE_TESTS)) +def test_setIsSubset_types(collection, test): + """Test $setIsSubset element type handling, numeric equivalence, and special values.""" + 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/set/setIsSubset/test_setIsSubset_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setIsSubset/test_setIsSubset_expressions.py new file mode 100644 index 000000000..5adfb785d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setIsSubset/test_setIsSubset_expressions.py @@ -0,0 +1,133 @@ +"""Tests for $setIsSubset operand input shapes, inline and resolved via paths and variables.""" + +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 [Inline Operand Input]: literal arrays and expression-operator operands +# evaluate inline without a field reference. +SETISSUBSET_INLINE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "literal", + expression={"$setIsSubset": [["a", "b"], ["a", "b", "c"]]}, + expected=True, + msg="$setIsSubset should compare literal array inputs", + ), + ExpressionTestCase( + "literal_expression_operator", + expression={"$setIsSubset": [{"$literal": [1, 2]}, {"$literal": [1, 2, 3]}]}, + expected=True, + msg="$setIsSubset should accept a $literal expression result as an operand", + ), + ExpressionTestCase( + "nested_set_operator", + expression={"$setIsSubset": [{"$setUnion": [[1, 2], [3]]}, [1, 2, 3]]}, + expected=True, + msg="$setIsSubset should accept a nested set-operator result as an operand", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SETISSUBSET_INLINE_TESTS)) +def test_setIsSubset_expression_inline(collection, test): + """Test $setIsSubset with inline literal and expression-operator operands.""" + result = execute_expression(collection, test.expression) + assert_expression_result(result, expected=test.expected, msg=test.msg) + + +# Property [Resolved Input Shapes]: operands reached through a shorthand field +# reference, a nested-object path, an array-of-objects path, a numeric-index path, +# an operand array of field references, or a system variable resolve and feed the +# operator. +SETISSUBSET_INPUT_SHAPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "field_ref_subset", + doc={"a": [10, 20], "b": [10, 20, 30]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=True, + msg="$setIsSubset should resolve shorthand field references for both operands", + ), + ExpressionTestCase( + "field_ref_not_subset", + doc={"a": [10, 20], "b": [20, 30]}, + expression={"$setIsSubset": ["$a", "$b"]}, + expected=False, + msg="$setIsSubset should return false for unequal shorthand field references", + ), + ExpressionTestCase( + "nested_object_field", + doc={"a": {"b": [1, 2]}, "c": {"d": [1, 2, 3]}}, + expression={"$setIsSubset": ["$a.b", "$c.d"]}, + expected=True, + msg="$setIsSubset should resolve nested object field paths for both operands", + ), + ExpressionTestCase( + "composite_array_field", + doc={"a": [{"b": 1}, {"b": 2}]}, + expression={"$setIsSubset": ["$a.b", [1, 2, 3]]}, + expected=True, + msg="$setIsSubset should resolve a composite array field path through an array of objects", + ), + # In an aggregation field path a numeric component is a field name, not an + # array index, so it resolves to an empty array over the array of arrays. + # An empty first operand is a subset of any set. + ExpressionTestCase( + "array_index_path_first", + doc={"a": [[1, 2], [3]]}, + expression={"$setIsSubset": ["$a.0", [1, 2]]}, + expected=True, + msg="$setIsSubset should treat a numeric path component as an empty first operand (true)", + ), + # The same empty resolution as a non-empty second operand's superset makes a + # non-empty first operand fail the subset check. + ExpressionTestCase( + "array_index_path_second", + doc={"a": [[1, 2], [3]]}, + expression={"$setIsSubset": [[1, 2], "$a.0"]}, + expected=False, + msg="$setIsSubset should treat a numeric path component as an empty second operand (false)", + ), + ExpressionTestCase( + "operand_array_of_field_refs", + doc={"x": 1, "y": 2}, + expression={"$setIsSubset": [["$x", "$y"], [1, 2, 3]]}, + expected=True, + msg="$setIsSubset should resolve an operand array whose elements are field references", + ), + ExpressionTestCase( + "deeply_nested_field", + doc={"a": {"b": {"c": [1, 2]}}, "d": {"e": {"f": [1, 2, 3]}}}, + expression={"$setIsSubset": ["$a.b.c", "$d.e.f"]}, + expected=True, + msg="$setIsSubset should resolve deeply nested field paths for both operands", + ), + ExpressionTestCase( + "root_variable", + doc={"arr": [1, 2]}, + expression={"$setIsSubset": ["$$ROOT.arr", [1, 2, 3]]}, + expected=True, + msg="$setIsSubset should resolve the ROOT system variable for an array field", + ), + ExpressionTestCase( + "current_variable", + doc={"arr": [1, 2]}, + expression={"$setIsSubset": ["$$CURRENT.arr", [1, 2, 3]]}, + expected=True, + msg="$setIsSubset should resolve the CURRENT system variable for an array field", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SETISSUBSET_INPUT_SHAPE_TESTS)) +def test_setIsSubset_input_shapes(collection, test): + """Test $setIsSubset with field-reference, nested, composite, index, and variable operands.""" + 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/set/setIsSubset/test_setIsSubset_invalid.py b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setIsSubset/test_setIsSubset_invalid.py new file mode 100644 index 000000000..6f090f371 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/set/setIsSubset/test_setIsSubset_invalid.py @@ -0,0 +1,270 @@ +"""Tests for $setIsSubset argument-count errors, null operands, and non-array operand errors.""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, 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 ( + EXPRESSION_TYPE_MISMATCH_ERROR, + SET_IS_SUBSET_FIRST_NOT_ARRAY_ERROR, + SET_IS_SUBSET_SECOND_NOT_ARRAY_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + FLOAT_INFINITY, + FLOAT_NAN, + MISSING, +) + +# Property [Argument Count]: $setIsSubset takes exactly two operands, so any other +# operand-list arity or a non-array operand list errors. +SETISSUBSET_WRONG_ARG_COUNT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_args", + expression={"$setIsSubset": []}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$setIsSubset should return error for zero arguments", + ), + ExpressionTestCase( + "one_arg", + expression={"$setIsSubset": [[1, 2]]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$setIsSubset should return error for a single argument", + ), + ExpressionTestCase( + "three_args", + expression={"$setIsSubset": [[1], [2], [3]]}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$setIsSubset should return error for three arguments", + ), + ExpressionTestCase( + "non_array_wrapper", + expression={"$setIsSubset": "not_an_array"}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$setIsSubset should return error for a string operand list", + ), + ExpressionTestCase( + "scalar_wrapper", + expression={"$setIsSubset": 1}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$setIsSubset should return error for a scalar operand list", + ), + ExpressionTestCase( + "object_operand", + expression={"$setIsSubset": {"a": [1, 2]}}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$setIsSubset should return error for an object operand list", + ), + ExpressionTestCase( + "null_wrapper", + expression={"$setIsSubset": None}, + error_code=EXPRESSION_TYPE_MISMATCH_ERROR, + msg="$setIsSubset should return error for a null operand list", + ), +] + +# Property [Null Operand]: a null operand is not an array and errors by position, +# reporting the first operand's code when both are null. +SETISSUBSET_NULL_OPERAND_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_first", + expression={"$setIsSubset": [None, [1, 2]]}, + error_code=SET_IS_SUBSET_FIRST_NOT_ARRAY_ERROR, + msg="$setIsSubset should return error for a null first operand", + ), + ExpressionTestCase( + "null_second", + expression={"$setIsSubset": [[1, 2], None]}, + error_code=SET_IS_SUBSET_SECOND_NOT_ARRAY_ERROR, + msg="$setIsSubset should return error for a null second operand", + ), + ExpressionTestCase( + "null_both", + expression={"$setIsSubset": [None, None]}, + error_code=SET_IS_SUBSET_FIRST_NOT_ARRAY_ERROR, + msg="$setIsSubset should report the first operand error when both operands are null", + ), + ExpressionTestCase( + "null_first_empty_second", + expression={"$setIsSubset": [None, []]}, + error_code=SET_IS_SUBSET_FIRST_NOT_ARRAY_ERROR, + msg="$setIsSubset should return error for a null first operand with an empty second", + ), + ExpressionTestCase( + "null_second_empty_first", + expression={"$setIsSubset": [[], None]}, + error_code=SET_IS_SUBSET_SECOND_NOT_ARRAY_ERROR, + msg="$setIsSubset should return error for a null second operand with an empty first", + ), +] + +# Property [Non-Array First Operand]: every non-array BSON type in the first +# operand position is rejected. +SETISSUBSET_NON_ARRAY_FIRST_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"{tid}_first", + expression={"$setIsSubset": [val, [1, 2]]}, + error_code=SET_IS_SUBSET_FIRST_NOT_ARRAY_ERROR, + msg=f"$setIsSubset should return error for {tid} as the first operand", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("string", "hello"), + ("bool", True), + ("object", {"a": 1}), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("aaaaaaaaaaaaaaaaaaaaaaaa")), + ("regex", Regex("abc", "")), + ("javascript", Code("function(){}")), + ("binary", Binary(b"\x01", 0)), + ("timestamp", Timestamp(1, 1)), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("nan", FLOAT_NAN), + ("infinity", FLOAT_INFINITY), + ("decimal_nan", DECIMAL128_NAN), + ("decimal_infinity", DECIMAL128_INFINITY), + ("decimal_neg_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + +# Property [Non-Array Second Operand]: every non-array BSON type in the second +# operand position is rejected. +SETISSUBSET_NON_ARRAY_SECOND_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + f"{tid}_second", + expression={"$setIsSubset": [[1, 2], val]}, + error_code=SET_IS_SUBSET_SECOND_NOT_ARRAY_ERROR, + msg=f"$setIsSubset should return error for {tid} as the second operand", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("string", "hello"), + ("bool", True), + ("object", {"a": 1}), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("objectid", ObjectId("aaaaaaaaaaaaaaaaaaaaaaaa")), + ("regex", Regex("abc", "")), + ("javascript", Code("function(){}")), + ("binary", Binary(b"\x01", 0)), + ("timestamp", Timestamp(1, 1)), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("nan", FLOAT_NAN), + ("infinity", FLOAT_INFINITY), + ("decimal_nan", DECIMAL128_NAN), + ("decimal_infinity", DECIMAL128_INFINITY), + ("decimal_neg_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + +# Property [Both Operands Non-Array]: when both operands are non-array the first +# operand's error is reported. +SETISSUBSET_BOTH_NON_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "both_non_array", + expression={"$setIsSubset": ["hello", 123]}, + error_code=SET_IS_SUBSET_FIRST_NOT_ARRAY_ERROR, + msg="$setIsSubset should report the first operand error when both operands are non-array", + ), +] + +# Property [Missing Field]: a field path to an absent field resolves to missing +# and errors by position, reporting the first operand's code when both are missing. +SETISSUBSET_MISSING_FIELD_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_first", + doc={"b": [1, 2]}, + expression={"$setIsSubset": [MISSING, "$b"]}, + error_code=SET_IS_SUBSET_FIRST_NOT_ARRAY_ERROR, + msg="$setIsSubset should return error when the first field is missing", + ), + ExpressionTestCase( + "missing_second", + doc={"a": [1, 2]}, + expression={"$setIsSubset": ["$a", MISSING]}, + error_code=SET_IS_SUBSET_SECOND_NOT_ARRAY_ERROR, + msg="$setIsSubset should return error when the second field is missing", + ), + ExpressionTestCase( + "missing_both", + doc={"z": 1}, + expression={"$setIsSubset": ["$x", "$y"]}, + error_code=SET_IS_SUBSET_FIRST_NOT_ARRAY_ERROR, + msg="$setIsSubset should report the first operand error when both fields are missing", + ), +] + +# Property [Field Resolves To Non-Array]: a field path that resolves to a +# non-array value errors by position, including a field present as null. +SETISSUBSET_FIELD_NON_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "field_resolves_int_first", + doc={"a": 5}, + expression={"$setIsSubset": ["$a", [1]]}, + error_code=SET_IS_SUBSET_FIRST_NOT_ARRAY_ERROR, + msg="$setIsSubset should error when the first field resolves to int", + ), + ExpressionTestCase( + "field_resolves_string_first", + doc={"a": "hello"}, + expression={"$setIsSubset": ["$a", [1]]}, + error_code=SET_IS_SUBSET_FIRST_NOT_ARRAY_ERROR, + msg="$setIsSubset should error when the first field resolves to string", + ), + ExpressionTestCase( + "field_resolves_null_first", + doc={"a": None, "b": [1]}, + expression={"$setIsSubset": ["$a", "$b"]}, + error_code=SET_IS_SUBSET_FIRST_NOT_ARRAY_ERROR, + msg="$setIsSubset should error when the first field resolves to null", + ), + ExpressionTestCase( + "field_resolves_int_second", + doc={"b": 5}, + expression={"$setIsSubset": [[1], "$b"]}, + error_code=SET_IS_SUBSET_SECOND_NOT_ARRAY_ERROR, + msg="$setIsSubset should error when the second field resolves to int", + ), + ExpressionTestCase( + "field_resolves_null_second", + doc={"a": [1], "b": None}, + expression={"$setIsSubset": ["$a", "$b"]}, + error_code=SET_IS_SUBSET_SECOND_NOT_ARRAY_ERROR, + msg="$setIsSubset should error when the second field resolves to null", + ), +] + +SETISSUBSET_INVALID_TESTS: list[ExpressionTestCase] = ( + SETISSUBSET_WRONG_ARG_COUNT_TESTS + + SETISSUBSET_NULL_OPERAND_TESTS + + SETISSUBSET_NON_ARRAY_FIRST_TESTS + + SETISSUBSET_NON_ARRAY_SECOND_TESTS + + SETISSUBSET_BOTH_NON_ARRAY_TESTS + + SETISSUBSET_MISSING_FIELD_TESTS + + SETISSUBSET_FIELD_NON_ARRAY_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(SETISSUBSET_INVALID_TESTS)) +def test_setIsSubset_invalid(collection, test): + """Test $setIsSubset argument-count, null, missing-field, and non-array errors.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result(result, error_code=test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/set/test_set_operator_combinations.py b/documentdb_tests/compatibility/tests/core/operator/expressions/set/test_set_operator_combinations.py new file mode 100644 index 000000000..4ed61b94c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/set/test_set_operator_combinations.py @@ -0,0 +1,133 @@ +"""Cross-operator combination tests exercising multiple distinct set operators together.""" + +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 + +# Property [setDifference Composition]: a $setDifference result feeds the other +# set operators. +SETDIFFERENCE_CROSS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "setDifference_nested_setUnion_operand", + doc={"a": [1, 2, 3], "b": [3, 4]}, + expression={"$setDifference": [{"$setUnion": ["$a", [5]]}, "$b"]}, + expected=[1, 2, 5], + msg="$setDifference should accept a $setUnion result as an operand", + ), + ExpressionTestCase( + "setDifference_into_setUnion", + expression={"$setUnion": [{"$setDifference": [["a", "b", "c"], ["b"]]}, ["d"]]}, + expected=["a", "c", "d"], + msg="$setDifference result should compose as a $setUnion operand", + ), + ExpressionTestCase( + "setDifference_into_setIntersection", + expression={"$setIntersection": [{"$setDifference": [["a", "b", "c"], ["c"]]}, ["a", "d"]]}, + expected=["a"], + msg="$setDifference result should compose as a $setIntersection operand", + ), + ExpressionTestCase( + "setDifference_into_setEquals", + expression={"$setEquals": [{"$setDifference": [["a", "b"], ["b"]]}, ["a"]]}, + expected=True, + msg="$setDifference result should compose as a $setEquals operand", + ), + ExpressionTestCase( + "setDifference_into_setIsSubset", + expression={ + "$setIsSubset": [{"$setDifference": [["a", "b", "c"], ["c"]]}, ["a", "b", "d"]] + }, + expected=True, + msg="$setDifference result should compose as a $setIsSubset operand", + ), +] + +# Property [setIntersection Composition]: $setIntersection composes with the other +# set operators. +SETINTERSECTION_CROSS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "setIntersection_nested_setUnion_operand", + expression={"$setIntersection": [{"$setUnion": [[1, 2], [3]]}, [2, 3, 4]]}, + expected=[2, 3], + msg="$setIntersection should accept a $setUnion result as an operand", + ), + ExpressionTestCase( + "setIntersection_with_setDifference", + expression={"$setIntersection": [{"$setDifference": [[1, 2, 3], [1]]}, [2, 4]]}, + expected=[2], + msg="$setIntersection should accept a $setDifference result as an operand", + ), +] + +# Property [setEquals Composition]: $setEquals verifies the results of the other +# set operators. +SETEQUALS_CROSS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "setEquals_setUnion_operand", + expression={"$setEquals": [{"$setUnion": [[1, 2], [3]]}, [1, 2, 3]]}, + expected=True, + msg="$setEquals should accept a $setUnion result as an operand", + ), + ExpressionTestCase( + "setEquals_setIntersection_operand", + expression={"$setEquals": [{"$setIntersection": [[1, 2, 3], [2, 3, 4]]}, [2, 3]]}, + expected=True, + msg="$setEquals should accept a $setIntersection result as an operand", + ), + ExpressionTestCase( + "setEquals_setDifference_operand", + expression={"$setEquals": [{"$setDifference": [[1, 2, 3], [2]]}, [1, 3]]}, + expected=True, + msg="$setEquals should accept a $setDifference result as an operand", + ), + ExpressionTestCase( + "setEquals_chained_setUnion", + expression={"$setEquals": [{"$setUnion": [[1, 2], [3]]}, {"$setUnion": [[3], [1, 2]]}]}, + expected=True, + msg="$setEquals should compare two $setUnion results as commutative operands", + ), +] + +# Property [setIsSubset Composition]: $setIsSubset verifies the results of the +# other set operators. +SETISSUBSET_CROSS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "setIsSubset_of_setUnion", + expression={"$setIsSubset": [[1, 2, 3], {"$setUnion": [[1, 2, 3], [4, 5]]}]}, + expected=True, + msg="$setIsSubset should hold for a set as a subset of its $setUnion with another", + ), + ExpressionTestCase( + "setIsSubset_of_setIntersection", + expression={"$setIsSubset": [{"$setIntersection": [[1, 2, 3], [2, 3, 4]]}, [1, 2, 3]]}, + expected=True, + msg="$setIsSubset should hold for a $setIntersection result as a subset of an operand", + ), + ExpressionTestCase( + "setIsSubset_of_setDifference", + expression={"$setIsSubset": [{"$setDifference": [[1, 2, 3], [2, 3, 4]]}, [1, 2, 3]]}, + expected=True, + msg="$setIsSubset should hold for a $setDifference result as a subset of the minuend", + ), +] + +SET_OPERATOR_COMBINATION_TESTS: list[ExpressionTestCase] = ( + SETDIFFERENCE_CROSS_TESTS + + SETINTERSECTION_CROSS_TESTS + + SETEQUALS_CROSS_TESTS + + SETISSUBSET_CROSS_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(SET_OPERATOR_COMBINATION_TESTS)) +def test_set_operator_combinations(collection, test): + """Test compositions of two or more distinct set operators.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result(result, expected=test.expected, msg=test.msg, ignore_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_setDifference.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_setDifference.py new file mode 100644 index 000000000..1b01729a6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_setDifference.py @@ -0,0 +1,153 @@ +"""Combination tests for $setDifference composed with non-set operators and contexts.""" + +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.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Array-Producing Inputs]: operators that produce arrays can feed +# $setDifference operands. +SETDIFFERENCE_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "concatArrays_input", + expression={"$setDifference": [{"$concatArrays": [[1, 2], [3]]}, [2]]}, + expected=[1, 3], + msg="$setDifference should accept a $concatArrays result as an operand", + ), + ExpressionTestCase( + "filter_input", + doc={"a": [1, 2, 3, 4]}, + expression={ + "$setDifference": [{"$filter": {"input": "$a", "cond": {"$gt": ["$$this", 2]}}}, [3]] + }, + expected=[4], + msg="$setDifference should accept a $filter result as an operand", + ), + ExpressionTestCase( + "map_input", + doc={"a": [1, 2, 3]}, + expression={ + "$setDifference": [{"$map": {"input": "$a", "in": {"$multiply": ["$$this", 2]}}}, [4]] + }, + expected=[2, 6], + msg="$setDifference should accept a $map result as an operand", + ), + ExpressionTestCase( + "reverseArray_input", + doc={"a": [1, 2, 3]}, + expression={"$setDifference": [{"$reverseArray": "$a"}, [2]]}, + expected=[3, 1], + msg="$setDifference should accept a $reverseArray result as an operand", + ), + ExpressionTestCase( + "cond_input", + expression={"$setDifference": [{"$cond": [True, [1, 2, 3], []]}, [2]]}, + expected=[1, 3], + msg="$setDifference should accept a $cond-produced array as an operand", + ), +] + +# Property [Self Composition]: $setDifference nests inside itself. +SETDIFFERENCE_SELF_COMPOSITION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "chained", + expression={"$setDifference": [{"$setDifference": [["a", "b", "c"], ["a"]]}, ["b"]]}, + expected=["c"], + msg="$setDifference should compose with another $setDifference", + ), + ExpressionTestCase( + "self_nested", + expression={"$setDifference": [{"$setDifference": [[1, 2, 3, 4], [1, 2]]}, [3]]}, + expected=[4], + msg="$setDifference should nest inside itself", + ), + ExpressionTestCase( + "deep_self_nested", + expression={ + "$setDifference": [ + {"$setDifference": [{"$setDifference": [[1, 2, 3, 4, 5], [1]]}, [2]]}, + [3], + ] + }, + expected=[4, 5], + msg="$setDifference should nest deeply inside itself", + ), +] + +# Property [Conditional And Binding Contexts]: $setDifference works inside $cond, +# $ifNull, and $let. +SETDIFFERENCE_CONTEXT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "in_cond_then", + expression={"$cond": [True, {"$setDifference": [["a", "b"], ["a"]]}, []]}, + expected=["b"], + msg="$setDifference should evaluate in a $cond then branch", + ), + ExpressionTestCase( + "in_cond_else", + expression={"$cond": [False, [], {"$setDifference": [["a", "b"], ["a"]]}]}, + expected=["b"], + msg="$setDifference should evaluate in a $cond else branch", + ), + ExpressionTestCase( + "ifnull_with_null", + expression={"$ifNull": [{"$setDifference": [None, [1]]}, "fallback"]}, + expected="fallback", + msg="$ifNull should return the fallback when $setDifference is null", + ), + ExpressionTestCase( + "with_let", + expression={ + "$let": { + "vars": {"arr1": [1, 2, 3], "arr2": [2]}, + "in": {"$setDifference": ["$$arr1", "$$arr2"]}, + } + }, + expected=[1, 3], + msg="$setDifference should evaluate over $let-bound variables", + ), +] + +SETDIFFERENCE_COMBINATION_TESTS: list[ExpressionTestCase] = ( + SETDIFFERENCE_INPUT_TESTS + SETDIFFERENCE_SELF_COMPOSITION_TESTS + SETDIFFERENCE_CONTEXT_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(SETDIFFERENCE_COMBINATION_TESTS)) +def test_setDifference_combination(collection, test): + """Test $setDifference combined with non-set operators and contexts.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result(result, expected=test.expected, msg=test.msg) + + +def test_setDifference_in_match_expr(collection): + """Test $setDifference in a $match $expr to filter documents.""" + collection.insert_many( + [ + {"_id": 1, "a": [1, 2, 3], "b": [2, 3]}, + {"_id": 2, "a": [1, 2], "b": [1, 2]}, + ] + ) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$match": {"$expr": {"$gt": [{"$size": {"$setDifference": ["$a", "$b"]}}, 0]}}} + ], + "cursor": {}, + }, + ) + assertSuccess( + result, + [{"_id": 1, "a": [1, 2, 3], "b": [2, 3]}], + msg="Should match documents where $setDifference is non-empty", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_setEquals.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_setEquals.py new file mode 100644 index 000000000..e76963661 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_setEquals.py @@ -0,0 +1,118 @@ +"""Combination tests for $setEquals composed with non-set operators and contexts.""" + +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.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Array-Producing Inputs]: operators that produce arrays can feed +# $setEquals operands. +SETEQUALS_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "concatArrays_input", + expression={"$setEquals": [{"$concatArrays": [[1], [2]]}, [1, 2]]}, + expected=True, + msg="$setEquals should accept a $concatArrays result as an operand", + ), + ExpressionTestCase( + "filter_input", + doc={"a": [1, 2, 3, 4]}, + expression={ + "$setEquals": [{"$filter": {"input": "$a", "cond": {"$gt": ["$$this", 2]}}}, [3, 4]] + }, + expected=True, + msg="$setEquals should accept a $filter result as an operand", + ), + ExpressionTestCase( + "map_input", + doc={"a": [1, 2, 3]}, + expression={ + "$setEquals": [{"$map": {"input": "$a", "in": {"$multiply": ["$$this", 2]}}}, [2, 4, 6]] + }, + expected=True, + msg="$setEquals should accept a $map result as an operand", + ), +] + +# Property [Conditional And Binding Contexts]: $setEquals works inside $cond, +# $let, and $not. +SETEQUALS_CONTEXT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "in_cond_true", + doc={"a": [1, 2], "b": [2, 1]}, + expression={ + "$cond": {"if": {"$setEquals": ["$a", "$b"]}, "then": "match", "else": "no_match"} + }, + expected="match", + msg="$setEquals should drive a $cond then branch when the sets are equal", + ), + ExpressionTestCase( + "in_cond_false", + doc={"a": [1, 2], "b": [3, 4]}, + expression={ + "$cond": {"if": {"$setEquals": ["$a", "$b"]}, "then": "match", "else": "no_match"} + }, + expected="no_match", + msg="$setEquals should drive a $cond else branch when the sets differ", + ), + ExpressionTestCase( + "with_let", + expression={ + "$let": { + "vars": {"arr1": [1, 2, 3], "arr2": [3, 2, 1]}, + "in": {"$setEquals": ["$$arr1", "$$arr2"]}, + } + }, + expected=True, + msg="$setEquals should evaluate over $let-bound variables", + ), + ExpressionTestCase( + "in_not", + doc={"a": [1, 2], "b": [3, 4]}, + expression={"$not": [{"$setEquals": ["$a", "$b"]}]}, + expected=True, + msg="$not should negate a false $setEquals result", + ), +] + +SETEQUALS_COMBINATION_TESTS: list[ExpressionTestCase] = ( + SETEQUALS_INPUT_TESTS + SETEQUALS_CONTEXT_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(SETEQUALS_COMBINATION_TESTS)) +def test_setEquals_combination(collection, test): + """Test $setEquals combined with non-set operators and contexts.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result(result, expected=test.expected, msg=test.msg) + + +def test_setEquals_in_match_expr(collection): + """Test $setEquals in a $match $expr to filter documents.""" + collection.insert_many( + [ + {"_id": 1, "a": [1, 2], "b": [2, 1]}, + {"_id": 2, "a": [1, 2], "b": [3, 4]}, + ] + ) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [{"$match": {"$expr": {"$setEquals": ["$a", "$b"]}}}], + "cursor": {}, + }, + ) + assertSuccess( + result, + [{"_id": 1, "a": [1, 2], "b": [2, 1]}], + msg="Should match documents where $setEquals is true", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_setIntersection.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_setIntersection.py new file mode 100644 index 000000000..daedac50f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_setIntersection.py @@ -0,0 +1,133 @@ +"""Combination tests for $setIntersection composed with non-set operators and contexts.""" + +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.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Array-Producing Inputs]: operators that produce arrays can feed +# $setIntersection operands. +SETINTERSECTION_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "concatArrays_input", + expression={"$setIntersection": [{"$concatArrays": [[1, 2], [3]]}, [2, 3, 4]]}, + expected=[2, 3], + msg="$setIntersection should accept a $concatArrays result as an operand", + ), + ExpressionTestCase( + "filter_input", + doc={"a": [1, 2, 3, 4]}, + expression={ + "$setIntersection": [ + {"$filter": {"input": "$a", "cond": {"$gt": ["$$this", 1]}}}, + [2, 4], + ] + }, + expected=[2, 4], + msg="$setIntersection should accept a $filter result as an operand", + ), + ExpressionTestCase( + "map_input", + doc={"a": [1, 2, 3]}, + expression={ + "$setIntersection": [ + {"$map": {"input": "$a", "in": {"$multiply": ["$$this", 2]}}}, + [4, 6], + ] + }, + expected=[4, 6], + msg="$setIntersection should accept a $map result as an operand", + ), +] + +# Property [Self Composition]: $setIntersection nests inside itself. +SETINTERSECTION_SELF_COMPOSITION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "self_nested", + expression={"$setIntersection": [{"$setIntersection": [[1, 2, 3], [2, 3, 4]]}, [2, 5]]}, + expected=[2], + msg="$setIntersection should nest inside itself", + ), +] + +# Property [Conditional And Binding Contexts]: $setIntersection works inside +# $cond, $let, and $ifNull. +SETINTERSECTION_CONTEXT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "in_cond", + doc={"a": [1, 2], "b": [2, 3]}, + expression={ + "$cond": { + "if": {"$gt": [{"$size": {"$setIntersection": ["$a", "$b"]}}, 0]}, + "then": "overlap", + "else": "disjoint", + } + }, + expected="overlap", + msg="$setIntersection should drive a $cond branch on intersection size", + ), + ExpressionTestCase( + "in_let", + doc={"a": [1, 2, 3], "b": [2, 3, 4]}, + expression={ + "$let": { + "vars": {"common": {"$setIntersection": ["$a", "$b"]}}, + "in": {"$size": "$$common"}, + } + }, + expected=2, + msg="$setIntersection should bind to a $let variable", + ), + ExpressionTestCase( + "ifnull_with_null", + expression={"$ifNull": [{"$setIntersection": [None, [1]]}, "fallback"]}, + expected="fallback", + msg="$ifNull should return the fallback when $setIntersection is null", + ), +] + +SETINTERSECTION_COMBINATION_TESTS: list[ExpressionTestCase] = ( + SETINTERSECTION_INPUT_TESTS + + SETINTERSECTION_SELF_COMPOSITION_TESTS + + SETINTERSECTION_CONTEXT_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(SETINTERSECTION_COMBINATION_TESTS)) +def test_setIntersection_combination(collection, test): + """Test $setIntersection combined with non-set operators and contexts.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result(result, expected=test.expected, msg=test.msg, ignore_order=True) + + +def test_setIntersection_in_match_expr(collection): + """Test $setIntersection in a $match $expr to filter documents.""" + collection.insert_many( + [ + {"_id": 1, "a": [1, 2, 3], "b": [2, 3]}, + {"_id": 2, "a": [1, 2], "b": [3, 4]}, + ] + ) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$match": {"$expr": {"$gt": [{"$size": {"$setIntersection": ["$a", "$b"]}}, 0]}}} + ], + "cursor": {}, + }, + ) + assertSuccess( + result, + [{"_id": 1, "a": [1, 2, 3], "b": [2, 3]}], + msg="Should match documents where $setIntersection is non-empty", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_setIsSubset.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_setIsSubset.py new file mode 100644 index 000000000..ef8193eaf --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_setIsSubset.py @@ -0,0 +1,128 @@ +"""Combination tests for $setIsSubset composed with non-set operators and contexts.""" + +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.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Array-Producing Inputs]: operators that produce arrays can feed +# $setIsSubset operands. +SETISSUBSET_INPUT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "concatArrays_input", + expression={"$setIsSubset": [{"$concatArrays": [[1], [2]]}, [1, 2, 3]]}, + expected=True, + msg="$setIsSubset should accept a $concatArrays result as the first operand", + ), + ExpressionTestCase( + "filter_input", + doc={"a": [1, 2, 3, 4], "b": [3, 4, 5]}, + expression={ + "$setIsSubset": [{"$filter": {"input": "$a", "cond": {"$gt": ["$$this", 2]}}}, "$b"] + }, + expected=True, + msg="$setIsSubset should accept a $filter result as the first operand", + ), + ExpressionTestCase( + "map_input", + doc={"a": [1, 2], "b": [2, 4, 6]}, + expression={ + "$setIsSubset": [{"$map": {"input": "$a", "in": {"$multiply": ["$$this", 2]}}}, "$b"] + }, + expected=True, + msg="$setIsSubset should accept a $map result as the first operand", + ), +] + +# Property [Conditional And Binding Contexts]: $setIsSubset drives $cond, composes +# under $and/$or/$not, and evaluates over $let-bound variables. +SETISSUBSET_CONTEXT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "in_cond_true", + doc={"a": [1, 2], "b": [1, 2, 3]}, + expression={"$cond": {"if": {"$setIsSubset": ["$a", "$b"]}, "then": "yes", "else": "no"}}, + expected="yes", + msg="$setIsSubset should drive a $cond then branch when the first is a subset", + ), + ExpressionTestCase( + "in_cond_false", + doc={"a": [1, 2, 3], "b": [1, 2]}, + expression={"$cond": {"if": {"$setIsSubset": ["$a", "$b"]}, "then": "yes", "else": "no"}}, + expected="no", + msg="$setIsSubset should drive a $cond else branch when the first is not a subset", + ), + ExpressionTestCase( + "in_and_mutual_subset", + doc={"a": [1, 2], "b": [1, 2]}, + expression={"$and": [{"$setIsSubset": ["$a", "$b"]}, {"$setIsSubset": ["$b", "$a"]}]}, + expected=True, + msg="$and should combine mutual $setIsSubset checks into a set-equality test", + ), + ExpressionTestCase( + "in_or_one_subset", + doc={"a": [1, 2], "b": [1, 2, 3]}, + expression={"$or": [{"$setIsSubset": ["$a", "$b"]}, {"$setIsSubset": ["$b", "$a"]}]}, + expected=True, + msg="$or should be true when one direction of $setIsSubset holds", + ), + ExpressionTestCase( + "in_not", + doc={"a": [1, 2, 3], "b": [1, 2]}, + expression={"$not": [{"$setIsSubset": ["$a", "$b"]}]}, + expected=True, + msg="$not should negate a false $setIsSubset result", + ), + ExpressionTestCase( + "with_let", + expression={ + "$let": { + "vars": {"arr1": [1, 2], "arr2": [1, 2, 3]}, + "in": {"$setIsSubset": ["$$arr1", "$$arr2"]}, + } + }, + expected=True, + msg="$setIsSubset should evaluate over $let-bound variables", + ), +] + +SETISSUBSET_COMBINATION_TESTS: list[ExpressionTestCase] = ( + SETISSUBSET_INPUT_TESTS + SETISSUBSET_CONTEXT_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(SETISSUBSET_COMBINATION_TESTS)) +def test_setIsSubset_combination(collection, test): + """Test $setIsSubset combined with non-set operators and contexts.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result(result, expected=test.expected, msg=test.msg) + + +def test_setIsSubset_in_match_expr(collection): + """Test $setIsSubset in a $match $expr to filter documents.""" + collection.insert_many( + [ + {"_id": 1, "a": [1, 2], "b": [1, 2, 3]}, + {"_id": 2, "a": [1, 2, 4], "b": [1, 2, 3]}, + ] + ) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [{"$match": {"$expr": {"$setIsSubset": ["$a", "$b"]}}}], + "cursor": {}, + }, + ) + assertSuccess( + result, + [{"_id": 1, "a": [1, 2], "b": [1, 2, 3]}], + msg="Should match documents where the first array is a subset of the second", + )