diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 135473efa..b0510edae 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -9,6 +9,7 @@ on: pull_request: branches: - main + workflow_dispatch: env: REGISTRY: ghcr.io diff --git a/documentdb_tests/compatibility/tests/core/operator/accumulators/addToSet/test_accumulator_addToSet_behaviors.py b/documentdb_tests/compatibility/tests/core/operator/accumulators/addToSet/test_accumulator_addToSet_behaviors.py new file mode 100644 index 000000000..fb5a5cc36 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/accumulators/addToSet/test_accumulator_addToSet_behaviors.py @@ -0,0 +1,447 @@ +""" +Behavior coverage for the $addToSet accumulator inside $group. + +Oracle: MongoDB 7.0. The accumulator produces an array of distinct values per +group; tests use `ignore_order_in=["vals"]` (or similar) so that the +engine-agnostic contract is "same set of values," not "same array order". +""" + +import pytest +from bson import Decimal128, ObjectId +from datetime import datetime, timezone + +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccess +from documentdb_tests.framework.executor import execute_command + +pytestmark = pytest.mark.aggregate + + +# --------------------------------------------------------------------------- +# Dedup semantics across types +# --------------------------------------------------------------------------- + + +def test_accumulator_addToSet_does_not_dedup_string_and_number(collection): + """A string value and a numerically-equal number are kept as distinct elements.""" + collection.insert_many( + [ + {"_id": 1, "g": "A", "v": 1}, + {"_id": 2, "g": "A", "v": "1"}, + ] + ) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$group": {"_id": "$g", "vals": {"$addToSet": "$v"}}}, + {"$sort": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + assertSuccess( + result, + [{"_id": "A", "vals": [1, "1"]}], + ignore_order_in=["vals"], + msg="String '1' and int 1 are distinct values", + ) + + +def test_accumulator_addToSet_dedups_int_and_double_numerically_equal(collection): + """Int 1 and Double 1.0 collapse to a single element.""" + collection.insert_many( + [ + {"_id": 1, "g": "A", "v": 1}, + {"_id": 2, "g": "A", "v": 1.0}, + ] + ) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$group": {"_id": "$g", "vals": {"$addToSet": "$v"}}}, + {"$sort": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + # Whichever document is consumed first wins the BSON type of the + # surviving element; the documented contract is set-equality only. + assertSuccess( + result, + [{"_id": "A", "vals": [1]}], + ignore_order_in=["vals"], + msg="Int 1 and Double 1.0 are the same set element", + ) + + +def test_accumulator_addToSet_dedups_decimal128_independent_of_trailing_zeros( + collection, +): + """Decimal128('1.0') and Decimal128('1.00') collapse to a single element.""" + collection.insert_many( + [ + {"_id": 1, "g": "A", "v": Decimal128("1.0")}, + {"_id": 2, "g": "A", "v": Decimal128("1.00")}, + ] + ) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$group": {"_id": "$g", "vals": {"$addToSet": "$v"}}}, + {"$sort": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + assertSuccess( + result, + [{"_id": "A", "vals": [Decimal128("1.0")]}], + ignore_order_in=["vals"], + msg="Decimal128 dedup must ignore trailing-zero representation", + ) + + +def test_accumulator_addToSet_dedups_null_values(collection): + """Multiple null-valued documents collapse to one null in the output set.""" + collection.insert_many( + [ + {"_id": 1, "g": "A", "v": None}, + {"_id": 2, "g": "A", "v": None}, + {"_id": 3, "g": "A", "v": 1}, + ] + ) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$group": {"_id": "$g", "vals": {"$addToSet": "$v"}}}, + {"$sort": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + assertSuccess( + result, + [{"_id": "A", "vals": [None, 1]}], + ignore_order_in=["vals"], + msg="Null appears at most once in the output set", + ) + + +def test_accumulator_addToSet_dedups_subdocuments_by_deep_equality(collection): + """Equal subdocuments collapse to one element.""" + collection.insert_many( + [ + {"_id": 1, "g": "A", "x": 1, "y": 2}, + {"_id": 2, "g": "A", "x": 1, "y": 2}, + {"_id": 3, "g": "A", "x": 3, "y": 4}, + ] + ) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$group": { + "_id": "$g", + "pairs": {"$addToSet": {"x": "$x", "y": "$y"}}, + } + }, + {"$sort": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + assertSuccess( + result, + [{"_id": "A", "pairs": [{"x": 1, "y": 2}, {"x": 3, "y": 4}]}], + ignore_order_in=["pairs"], + msg="Equal subdocuments must be deduplicated", + ) + + +# --------------------------------------------------------------------------- +# Missing-field semantics (known divergence) +# --------------------------------------------------------------------------- + + +@pytest.mark.engine_xfail( + engine="pgmongo", + reason=( + "$addToSet accumulator on a missing field: native MongoDB skips the " + "document; documentdb includes a null. Tracked divergence." + ), + raises=AssertionError, +) +def test_accumulator_addToSet_skips_documents_missing_field(collection): + """Documents that don't have the source field must be skipped, not included as null.""" + collection.insert_many( + [ + {"_id": 1, "g": "A"}, # no `v` — must be skipped + {"_id": 2, "g": "A", "v": 1}, + {"_id": 3, "g": "A", "v": 2}, + ] + ) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$group": {"_id": "$g", "vals": {"$addToSet": "$v"}}}, + {"$sort": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + assertSuccess( + result, + [{"_id": "A", "vals": [1, 2]}], + ignore_order_in=["vals"], + msg="Missing-field documents must be skipped by $addToSet", + ) + + +# --------------------------------------------------------------------------- +# Error cases +# --------------------------------------------------------------------------- + + +@pytest.mark.engine_xfail( + engine="pgmongo", + reason=( + "$addToSet is a unary accumulator and must reject array-shaped " + "arguments with code 40237; documentdb accepts the array as a " + "literal value. Tracked divergence." + ), + raises=AssertionError, +) +def test_accumulator_addToSet_rejects_multi_arg_array(collection): + """Passing an array literal as the accumulator argument must fail with 40237.""" + collection.insert_one({"_id": 1, "x": 1, "y": 2}) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$group": { + "_id": None, + "v": {"$addToSet": ["$x", "$y"]}, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode( + result, + 40237, + msg="$addToSet must reject multi-argument array form (40237)", + ) + + +# --------------------------------------------------------------------------- +# Type-specific dedup +# --------------------------------------------------------------------------- + + +def test_accumulator_addToSet_dedups_objectid(collection): + """Equal ObjectIds across documents collapse to a single element.""" + oid = ObjectId("64b5e4f0a1b2c3d4e5f60001") + collection.insert_many( + [ + {"_id": 1, "g": "A", "v": oid}, + {"_id": 2, "g": "A", "v": oid}, + ] + ) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$group": {"_id": "$g", "vals": {"$addToSet": "$v"}}}, + {"$sort": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + assertSuccess( + result, + [{"_id": "A", "vals": [oid]}], + ignore_order_in=["vals"], + msg="Equal ObjectIds must collapse to a single element", + ) + + +def test_accumulator_addToSet_dedups_date(collection): + """Equal Date values across documents collapse to a single element.""" + d = datetime(2024, 1, 1, tzinfo=timezone.utc) + collection.insert_many( + [ + {"_id": 1, "g": "A", "v": d}, + {"_id": 2, "g": "A", "v": d}, + ] + ) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$group": {"_id": "$g", "vals": {"$addToSet": "$v"}}}, + {"$sort": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + assertSuccess( + result, + [{"_id": "A", "vals": [d]}], + ignore_order_in=["vals"], + msg="Equal Date values must collapse to a single element", + ) + + +# --------------------------------------------------------------------------- +# Literal and expression arguments +# --------------------------------------------------------------------------- + + +def test_accumulator_addToSet_literal_value_collapses_to_single_element(collection): + """A literal scalar argument produces a single-element set regardless of input row count.""" + collection.insert_many( + [{"_id": 1, "g": "A"}, {"_id": 2, "g": "A"}, {"_id": 3, "g": "A"}] + ) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$group": {"_id": "$g", "vals": {"$addToSet": "constant"}}}, + {"$sort": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + assertSuccess( + result, + [{"_id": "A", "vals": ["constant"]}], + ignore_order_in=["vals"], + msg="Literal-arg $addToSet must produce a single-element set", + ) + + +def test_accumulator_addToSet_dedups_after_expression_transform(collection): + """The accumulator dedups on the expression result, not on the raw field.""" + collection.insert_many( + [ + {"_id": 1, "g": "A", "v": "abc"}, + {"_id": 2, "g": "A", "v": "ABC"}, + {"_id": 3, "g": "A", "v": "aBc"}, + ] + ) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$group": { + "_id": "$g", + "vals": {"$addToSet": {"$toUpper": "$v"}}, + } + }, + {"$sort": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + assertSuccess( + result, + [{"_id": "A", "vals": ["ABC"]}], + ignore_order_in=["vals"], + msg="Equality is measured on the expression result, not the raw input", + ) + + +# --------------------------------------------------------------------------- +# Group key edge cases +# --------------------------------------------------------------------------- + + +def test_accumulator_addToSet_with_id_null_groups_all_documents(collection): + """`_id: null` produces a single global group containing the union of values.""" + collection.insert_many( + [{"_id": 1, "v": 1}, {"_id": 2, "v": 2}, {"_id": 3, "v": 1}] + ) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$group": {"_id": None, "vals": {"$addToSet": "$v"}}}, + ], + "cursor": {}, + }, + ) + assertSuccess( + result, + [{"_id": None, "vals": [1, 2]}], + ignore_order_in=["vals"], + msg="`_id: null` must group all documents into a single set", + ) + + +def test_accumulator_addToSet_treats_reordered_subdocument_keys_as_distinct( + collection, +): + """Subdocument elements with reordered keys are kept as distinct set elements.""" + collection.insert_many( + [ + {"_id": 1, "g": "A", "doc": {"a": 1, "b": 2}}, + {"_id": 2, "g": "A", "doc": {"b": 2, "a": 1}}, + ] + ) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$group": {"_id": "$g", "vals": {"$addToSet": "$doc"}}}, + {"$sort": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + assertSuccess( + result, + [{"_id": "A", "vals": [{"a": 1, "b": 2}, {"b": 2, "a": 1}]}], + ignore_order_in=["vals"], + msg="Field ordering must matter for subdocument set equality", + ) + + +def test_accumulator_addToSet_empty_input_produces_no_groups(collection): + """Aggregating an empty collection yields no group rows (not an empty set).""" + # collection fixture is already empty; do not insert. + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$group": {"_id": "$g", "vals": {"$addToSet": "$v"}}}, + ], + "cursor": {}, + }, + ) + assertSuccess( + result, + [], + msg="Empty input must yield zero group rows", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/update/array/addToSet/test_addToSet_behaviors.py b/documentdb_tests/compatibility/tests/core/operator/update/array/addToSet/test_addToSet_behaviors.py new file mode 100644 index 000000000..9f2916b60 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/array/addToSet/test_addToSet_behaviors.py @@ -0,0 +1,786 @@ +""" +Behavior coverage for the $addToSet update operator. + +Oracle: MongoDB 7.0. Each test asserts the structural response shape +(n / nModified / ok / writeErrors[].code) so the test stays deterministic +and engine-agnostic. Server-injected fields (modified array contents, upsert +ids that we control via `q`) are read back through a follow-up find when +necessary. +""" + +import pytest +from bson import Binary, Decimal128, ObjectId +from datetime import datetime, timezone + +from documentdb_tests.framework.assertions import ( + assertFailureCode, + assertSuccess, + assertSuccessPartial, +) +from documentdb_tests.framework.executor import execute_command + +pytestmark = pytest.mark.update + + +# --------------------------------------------------------------------------- +# Dedup semantics +# --------------------------------------------------------------------------- + + +def test_addToSet_no_op_when_value_already_in_array(collection): + """$addToSet of an existing value reports nModified=0 (set semantics).""" + collection.insert_one({"_id": 1, "tags": ["A", "B"]}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$addToSet": {"tags": "A"}}}], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 0, "ok": 1.0}, + msg="Re-adding existing value must be a no-op", + ) + + +def test_addToSet_creates_array_on_missing_field(collection): + """$addToSet on a missing field creates a new single-element array.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$addToSet": {"tags": "A"}}}], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 1, "ok": 1.0}, + msg="Missing field is created as a new array", + ) + + +def test_addToSet_value_that_is_array_added_as_single_element(collection): + """An array value (without $each) is added as a single element, not flattened.""" + collection.insert_one({"_id": 1, "tags": ["A"]}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 1}, "u": {"$addToSet": {"tags": ["B", "C"]}}} + ], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 1, "ok": 1.0}, + msg="Array argument (no $each) is one element, not flattened", + ) + + +def test_addToSet_dotted_path_adds_to_nested_array(collection): + """$addToSet works on a dotted path into a nested document.""" + collection.insert_one({"_id": 1, "profile": {"tags": ["A"]}}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 1}, "u": {"$addToSet": {"profile.tags": "B"}}} + ], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 1, "ok": 1.0}, + msg="Dotted-path target must modify the nested array", + ) + + +def test_addToSet_dedups_subdocuments_by_deep_equality(collection): + """Adding an equal subdocument is a no-op.""" + collection.insert_one({"_id": 1, "items": [{"k": 1, "v": "a"}]}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$addToSet": {"items": {"k": 1, "v": "a"}}}, + } + ], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 0, "ok": 1.0}, + msg="Equal subdocument must not be re-added", + ) + + +def test_addToSet_treats_reordered_subdocument_keys_as_distinct(collection): + """{k:1,v:'a'} and {v:'a',k:1} are different documents — both kept.""" + collection.insert_one({"_id": 1, "items": [{"k": 1, "v": "a"}]}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$addToSet": {"items": {"v": "a", "k": 1}}}, + } + ], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 1, "ok": 1.0}, + msg="Field ordering must matter for subdocument equality", + ) + + +def test_addToSet_dedups_int_and_double_numerically_equal(collection): + """Int(1) and Double(1.0) are equal — second add is a no-op.""" + collection.insert_one({"_id": 1, "n": [1]}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$addToSet": {"n": 1.0}}}], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 0, "ok": 1.0}, + msg="Int 1 and Double 1.0 must dedup", + ) + + +def test_addToSet_dedups_decimal128_independent_of_trailing_zeros(collection): + """Decimal128('1.0') and Decimal128('1.00') compare equal — no-op.""" + collection.insert_one({"_id": 1, "n": [Decimal128("1.0")]}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 1}, "u": {"$addToSet": {"n": Decimal128("1.00")}}} + ], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 0, "ok": 1.0}, + msg="Decimal128 dedup must ignore trailing-zero representation", + ) + + +def test_addToSet_dedups_null_value(collection): + """Adding null when null already present is a no-op.""" + collection.insert_one({"_id": 1, "vals": [None]}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$addToSet": {"vals": None}}}], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 0, "ok": 1.0}, + msg="Null is a value that dedups against itself", + ) + + +# --------------------------------------------------------------------------- +# $each modifier +# --------------------------------------------------------------------------- + + +def test_addToSet_each_adds_only_new_values(collection): + """$each with mix of new and existing values adds only the new ones.""" + collection.insert_one({"_id": 1, "tags": ["A"]}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$addToSet": {"tags": {"$each": ["B", "C", "A"]}}}, + } + ], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 1, "ok": 1.0}, + msg="$each must add new values and skip duplicates", + ) + + +def test_addToSet_each_empty_array_is_noop(collection): + """$each with an empty array must be a no-op (nModified=0).""" + collection.insert_one({"_id": 1, "tags": ["A"]}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 1}, "u": {"$addToSet": {"tags": {"$each": []}}}} + ], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 0, "ok": 1.0}, + msg="$each: [] must not modify the document", + ) + + +# --------------------------------------------------------------------------- +# Error cases +# --------------------------------------------------------------------------- + + +def test_addToSet_errors_on_non_array_field(collection): + """$addToSet onto a scalar field returns code 2 (BadValue).""" + collection.insert_one({"_id": 1, "tags": "scalar"}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$addToSet": {"tags": "X"}}}], + }, + ) + assertFailureCode( + result, + 2, + msg="$addToSet against non-array field must fail with BadValue (2)", + ) + + +def test_addToSet_errors_on_non_array_each_argument(collection): + """$each argument must be an array — string argument returns code 14 (TypeMismatch).""" + collection.insert_one({"_id": 1, "tags": ["A"]}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$addToSet": {"tags": {"$each": "B"}}}, + } + ], + }, + ) + assertFailureCode( + result, + 14, + msg="$each with non-array value must fail with TypeMismatch (14)", + ) + + +def test_addToSet_errors_on_conflicting_set_on_same_path(collection): + """$addToSet and $set on the same path conflict — code 40.""" + collection.insert_one({"_id": 1, "tags": ["A"]}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$addToSet": {"tags": "B"}, "$set": {"tags": ["X"]}}, + } + ], + }, + ) + assertFailureCode( + result, + 40, + msg="Same-path $addToSet + $set must conflict (code 40)", + ) + + +@pytest.mark.engine_xfail( + engine="pgmongo", + reason=( + "documentdb silently accepts unknown modifiers (e.g. $slice) " + "alongside $each in $addToSet; native MongoDB rejects with code 2." + ), + raises=AssertionError, +) +def test_addToSet_each_with_unknown_modifier_errors(collection): + """$each combined with an unknown modifier ($slice) must fail with code 2.""" + collection.insert_one({"_id": 1, "tags": ["A"]}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": { + "$addToSet": { + "tags": {"$each": ["B"], "$slice": 2}, + } + }, + } + ], + }, + ) + assertFailureCode( + result, + 2, + msg="$each + unknown modifier in $addToSet must fail with code 2", + ) + + +# --------------------------------------------------------------------------- +# Upsert +# --------------------------------------------------------------------------- + + +def test_addToSet_upsert_reports_inserted_via_response(collection): + """Upsert with $addToSet reports n=1 and nModified=0 on the insert path.""" + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 99}, + "u": {"$addToSet": {"tags": "A"}}, + "upsert": True, + } + ], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 0, "ok": 1.0}, + msg="Upsert reports n=1 and nModified=0 on insert path", + ) + + +def test_addToSet_upsert_creates_array_field(collection): + """Upserted document contains the new array populated with the value.""" + execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 99}, + "u": {"$addToSet": {"tags": "A"}}, + "upsert": True, + } + ], + }, + ) + docs = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 99}, "sort": {"_id": 1}}, + ) + assertSuccess( + docs, + [{"_id": 99, "tags": ["A"]}], + msg="Upserted document must contain the new array", + ) + + +# --------------------------------------------------------------------------- +# $each extra dedup semantics +# --------------------------------------------------------------------------- + + +def test_addToSet_each_collapses_internal_duplicates(collection): + """Duplicates inside the $each argument are deduped against each other and the field.""" + collection.insert_one({"_id": 1, "tags": ["A"]}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$addToSet": {"tags": {"$each": ["B", "B", "A", "C", "C"]}}}, + } + ], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 1, "ok": 1.0}, + msg="$each must dedup internally and against existing values", + ) + + +def test_addToSet_each_all_existing_values_is_noop(collection): + """$each containing only values already present must report nModified=0.""" + collection.insert_one({"_id": 1, "tags": ["A", "B"]}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$addToSet": {"tags": {"$each": ["A", "B"]}}}, + } + ], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 0, "ok": 1.0}, + msg="$each with only existing values must not modify the document", + ) + + +# --------------------------------------------------------------------------- +# Empty / first-element / special-value semantics +# --------------------------------------------------------------------------- + + +def test_addToSet_to_empty_array_adds_first_element(collection): + """$addToSet against an empty array reports nModified=1 with one new element.""" + collection.insert_one({"_id": 1, "tags": []}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$addToSet": {"tags": "X"}}}], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 1, "ok": 1.0}, + msg="Empty array must accept the first element", + ) + + +def test_addToSet_NaN_dedups_against_NaN(collection): + """NaN compares equal to NaN under $addToSet set semantics (no-op).""" + collection.insert_one({"_id": 1, "n": [float("nan")]}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$addToSet": {"n": float("nan")}}}], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 0, "ok": 1.0}, + msg="NaN must dedup against NaN inside $addToSet", + ) + + +def test_addToSet_positive_and_negative_infinity_distinct(collection): + """+Infinity and -Infinity are different set elements.""" + collection.insert_one({"_id": 1, "n": [float("inf")]}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$addToSet": {"n": float("-inf")}}}], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 1, "ok": 1.0}, + msg="+Infinity and -Infinity must be treated as distinct values", + ) + + +def test_addToSet_nested_array_dedups_by_value(collection): + """An array-as-element is deduped against an equal array-as-element.""" + collection.insert_one({"_id": 1, "arrs": [[1, 2]]}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$addToSet": {"arrs": [1, 2]}}}], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 0, "ok": 1.0}, + msg="Nested-array element must dedup by value", + ) + + +def test_addToSet_objectid_dedups_by_value(collection): + """Equal ObjectIds compare equal — second add is a no-op.""" + oid = ObjectId("64b5e4f0a1b2c3d4e5f60001") + collection.insert_one({"_id": 1, "oids": [oid]}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$addToSet": {"oids": oid}}}], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 0, "ok": 1.0}, + msg="Equal ObjectId must dedup", + ) + + +def test_addToSet_date_dedups_by_value(collection): + """Equal Date values compare equal — second add is a no-op.""" + d = datetime(2024, 1, 1, tzinfo=timezone.utc) + collection.insert_one({"_id": 1, "ds": [d]}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$addToSet": {"ds": d}}}], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 0, "ok": 1.0}, + msg="Equal Date must dedup", + ) + + +def test_addToSet_binary_dedups_by_value(collection): + """Equal Binary values (same subtype + payload) dedup.""" + b = Binary(b"hello", 0) + collection.insert_one({"_id": 1, "bs": [b]}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$addToSet": {"bs": b}}}], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 0, "ok": 1.0}, + msg="Equal Binary must dedup", + ) + + +# --------------------------------------------------------------------------- +# Multi-document update +# --------------------------------------------------------------------------- + + +def test_addToSet_multi_true_modifies_only_docs_that_change(collection): + """multi:true reports n equal to matched docs and nModified only for changed.""" + collection.insert_many( + [{"_id": 1, "tags": ["A"]}, {"_id": 2, "tags": ["A", "B"]}] + ) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {}, "u": {"$addToSet": {"tags": "B"}}, "multi": True}], + }, + ) + assertSuccessPartial( + result, + {"n": 2, "nModified": 1, "ok": 1.0}, + msg="multi:true must match both but only modify the doc that gained an element", + ) + + +# --------------------------------------------------------------------------- +# Shared-error cases (must fail with the same code on native and on documentdb) +# --------------------------------------------------------------------------- + + +def test_addToSet_errors_on_scalar_number_field(collection): + """$addToSet onto a numeric scalar must fail with BadValue (2).""" + collection.insert_one({"_id": 1, "n": 5}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$addToSet": {"n": 7}}}], + }, + ) + assertFailureCode( + result, + 2, + msg="$addToSet against a numeric scalar must fail with BadValue (2)", + ) + + +def test_addToSet_errors_on_scalar_date_field(collection): + """$addToSet onto a Date scalar must fail with BadValue (2).""" + collection.insert_one( + {"_id": 1, "d": datetime(2024, 1, 1, tzinfo=timezone.utc)} + ) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$addToSet": {"d": "x"}}}], + }, + ) + assertFailureCode( + result, + 2, + msg="$addToSet against a Date scalar must fail with BadValue (2)", + ) + + +def test_addToSet_errors_on_null_field(collection): + """$addToSet onto a field whose value is BSON null must fail with BadValue (2).""" + collection.insert_one({"_id": 1, "tags": None}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$addToSet": {"tags": "X"}}}], + }, + ) + assertFailureCode( + result, + 2, + msg="$addToSet against a null-valued field must fail with BadValue (2)", + ) + + +def test_addToSet_errors_on_dotted_path_through_scalar(collection): + """$addToSet on a dotted path whose intermediate is a scalar must fail with PathNotViable (28).""" + collection.insert_one({"_id": 1, "name": "John"}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 1}, "u": {"$addToSet": {"name.first": "X"}}} + ], + }, + ) + assertFailureCode( + result, + 28, + msg="$addToSet through a scalar intermediate must fail with PathNotViable (28)", + ) + + +def test_addToSet_errors_on_id_target_as_non_array(collection): + """$addToSet targeting the _id field (a non-array scalar) must fail with BadValue (2).""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$addToSet": {"_id": 99}}}], + }, + ) + assertFailureCode( + result, + 2, + msg="$addToSet against _id (a non-array scalar) must fail with BadValue (2)", + ) + + +def test_addToSet_errors_on_conflicting_subpath(collection): + """$addToSet on `tags` and `tags.0` in the same update must conflict (40).""" + collection.insert_one({"_id": 1, "tags": ["A", "B"]}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$addToSet": {"tags": "C", "tags.0": "Z"}}, + } + ], + }, + ) + assertFailureCode( + result, + 40, + msg="Updates on tags and tags.0 in the same operator must conflict (40)", + ) + + +# --------------------------------------------------------------------------- +# Engine divergences ($push modifiers leaking into $addToSet) +# --------------------------------------------------------------------------- + + +@pytest.mark.engine_xfail( + engine="pgmongo", + reason=( + "$position is a $push modifier; native MongoDB rejects it in $addToSet " + "with code 2 ('Found unexpected fields after $each'). documentdb " + "silently ignores the modifier and succeeds." + ), + raises=AssertionError, +) +def test_addToSet_each_with_position_modifier_errors(collection): + """$each combined with $position must fail with BadValue (2).""" + collection.insert_one({"_id": 1, "tags": ["A"]}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": { + "$addToSet": { + "tags": {"$each": ["B"], "$position": 0}, + } + }, + } + ], + }, + ) + assertFailureCode( + result, + 2, + msg="$each + $position in $addToSet must fail with BadValue (2)", + ) + + +@pytest.mark.engine_xfail( + engine="pgmongo", + reason=( + "$sort is a $push modifier; native MongoDB rejects it in $addToSet " + "with code 2 ('Found unexpected fields after $each'). documentdb " + "silently ignores the modifier and succeeds." + ), + raises=AssertionError, +) +def test_addToSet_each_with_sort_modifier_errors(collection): + """$each combined with $sort must fail with BadValue (2).""" + collection.insert_one({"_id": 1, "tags": ["A"]}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": { + "$addToSet": { + "tags": {"$each": ["B"], "$sort": 1}, + } + }, + } + ], + }, + ) + assertFailureCode( + result, + 2, + msg="$each + $sort in $addToSet must fail with BadValue (2)", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/update/fields/currentDate/test_currentDate_behaviors.py b/documentdb_tests/compatibility/tests/core/operator/update/fields/currentDate/test_currentDate_behaviors.py new file mode 100644 index 000000000..ed1e81aa3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/fields/currentDate/test_currentDate_behaviors.py @@ -0,0 +1,988 @@ +""" +Behavior coverage for the $currentDate update operator. + +Oracle: MongoDB 7.0 (per functional-tests CI baseline). Each test asserts on +either (a) the structural update-command response (n / nModified / ok) or +(b) field presence / BSON type via assertProperties on a follow-up find — +never on the time value itself, which is server-injected and volatile. +""" + +from datetime import datetime + +import pytest + +from documentdb_tests.framework.assertions import ( + assertFailureCode, + assertProperties, + assertSuccessPartial, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.property_checks import Eq, Exists, IsType, Len + +pytestmark = pytest.mark.update + + +# --------------------------------------------------------------------------- +# Type-specification behavior +# --------------------------------------------------------------------------- + + +def test_currentDate_true_returns_match_counts(collection): + """`true` shorthand reports n=1 / nModified=1 / ok=1.0.""" + collection.insert_one({"_id": 1, "name": "test"}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 1}, "u": {"$currentDate": {"lastModified": True}}} + ], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 1, "ok": 1.0}, + msg="$currentDate with true must report n=1 / nModified=1", + ) + + +def test_currentDate_true_sets_date_typed_field(collection): + """`true` injects a BSON Date into the named field.""" + collection.insert_one({"_id": 1}) + execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 1}, "u": {"$currentDate": {"lastModified": True}}} + ], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties( + result, + {"lastModified": IsType("date")}, + msg="lastModified must be a BSON Date after $currentDate with true", + ) + + +def test_currentDate_explicit_type_date_returns_match_counts(collection): + """`{$type: 'date'}` long-form reports n=1 / nModified=1 / ok=1.0.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$currentDate": {"lastModified": {"$type": "date"}}}, + } + ], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 1, "ok": 1.0}, + msg="$currentDate with {$type:'date'} must report n=1 / nModified=1", + ) + + +def test_currentDate_type_timestamp_returns_match_counts(collection): + """`{$type: 'timestamp'}` reports n=1 / nModified=1 / ok=1.0.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$currentDate": {"ts": {"$type": "timestamp"}}}, + } + ], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 1, "ok": 1.0}, + msg="$currentDate with {$type:'timestamp'} must report n=1 / nModified=1", + ) + + +def test_currentDate_type_timestamp_sets_field(collection): + """`{$type: 'timestamp'}` creates the named field on the document.""" + collection.insert_one({"_id": 1}) + execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$currentDate": {"ts": {"$type": "timestamp"}}}, + } + ], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties( + result, + {"ts": Exists()}, + msg="ts must be present after $currentDate with {$type:'timestamp'}", + ) + + +def test_currentDate_multiple_fields_returns_match_counts(collection): + """One $currentDate stage with multiple fields reports n=1 / nModified=1.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": { + "$currentDate": { + "a": True, + "b": {"$type": "timestamp"}, + } + }, + } + ], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 1, "ok": 1.0}, + msg="$currentDate must succeed when setting multiple fields in one operator", + ) + + +def test_currentDate_multiple_fields_sets_date_typed_field(collection): + """The 'true'-typed field in a multi-field stage is a BSON Date.""" + collection.insert_one({"_id": 1}) + execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": { + "$currentDate": { + "a": True, + "b": {"$type": "timestamp"}, + } + }, + } + ], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties( + result, + {"a": IsType("date"), "b": Exists()}, + msg="Mixed-type $currentDate must inject Date for a and any value for b", + ) + + +# --------------------------------------------------------------------------- +# Path / structural behavior +# --------------------------------------------------------------------------- + + +def test_currentDate_creates_missing_field(collection): + """$currentDate auto-creates the target field when it does not exist.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 1}, "u": {"$currentDate": {"newField": True}}} + ], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 1, "ok": 1.0}, + msg="Missing field must be auto-created", + ) + + +def test_currentDate_dotted_path_creates_intermediate_subdoc(collection): + """Dotted path creates the intermediate subdocument and the leaf field.""" + collection.insert_one({"_id": 1}) + execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$currentDate": {"meta.lastModified": True}}, + } + ], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties( + result, + {"meta.lastModified": IsType("date")}, + msg="Dotted path must create the intermediate subdocument with a Date leaf", + ) + + +def test_currentDate_overwrites_scalar_field_with_date(collection): + """$currentDate replaces a string scalar — the new value is a Date.""" + collection.insert_one({"_id": 1, "field": "scalar string"}) + execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 1}, "u": {"$currentDate": {"field": True}}} + ], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties( + result, + {"field": IsType("date")}, + msg="String scalar must be replaced with a Date value", + ) + + +def test_currentDate_overwrites_existing_date_returns_match_counts(collection): + """$currentDate on an existing Date reports n=1 / nModified=1.""" + collection.insert_one({"_id": 1, "ts": datetime(2020, 1, 1)}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$currentDate": {"ts": True}}}], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 1, "ok": 1.0}, + msg="$currentDate must overwrite an existing Date value", + ) + + +def test_currentDate_multi_true_returns_n_3_nModified_3(collection): + """multi:true reports n=3 / nModified=3 when matching all three documents.""" + collection.insert_many([{"_id": 1}, {"_id": 2}, {"_id": 3}]) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {}, "u": {"$currentDate": {"ts": True}}, "multi": True} + ], + }, + ) + assertSuccessPartial( + result, + {"n": 3, "nModified": 3, "ok": 1.0}, + msg="multi:true must update every matching document", + ) + + +def test_currentDate_multi_true_sets_field_on_all_documents(collection): + """After multi:true, every matched document has the field present.""" + collection.insert_many([{"_id": 1}, {"_id": 2}, {"_id": 3}]) + execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {}, "u": {"$currentDate": {"ts": True}}, "multi": True} + ], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {}, "sort": {"_id": 1}}, + ) + assertProperties( + result, + { + "cursor.firstBatch": Len(3), + "cursor.firstBatch.0.ts": IsType("date"), + "cursor.firstBatch.1.ts": IsType("date"), + "cursor.firstBatch.2.ts": IsType("date"), + }, + raw_res=True, + msg="multi:true must inject a Date into every matched document", + ) + + +# --------------------------------------------------------------------------- +# Upsert +# --------------------------------------------------------------------------- + + +def test_currentDate_upsert_returns_n_1_nModified_0(collection): + """Upsert with $currentDate reports n=1 / nModified=0 on the insert path.""" + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 99}, + "u": {"$currentDate": {"ts": True}}, + "upsert": True, + } + ], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 0, "ok": 1.0}, + msg="Upsert must report n=1 / nModified=0 on the insert path", + ) + + +def test_currentDate_upsert_creates_document_with_date_field(collection): + """The upserted document contains the new field as a Date.""" + execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 99}, + "u": {"$currentDate": {"ts": True}}, + "upsert": True, + } + ], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 99}, "sort": {"_id": 1}}, + ) + assertProperties( + result, + {"ts": IsType("date")}, + msg="Upserted document must contain a Date in ts", + ) + + +# --------------------------------------------------------------------------- +# No-op edge case +# --------------------------------------------------------------------------- + + +def test_currentDate_empty_operand_is_noop(collection): + """`{$currentDate: {}}` matches the document but modifies nothing.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$currentDate": {}}}], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 0, "ok": 1.0}, + msg="Empty $currentDate operand must be a no-op (matched, not modified)", + ) + + +# --------------------------------------------------------------------------- +# Shared-error cases (both engines must fail with the same code) +# --------------------------------------------------------------------------- + + +def test_currentDate_errors_on_invalid_type_string(collection): + """`{$type: 'string'}` (not 'date'/'timestamp') must fail with BadValue (2).""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$currentDate": {"ts": {"$type": "string"}}}, + } + ], + }, + ) + assertFailureCode( + result, + 2, + msg="$currentDate $type other than 'date'/'timestamp' must fail with code 2", + ) + + +def test_currentDate_errors_on_type_with_wrong_case(collection): + """`{$type: 'Date'}` (capital D) must fail — type strings are case-sensitive.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$currentDate": {"ts": {"$type": "Date"}}}, + } + ], + }, + ) + assertFailureCode( + result, + 2, + msg="$currentDate $type is case-sensitive; 'Date' must fail with code 2", + ) + + +def test_currentDate_errors_on_integer_value(collection): + """Integer values are not allowed — must fail with BadValue (2).""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$currentDate": {"ts": 42}}}], + }, + ) + assertFailureCode( + result, + 2, + msg="$currentDate integer value must fail with code 2", + ) + + +def test_currentDate_errors_on_string_value(collection): + """String values are not allowed — must fail with BadValue (2).""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 1}, "u": {"$currentDate": {"ts": "now"}}} + ], + }, + ) + assertFailureCode( + result, + 2, + msg="$currentDate string value must fail with code 2", + ) + + +def test_currentDate_errors_on_type_value_as_number(collection): + """`{$type: 1}` (non-string) must fail with BadValue (2).""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$currentDate": {"ts": {"$type": 1}}}, + } + ], + }, + ) + assertFailureCode( + result, + 2, + msg="$currentDate $type non-string value must fail with code 2", + ) + + +def test_currentDate_errors_on_empty_type_spec(collection): + """`{}` (empty type spec — missing required $type) must fail with code 2.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 1}, "u": {"$currentDate": {"ts": {}}}} + ], + }, + ) + assertFailureCode( + result, + 2, + msg="$currentDate with empty type spec must fail with code 2", + ) + + +def test_currentDate_errors_on_unknown_modifier_in_spec(collection): + """Any modifier other than $type in the spec must fail with code 2.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$currentDate": {"ts": {"$weird": "x"}}}, + } + ], + }, + ) + assertFailureCode( + result, + 2, + msg="$currentDate unknown modifier in spec must fail with code 2", + ) + + +def test_currentDate_errors_on_conflicting_set_on_same_path(collection): + """$currentDate and $set on the same path must conflict (code 40).""" + collection.insert_one({"_id": 1, "ts": "x"}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$currentDate": {"ts": True}, "$set": {"ts": "y"}}, + } + ], + }, + ) + assertFailureCode( + result, + 40, + msg="Same-path $currentDate + $set must conflict with code 40", + ) + + +def test_currentDate_errors_on_dotted_path_through_scalar(collection): + """$currentDate on a dotted path through a scalar must fail with PathNotViable (28).""" + collection.insert_one({"_id": 1, "name": "John"}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 1}, "u": {"$currentDate": {"name.first": True}}} + ], + }, + ) + assertFailureCode( + result, + 28, + msg="$currentDate through a scalar intermediate must fail with code 28", + ) + + +def test_currentDate_errors_on_id_target(collection): + """$currentDate targeting _id must fail with ImmutableField (66).""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 1}, "u": {"$currentDate": {"_id": True}}} + ], + }, + ) + assertFailureCode( + result, + 66, + msg="$currentDate against _id must fail with ImmutableField (66)", + ) + + +# --------------------------------------------------------------------------- +# Engine divergence +# --------------------------------------------------------------------------- + + +@pytest.mark.engine_xfail( + engine="pgmongo", + reason=( + "$currentDate spec with an extra field alongside $type: native " + "MongoDB rejects with code 2 ('Unrecognized $currentDate option: " + "'); documentdb silently accepts the extra field and applies " + "the operator." + ), + raises=AssertionError, +) +def test_currentDate_errors_on_extra_field_in_type_spec(collection): + """Any field alongside $type in the spec must fail with code 2.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": { + "$currentDate": { + "ts": {"$type": "date", "extra": 1} + } + }, + } + ], + }, + ) + assertFailureCode( + result, + 2, + msg="$currentDate $type spec with extra field must fail with code 2", + ) + + +# --------------------------------------------------------------------------- +# Operator-value type matrix (the $currentDate value must be a document) +# Every non-document operand must fail with FailedToParse (9). +# --------------------------------------------------------------------------- + + +def test_currentDate_errors_when_operand_is_null(collection): + """`{$currentDate: null}` must fail with FailedToParse (9).""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$currentDate": None}}], + }, + ) + assertFailureCode( + result, + 9, + msg="Null operand to $currentDate must fail with FailedToParse (9)", + ) + + +def test_currentDate_errors_when_operand_is_array(collection): + """`{$currentDate: [...]}` must fail with FailedToParse (9).""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$currentDate": [1, 2]}}], + }, + ) + assertFailureCode( + result, + 9, + msg="Array operand to $currentDate must fail with FailedToParse (9)", + ) + + +def test_currentDate_errors_when_operand_is_string(collection): + """`{$currentDate: 'now'}` must fail with FailedToParse (9).""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$currentDate": "now"}}], + }, + ) + assertFailureCode( + result, + 9, + msg="String operand to $currentDate must fail with FailedToParse (9)", + ) + + +def test_currentDate_errors_when_operand_is_bool(collection): + """`{$currentDate: true}` (top-level bool) must fail with FailedToParse (9).""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$currentDate": True}}], + }, + ) + assertFailureCode( + result, + 9, + msg="Bool operand to $currentDate must fail with FailedToParse (9)", + ) + + +def test_currentDate_errors_when_operand_is_integer(collection): + """`{$currentDate: 42}` must fail with FailedToParse (9).""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$currentDate": 42}}], + }, + ) + assertFailureCode( + result, + 9, + msg="Integer operand to $currentDate must fail with FailedToParse (9)", + ) + + +# --------------------------------------------------------------------------- +# Composition with other update operators +# --------------------------------------------------------------------------- + + +def test_currentDate_composes_with_set_on_different_path(collection): + """$currentDate and $set on different paths combine without conflict.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": { + "$currentDate": {"ts": True}, + "$set": {"x": 1}, + }, + } + ], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 1, "ok": 1.0}, + msg="$currentDate must compose with $set on a different path", + ) + + +def test_currentDate_composes_with_inc_on_different_path(collection): + """$currentDate and $inc on different paths combine without conflict.""" + collection.insert_one({"_id": 1, "n": 0}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": { + "$currentDate": {"ts": True}, + "$inc": {"n": 1}, + }, + } + ], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 1, "ok": 1.0}, + msg="$currentDate must compose with $inc on a different path", + ) + + +def test_currentDate_errors_on_conflicting_unset_on_same_path(collection): + """$currentDate and $unset on the same path must conflict (code 40).""" + collection.insert_one({"_id": 1, "ts": "x"}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": { + "$currentDate": {"ts": True}, + "$unset": {"ts": ""}, + }, + } + ], + }, + ) + assertFailureCode( + result, + 40, + msg="Same-path $currentDate + $unset must conflict with code 40", + ) + + +# --------------------------------------------------------------------------- +# Surprising-but-shared field-value behavior +# --------------------------------------------------------------------------- + + +def test_currentDate_field_value_false_also_sets_date(collection): + """Both engines treat `false` field value the same as `true` — sets a Date.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 1}, "u": {"$currentDate": {"ts": False}}} + ], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 1, "ok": 1.0}, + msg="Boolean false at the field-value level is accepted; pins shared engine behavior", + ) + + +# --------------------------------------------------------------------------- +# Path semantics: dotted into / past array +# --------------------------------------------------------------------------- + + +def test_currentDate_dotted_into_array_element_by_index_sets_field(collection): + """`tags.0.ts` updates the subdocument at index 0 of the array.""" + collection.insert_one({"_id": 1, "tags": [{"a": 1}]}) + execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 1}, "u": {"$currentDate": {"tags.0.ts": True}}} + ], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties( + result, + {"tags.0.ts": IsType("date")}, + msg="Dotted path through array index must add a Date to that element", + ) + + +def test_currentDate_dotted_past_array_end_pads_with_nulls(collection): + """`tags.10` on a 2-element array pads with nulls and sets the Date at index 10.""" + collection.insert_one({"_id": 1, "tags": [10, 20]}) + execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 1}, "u": {"$currentDate": {"tags.10": True}}} + ], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties( + result, + { + "tags": Len(11), + "tags.0": Exists(), + "tags.1": Exists(), + "tags.10": IsType("date"), + }, + msg="Dotted path past end of array must pad with nulls and set the Date", + ) + + +# --------------------------------------------------------------------------- +# Overwrite matrix: replacing every existing BSON type with a Date +# --------------------------------------------------------------------------- + + +def test_currentDate_overwrites_array_field_with_date(collection): + """$currentDate replaces a whole array field with a Date scalar.""" + collection.insert_one({"_id": 1, "field": [1, 2, 3]}) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$currentDate": {"field": True}}}], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties( + result, + {"field": IsType("date")}, + msg="An array field must be replaced with a Date by $currentDate", + ) + + +def test_currentDate_overwrites_subdocument_field_with_date(collection): + """$currentDate replaces a whole subdocument field with a Date scalar.""" + collection.insert_one({"_id": 1, "field": {"a": 1}}) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$currentDate": {"field": True}}}], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties( + result, + {"field": IsType("date")}, + msg="A subdocument field must be replaced with a Date by $currentDate", + ) + + +# --------------------------------------------------------------------------- +# Command-path coverage: findAndModify +# --------------------------------------------------------------------------- + + +def test_currentDate_via_findAndModify_returns_updated_doc(collection): + """`findAndModify` applies $currentDate and returns the updated document.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$currentDate": {"ts": True}}, + "new": True, + }, + ) + assertProperties( + result, + { + "ok": Eq(1.0), + "lastErrorObject.n": Eq(1), + "lastErrorObject.updatedExisting": Eq(True), + "value._id": Eq(1), + "value.ts": IsType("date"), + }, + raw_res=True, + msg="findAndModify with $currentDate must return the updated document", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/update/fields/inc/test_inc_behaviors.py b/documentdb_tests/compatibility/tests/core/operator/update/fields/inc/test_inc_behaviors.py new file mode 100644 index 000000000..2ec8dbc25 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/fields/inc/test_inc_behaviors.py @@ -0,0 +1,795 @@ +""" +Behavior coverage for the $inc update operator. + +Oracle: MongoDB 7.0 (per functional-tests CI baseline). The operator +mutates a numeric field by adding an increment; tests assert on (a) the +structural update-command response (n / nModified / ok), (b) the +post-update document state via assertProperties (value + BSON type), or +(c) the documented error code via assertFailureCode. + +Coverage walks the case matrix in the write-compat-functional-test skill, +Step 2: +- Operator-value type matrix (top-level operand must be a document). +- Field-value type matrix (numeric accepted, non-numeric rejected). +- Existing-field type-overwrite matrix (non-numeric existing field rejected). +- Boundary values (0, negative, NaN, Infinity, Int32.MAX→Int64 promotion, + Int64.MAX overflow error). +- Path semantics (top-level, dotted, dotted-into-array-by-index, + dotted-past-array-end, dotted-through-scalar, _id target). +- Composition matrix (compose on different paths; conflict on same path). +- Command-path matrix (update, findAndModify, multi:true, upsert:true). +""" + +from datetime import datetime + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.framework.assertions import ( + assertFailureCode, + assertProperties, + assertSuccessPartial, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.property_checks import Eq, Exists, IsType, Len + +pytestmark = pytest.mark.update + + +# --------------------------------------------------------------------------- +# Operator-value type matrix (top-level operand must be a document) +# Every non-document operand must fail with FailedToParse (9). +# --------------------------------------------------------------------------- + + +def test_inc_errors_when_operand_is_null(collection): + """Inc errors when operand is null.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$inc": None}}]}, + ) + assertFailureCode(result, 9, msg="Null operand to $inc must fail with code 9") + + +def test_inc_errors_when_operand_is_array(collection): + """Inc errors when operand is array.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$inc": [1, 2]}}]}, + ) + assertFailureCode(result, 9, msg="Array operand to $inc must fail with code 9") + + +def test_inc_errors_when_operand_is_string(collection): + """Inc errors when operand is string.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$inc": "5"}}]}, + ) + assertFailureCode(result, 9, msg="String operand to $inc must fail with code 9") + + +def test_inc_errors_when_operand_is_bool(collection): + """Inc errors when operand is bool.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$inc": True}}]}, + ) + assertFailureCode(result, 9, msg="Bool operand to $inc must fail with code 9") + + +def test_inc_errors_when_operand_is_integer(collection): + """Inc errors when operand is integer.""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$inc": 42}}]}, + ) + assertFailureCode(result, 9, msg="Integer operand to $inc must fail with code 9") + + +def test_inc_empty_operand_is_noop(collection): + """Empty operand `{$inc: {}}` matches but does not modify (n=1, nModified=0).""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$inc": {}}}]}, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 0, "ok": 1.0}, + msg="Empty $inc operand must be a no-op", + ) + + +# --------------------------------------------------------------------------- +# Field-value type matrix — numeric (accepted) +# --------------------------------------------------------------------------- + + +def test_inc_with_int32_value_returns_match_counts(collection): + """Inc with int32 value returns match counts.""" + collection.insert_one({"_id": 1, "n": 10}) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$inc": {"n": 5}}}]}, + ) + assertSuccessPartial(result, {"n": 1, "nModified": 1, "ok": 1.0}) + + +def test_inc_with_int32_value_adds_correctly(collection): + """Inc with int32 value adds correctly.""" + collection.insert_one({"_id": 1, "n": 10}) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$inc": {"n": 5}}}]}, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"n": Eq(15)}, msg="10 + 5 must equal 15") + + +def test_inc_with_int64_value_adds_correctly(collection): + """Inc with int64 value adds correctly.""" + collection.insert_one({"_id": 1, "n": Int64(10)}) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$inc": {"n": Int64(5)}}}], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"n": IsType("long")}, msg="Int64 + Int64 must remain Int64") + + +def test_inc_with_double_value_adds_correctly(collection): + """Inc with double value adds correctly.""" + collection.insert_one({"_id": 1, "n": 10.0}) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$inc": {"n": 5.5}}}]}, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"n": Eq(15.5)}, msg="10.0 + 5.5 must equal 15.5") + + +def test_inc_with_decimal128_value_adds_correctly(collection): + """Inc with decimal128 value adds correctly.""" + collection.insert_one({"_id": 1, "n": Decimal128("10")}) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$inc": {"n": Decimal128("5.5")}}}], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"n": IsType("decimal")}, msg="Decimal128 sum must remain Decimal128") + + +# --------------------------------------------------------------------------- +# Cross-numeric-type arithmetic — type promotion +# --------------------------------------------------------------------------- + + +def test_inc_int_plus_double_promotes_to_double(collection): + """Inc int plus double promotes to double.""" + collection.insert_one({"_id": 1, "n": 10}) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$inc": {"n": 1.5}}}]}, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"n": IsType("double")}, msg="Int + Double must promote to Double") + + +def test_inc_int_plus_decimal128_promotes_to_decimal128(collection): + """Inc int plus decimal128 promotes to decimal128.""" + collection.insert_one({"_id": 1, "n": 10}) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$inc": {"n": Decimal128("1.5")}}}], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"n": IsType("decimal")}, msg="Int + Decimal128 must promote to Decimal128") + + +def test_inc_double_plus_int_stays_double(collection): + """Inc double plus int stays double.""" + collection.insert_one({"_id": 1, "n": 10.5}) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$inc": {"n": 1}}}]}, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"n": IsType("double")}, msg="Double + Int must remain Double") + + +# --------------------------------------------------------------------------- +# Field-value type matrix — non-numeric (must error, code 14) +# --------------------------------------------------------------------------- + + +def test_inc_errors_when_value_is_string(collection): + """Inc errors when value is string.""" + collection.insert_one({"_id": 1, "n": 10}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$inc": {"n": "x"}}}], + }, + ) + assertFailureCode(result, 14, msg="String value to $inc must fail with TypeMismatch (14)") + + +def test_inc_errors_when_value_is_bool(collection): + """Inc errors when value is bool.""" + collection.insert_one({"_id": 1, "n": 10}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$inc": {"n": True}}}], + }, + ) + assertFailureCode(result, 14, msg="Bool value to $inc must fail with TypeMismatch (14)") + + +def test_inc_errors_when_value_is_date(collection): + """Inc errors when value is date.""" + collection.insert_one({"_id": 1, "n": 10}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 1}, "u": {"$inc": {"n": datetime(2024, 1, 1)}}} + ], + }, + ) + assertFailureCode(result, 14, msg="Date value to $inc must fail with TypeMismatch (14)") + + +def test_inc_errors_when_value_is_array(collection): + """Inc errors when value is array.""" + collection.insert_one({"_id": 1, "n": 10}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$inc": {"n": [1, 2]}}}], + }, + ) + assertFailureCode(result, 14, msg="Array value to $inc must fail with TypeMismatch (14)") + + +def test_inc_errors_when_value_is_document(collection): + """Inc errors when value is document.""" + collection.insert_one({"_id": 1, "n": 10}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$inc": {"n": {"a": 1}}}}], + }, + ) + assertFailureCode(result, 14, msg="Document value to $inc must fail with TypeMismatch (14)") + + +def test_inc_errors_when_value_is_null(collection): + """Inc errors when value is null.""" + collection.insert_one({"_id": 1, "n": 10}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$inc": {"n": None}}}], + }, + ) + assertFailureCode(result, 14, msg="Null value to $inc must fail with TypeMismatch (14)") + + +# --------------------------------------------------------------------------- +# Existing-field type-overwrite matrix — non-numeric field rejected (code 14) +# --------------------------------------------------------------------------- + + +def test_inc_errors_when_existing_field_is_string(collection): + """Inc errors when existing field is string.""" + collection.insert_one({"_id": 1, "n": "hi"}) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$inc": {"n": 1}}}]}, + ) + assertFailureCode(result, 14, msg="$inc against string field must fail with code 14") + + +@pytest.mark.engine_xfail( + engine="pgmongo", + reason=( + "$inc against a bool-typed field: native MongoDB rejects with code 14 " + "('non-numeric type bool'); documentdb coerces the bool to 0/1 and " + "applies the increment, producing an Int32 result." + ), + raises=AssertionError, +) +def test_inc_errors_when_existing_field_is_bool(collection): + """Inc errors when existing field is bool.""" + collection.insert_one({"_id": 1, "n": True}) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$inc": {"n": 1}}}]}, + ) + assertFailureCode(result, 14, msg="$inc against bool field must fail with code 14") + + +def test_inc_errors_when_existing_field_is_date(collection): + """Inc errors when existing field is date.""" + collection.insert_one({"_id": 1, "n": datetime(2024, 1, 1)}) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$inc": {"n": 1}}}]}, + ) + assertFailureCode(result, 14, msg="$inc against Date field must fail with code 14") + + +def test_inc_errors_when_existing_field_is_array(collection): + """Inc errors when existing field is array.""" + collection.insert_one({"_id": 1, "n": [1, 2]}) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$inc": {"n": 1}}}]}, + ) + assertFailureCode(result, 14, msg="$inc against array field must fail with code 14") + + +def test_inc_errors_when_existing_field_is_document(collection): + """Inc errors when existing field is document.""" + collection.insert_one({"_id": 1, "n": {"a": 1}}) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$inc": {"n": 1}}}]}, + ) + assertFailureCode(result, 14, msg="$inc against document field must fail with code 14") + + +def test_inc_errors_when_existing_field_is_null(collection): + """Inc errors when existing field is null.""" + collection.insert_one({"_id": 1, "n": None}) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$inc": {"n": 1}}}]}, + ) + assertFailureCode(result, 14, msg="$inc against null field must fail with code 14") + + +# --------------------------------------------------------------------------- +# Boundary values +# --------------------------------------------------------------------------- + + +def test_inc_by_negative_subtracts(collection): + """Inc by negative subtracts.""" + collection.insert_one({"_id": 1, "n": 10}) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$inc": {"n": -3}}}]}, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"n": Eq(7)}, msg="$inc by -3 must subtract") + + +def test_inc_by_nan_yields_nan(collection): + """Adding NaN to a numeric field yields NaN (per IEEE-754).""" + collection.insert_one({"_id": 1, "n": 10.0}) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$inc": {"n": float("nan")}}}], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"n": IsType("double")}, msg="NaN result must remain a Double-typed field") + + +def test_inc_by_positive_infinity_yields_infinity(collection): + """Inc by positive infinity yields infinity.""" + collection.insert_one({"_id": 1, "n": 10.0}) + execute_command( + collection, + { + "update": collection.name, + "updates": [ + {"q": {"_id": 1}, "u": {"$inc": {"n": float("inf")}}} + ], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"n": Eq(float("inf"))}, msg="$inc by +Inf must yield +Inf") + + +def test_inc_int32_max_overflow_promotes_to_int64(collection): + """Incrementing Int32.MAX by 1 promotes the field to Int64 (no error).""" + collection.insert_one({"_id": 1, "n": 2147483647}) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$inc": {"n": 1}}}]}, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"n": IsType("long")}, msg="Int32 overflow must promote to Int64") + + +def test_inc_int64_max_overflow_errors(collection): + """Incrementing Int64.MAX by 1 cannot be represented; must fail with code 2.""" + collection.insert_one({"_id": 1, "n": Int64(9223372036854775807)}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$inc": {"n": Int64(1)}}}], + }, + ) + assertFailureCode( + result, 2, msg="Int64 overflow on $inc must fail with code 2" + ) + + +# --------------------------------------------------------------------------- +# Implicit creation +# --------------------------------------------------------------------------- + + +def test_inc_creates_missing_field_with_value(collection): + """$inc on a missing field creates the field with the increment as its value.""" + collection.insert_one({"_id": 1}) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$inc": {"newf": 5}}}], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"newf": Eq(5)}, msg="Missing field must be created with the increment as value") + + +def test_inc_creates_missing_intermediate_documents(collection): + """$inc on a deeply nested missing path creates the intermediate documents.""" + collection.insert_one({"_id": 1}) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$inc": {"a.b.c": 5}}}], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"a.b.c": Eq(5)}, msg="Nested missing path must auto-create") + + +# --------------------------------------------------------------------------- +# Path semantics +# --------------------------------------------------------------------------- + + +def test_inc_dotted_into_array_element_by_index(collection): + """`a.0.n` targets the field within the array element at index 0.""" + collection.insert_one({"_id": 1, "a": [{"n": 10}]}) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$inc": {"a.0.n": 5}}}], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"a.0.n": Eq(15)}, msg="Dotted path through array index must increment that element") + + +def test_inc_dotted_past_array_end_pads_with_nulls(collection): + """`a.10` on a 2-element array pads with nulls and sets the increment at index 10.""" + collection.insert_one({"_id": 1, "a": [10, 20]}) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$inc": {"a.10": 5}}}], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties( + result, + {"a": Len(11), "a.10": Eq(5)}, + msg="Dotted path past end of array must pad with nulls and set the increment", + ) + + +def test_inc_errors_on_dotted_path_through_scalar(collection): + """$inc on a dotted path whose intermediate is a scalar must fail with PathNotViable (28).""" + collection.insert_one({"_id": 1, "name": "John"}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$inc": {"name.x": 1}}}], + }, + ) + assertFailureCode(result, 28, msg="$inc through scalar intermediate must fail with PathNotViable (28)") + + +def test_inc_errors_on_id_target(collection): + """$inc against _id must fail with ImmutableField (66).""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$inc": {"_id": 1}}}], + }, + ) + assertFailureCode(result, 66, msg="$inc against _id must fail with ImmutableField (66)") + + +# --------------------------------------------------------------------------- +# Composition matrix +# --------------------------------------------------------------------------- + + +def test_inc_composes_with_set_on_different_path(collection): + """$inc and $set on different paths combine cleanly.""" + collection.insert_one({"_id": 1, "n": 0}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$inc": {"n": 1}, "$set": {"x": "y"}}, + } + ], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 1, "ok": 1.0}, + msg="$inc must compose with $set on a different path", + ) + + +def test_inc_errors_on_conflicting_set_on_same_path(collection): + """$inc and $set on the same path must conflict (code 40).""" + collection.insert_one({"_id": 1, "n": 0}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$inc": {"n": 1}, "$set": {"n": 5}}, + } + ], + }, + ) + assertFailureCode(result, 40, msg="Same-path $inc + $set must conflict with code 40") + + +def test_inc_errors_on_conflicting_mul_on_same_path(collection): + """$inc and $mul on the same path must conflict (code 40).""" + collection.insert_one({"_id": 1, "n": 0}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$inc": {"n": 1}, "$mul": {"n": 2}}, + } + ], + }, + ) + assertFailureCode(result, 40, msg="Same-path $inc + $mul must conflict with code 40") + + +# --------------------------------------------------------------------------- +# Command-path matrix: multi, upsert, findAndModify +# --------------------------------------------------------------------------- + + +def test_inc_multi_true_returns_n_2_nModified_2(collection): + """Inc multi true returns n 2 nmodified 2.""" + collection.insert_many([{"_id": 1, "n": 0}, {"_id": 2, "n": 0}]) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {}, "u": {"$inc": {"n": 1}}, "multi": True}], + }, + ) + assertSuccessPartial( + result, + {"n": 2, "nModified": 2, "ok": 1.0}, + msg="multi:true must increment every matching document", + ) + + +def test_inc_multi_true_increments_all_documents(collection): + """Inc multi true increments all documents.""" + collection.insert_many([{"_id": 1, "n": 0}, {"_id": 2, "n": 0}]) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {}, "u": {"$inc": {"n": 1}}, "multi": True}], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {}, "sort": {"_id": 1}}, + ) + assertProperties( + result, + { + "cursor.firstBatch": Len(2), + "cursor.firstBatch.0.n": Eq(1), + "cursor.firstBatch.1.n": Eq(1), + }, + raw_res=True, + msg="multi:true must set every matched n to 1", + ) + + +def test_inc_upsert_creates_document_with_increment(collection): + """Inc upsert creates document with increment.""" + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 99}, + "u": {"$inc": {"n": 5}}, + "upsert": True, + } + ], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 0, "ok": 1.0}, + msg="Upsert with $inc must report n=1 / nModified=0", + ) + + +def test_inc_upsert_inserted_document_has_field_set(collection): + """Inc upsert inserted document has field set.""" + execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 99}, + "u": {"$inc": {"n": 5}}, + "upsert": True, + } + ], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 99}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"n": Eq(5)}, msg="Upserted document must contain n=5") + + +def test_inc_via_findAndModify_returns_updated_doc(collection): + """`findAndModify` applies $inc and returns the updated document.""" + collection.insert_one({"_id": 1, "n": 10}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$inc": {"n": 5}}, + "new": True, + }, + ) + assertProperties( + result, + { + "ok": Eq(1.0), + "lastErrorObject.n": Eq(1), + "lastErrorObject.updatedExisting": Eq(True), + "value.n": Eq(15), + }, + raw_res=True, + msg="findAndModify with $inc must return updated document with n=15", + ) + + +# --------------------------------------------------------------------------- +# Engine divergence +# --------------------------------------------------------------------------- + + +@pytest.mark.engine_xfail( + engine="pgmongo", + reason=( + "$inc by 0 against an existing numeric field: native MongoDB reports " + "n=1 / nModified=0 (recognises the no-op); documentdb reports " + "nModified=1 even though the document value is unchanged." + ), + raises=AssertionError, +) +def test_inc_by_zero_reports_nModified_0_as_noop(collection): + """$inc by 0 leaves the value unchanged and reports nModified=0.""" + collection.insert_one({"_id": 1, "n": 10}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$inc": {"n": 0}}}], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 0, "ok": 1.0}, + msg="$inc by 0 must be reported as a no-op (nModified=0)", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/update/fields/min/test_min_behaviors.py b/documentdb_tests/compatibility/tests/core/operator/update/fields/min/test_min_behaviors.py new file mode 100644 index 000000000..1a9ab58b0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/update/fields/min/test_min_behaviors.py @@ -0,0 +1,757 @@ +""" +Behavior coverage for the $min update operator. + +Oracle: MongoDB 7.0 (per functional-tests CI baseline). $min sets the +field to the smaller of the current value and the supplied value (BSON +type-aware: per the canonical type ordering, Null < Number < String < +Document < Array < Binary < ObjectId < Bool < Date < Timestamp < Regex). + +Coverage walks the case matrix in the write-compat-functional-test skill, +Step 2: operator-value type matrix, field-value comparison behaviour +(greater/lesser/equal), numeric-type cross-comparison, non-numeric +comparisons (string, date), cross-BSON-type comparisons, path semantics, +composition matrix, command-path matrix. +""" + +from datetime import datetime + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.framework.assertions import ( + assertFailureCode, + assertProperties, + assertSuccessPartial, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.property_checks import Eq, IsType, Len + +pytestmark = pytest.mark.update + + +# --------------------------------------------------------------------------- +# Operator-value type matrix (top-level operand must be a document) +# --------------------------------------------------------------------------- + + +def test_min_errors_when_operand_is_null(collection): + """Null operand to $min must fail with FailedToParse (9).""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$min": None}}]}, + ) + assertFailureCode(result, 9, msg="Null operand to $min must fail with code 9") + + +def test_min_errors_when_operand_is_array(collection): + """Array operand to $min must fail with FailedToParse (9).""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$min": [1]}}]}, + ) + assertFailureCode(result, 9, msg="Array operand to $min must fail with code 9") + + +def test_min_errors_when_operand_is_string(collection): + """String operand to $min must fail with FailedToParse (9).""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$min": "x"}}]}, + ) + assertFailureCode(result, 9, msg="String operand to $min must fail with code 9") + + +def test_min_errors_when_operand_is_bool(collection): + """Bool operand to $min must fail with FailedToParse (9).""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$min": True}}]}, + ) + assertFailureCode(result, 9, msg="Bool operand to $min must fail with code 9") + + +def test_min_errors_when_operand_is_integer(collection): + """Integer operand to $min must fail with FailedToParse (9).""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$min": 5}}]}, + ) + assertFailureCode(result, 9, msg="Integer operand to $min must fail with code 9") + + +def test_min_empty_operand_is_noop(collection): + """Empty operand `{$min: {}}` matches but does not modify (n=1, nModified=0).""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$min": {}}}]}, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 0, "ok": 1.0}, + msg="Empty $min operand must be a no-op", + ) + + +# --------------------------------------------------------------------------- +# Core comparison contract: smaller wins, equal is no-op, larger is no-op +# --------------------------------------------------------------------------- + + +def test_min_existing_greater_than_supplied_updates_value(collection): + """When existing value > supplied value, $min replaces with supplied.""" + collection.insert_one({"_id": 1, "s": 10}) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$min": {"s": 5}}}]}, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"s": Eq(5)}, msg="existing 10 > supplied 5 must replace with 5") + + +def test_min_existing_greater_reports_nModified_1(collection): + """The response reports nModified=1 when $min actually replaces a value.""" + collection.insert_one({"_id": 1, "s": 10}) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$min": {"s": 5}}}]}, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 1, "ok": 1.0}, + msg="An actual replacement must report nModified=1", + ) + + +def test_min_existing_less_than_supplied_is_noop_value(collection): + """When existing value < supplied value, $min keeps the existing value.""" + collection.insert_one({"_id": 1, "s": 3}) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$min": {"s": 5}}}]}, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"s": Eq(3)}, msg="existing 3 < supplied 5 must keep 3") + + +def test_min_existing_less_than_supplied_reports_nModified_0(collection): + """The response reports nModified=0 when $min is a no-op.""" + collection.insert_one({"_id": 1, "s": 3}) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$min": {"s": 5}}}]}, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 0, "ok": 1.0}, + msg="A no-op must report nModified=0 (matched but unchanged)", + ) + + +def test_min_existing_equal_to_supplied_reports_nModified_0(collection): + """When existing == supplied, $min is a no-op (nModified=0).""" + collection.insert_one({"_id": 1, "s": 5}) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$min": {"s": 5}}}]}, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 0, "ok": 1.0}, + msg="Equal values must report nModified=0", + ) + + +# --------------------------------------------------------------------------- +# Numeric type comparisons (same type) +# --------------------------------------------------------------------------- + + +def test_min_int_vs_smaller_int_picks_smaller(collection): + """Int32 vs smaller Int32: picks the smaller.""" + collection.insert_one({"_id": 1, "n": 10}) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$min": {"n": 3}}}]}, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"n": Eq(3)}, msg="Int 10 vs Int 3 → picks 3") + + +def test_min_int64_vs_smaller_int64_picks_smaller(collection): + """Int64 vs smaller Int64: picks the smaller (preserves Int64 type).""" + collection.insert_one({"_id": 1, "n": Int64(10)}) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$min": {"n": Int64(3)}}}], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"n": IsType("long")}, msg="Int64 vs Int64 → result is Int64") + + +def test_min_double_vs_smaller_double_picks_smaller(collection): + """Double vs smaller Double: picks the smaller.""" + collection.insert_one({"_id": 1, "n": 10.5}) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$min": {"n": 3.2}}}]}, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"n": Eq(3.2)}, msg="Double 10.5 vs Double 3.2 → picks 3.2") + + +def test_min_decimal128_vs_smaller_decimal128_picks_smaller(collection): + """Decimal128 vs smaller Decimal128: picks the smaller (preserves Decimal128 type).""" + collection.insert_one({"_id": 1, "n": Decimal128("10.5")}) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$min": {"n": Decimal128("3.2")}}}], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"n": IsType("decimal")}, msg="Decimal128 result must remain Decimal128") + + +# --------------------------------------------------------------------------- +# Cross-numeric-type comparisons +# --------------------------------------------------------------------------- + + +def test_min_int_numerically_equal_to_double_is_noop(collection): + """Int 5 and Double 5.0 are numerically equal — no-op.""" + collection.insert_one({"_id": 1, "n": 5}) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$min": {"n": 5.0}}}]}, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 0, "ok": 1.0}, + msg="Numerically-equal cross-type values must be a no-op", + ) + + +def test_min_int_vs_smaller_double_picks_double(collection): + """Int existing vs smaller Double supplied: picks Double (replaces type).""" + collection.insert_one({"_id": 1, "n": 10}) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$min": {"n": 3.5}}}]}, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"n": Eq(3.5)}, msg="Int 10 vs Double 3.5 → picks 3.5") + + +# --------------------------------------------------------------------------- +# Special numeric values: NaN, Infinity +# --------------------------------------------------------------------------- + + +def test_min_existing_nan_keeps_nan(collection): + """Existing NaN remains NaN — NaN is treated as the smallest numeric value.""" + collection.insert_one({"_id": 1, "n": float("nan")}) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$min": {"n": 10}}}]}, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 0, "ok": 1.0}, + msg="Existing NaN must remain — NaN is the smallest numeric", + ) + + +def test_min_negative_infinity_supplied_replaces_int(collection): + """-Infinity is smaller than any finite number; $min replaces with -Infinity.""" + collection.insert_one({"_id": 1, "n": 10}) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$min": {"n": float("-inf")}}}], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"n": Eq(float("-inf"))}, msg="-Infinity wins over Int 10") + + +def test_min_positive_infinity_supplied_is_noop(collection): + """+Infinity is larger than any finite number; $min is a no-op.""" + collection.insert_one({"_id": 1, "n": 10}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$min": {"n": float("inf")}}}], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 0, "ok": 1.0}, + msg="+Infinity vs existing finite int must be a no-op", + ) + + +# --------------------------------------------------------------------------- +# Missing-field behavior — creates with supplied value +# --------------------------------------------------------------------------- + + +def test_min_missing_field_creates_with_supplied_value(collection): + """When the field is absent, $min creates it with the supplied value.""" + collection.insert_one({"_id": 1}) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$min": {"newf": 5}}}], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"newf": Eq(5)}, msg="Missing field must be created with supplied value") + + +# --------------------------------------------------------------------------- +# Non-numeric same-type comparisons (string lex, date chronological) +# --------------------------------------------------------------------------- + + +def test_min_string_vs_lex_smaller_string_picks_smaller(collection): + """String values are compared lexicographically; $min picks the smaller.""" + collection.insert_one({"_id": 1, "s": "banana"}) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$min": {"s": "apple"}}}]}, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"s": Eq("apple")}, msg="'banana' vs 'apple' → picks 'apple' (lex)") + + +def test_min_string_vs_lex_greater_string_is_noop(collection): + """When the supplied string is lex-greater, $min is a no-op.""" + collection.insert_one({"_id": 1, "s": "apple"}) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$min": {"s": "banana"}}}]}, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 0, "ok": 1.0}, + msg="'apple' < 'banana' lex; supplied is larger so no-op", + ) + + +def test_min_date_vs_earlier_date_picks_earlier(collection): + """Date comparison is chronological; earlier wins.""" + collection.insert_one({"_id": 1, "d": datetime(2024, 1, 1)}) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$min": {"d": datetime(2020, 1, 1)}}}], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"d": IsType("date")}, msg="$min on Date keeps a Date result") + + +def test_min_date_vs_later_date_is_noop(collection): + """When supplied date is later, $min is a no-op.""" + collection.insert_one({"_id": 1, "d": datetime(2020, 1, 1)}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$min": {"d": datetime(2024, 1, 1)}}}], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 0, "ok": 1.0}, + msg="Existing 2020 < supplied 2024 → no-op", + ) + + +# --------------------------------------------------------------------------- +# Cross-BSON-type comparisons (per canonical type order: Null < Number < String < ...) +# --------------------------------------------------------------------------- + + +def test_min_int_vs_string_keeps_int(collection): + """BSON type order: Number < String; existing Int wins over supplied String.""" + collection.insert_one({"_id": 1, "n": 10}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$min": {"n": "abc"}}}], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 0, "ok": 1.0}, + msg="Int < String in BSON type order; existing Int must win", + ) + + +def test_min_string_vs_int_replaces_with_int(collection): + """BSON type order: Number < String; supplied Int wins over existing String.""" + collection.insert_one({"_id": 1, "n": "abc"}) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$min": {"n": 10}}}]}, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"n": Eq(10)}, msg="Int < String in BSON order; supplied Int replaces String") + + +def test_min_null_vs_int_keeps_null(collection): + """BSON type order: Null < Number; existing Null wins over supplied Int.""" + collection.insert_one({"_id": 1, "n": None}) + result = execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$min": {"n": 10}}}]}, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 0, "ok": 1.0}, + msg="Null < Number in BSON type order; existing Null must win", + ) + + +# --------------------------------------------------------------------------- +# Path semantics +# --------------------------------------------------------------------------- + + +def test_min_dotted_path_creates_intermediate_doc(collection): + """$min on a dotted path creates the intermediate subdocument.""" + collection.insert_one({"_id": 1}) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$min": {"a.b": 5}}}]}, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"a.b": Eq(5)}, msg="Dotted path must create the intermediate subdocument") + + +def test_min_dotted_into_array_element_by_index(collection): + """`a.0.n` updates the subdocument at index 0 of the array.""" + collection.insert_one({"_id": 1, "a": [{"n": 10}]}) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$min": {"a.0.n": 3}}}], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"a.0.n": Eq(3)}, msg="Dotted-into-array-index must update that element") + + +def test_min_dotted_past_array_end_pads_with_nulls(collection): + """`a.10` on a 2-element array pads with nulls and sets the supplied value at index 10.""" + collection.insert_one({"_id": 1, "a": [10, 20]}) + execute_command( + collection, + {"update": collection.name, "updates": [{"q": {"_id": 1}, "u": {"$min": {"a.10": 5}}}]}, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 1}, "sort": {"_id": 1}}, + ) + assertProperties( + result, + {"a": Len(11), "a.10": Eq(5)}, + msg="Dotted past array end must pad with nulls and set value", + ) + + +def test_min_errors_on_dotted_path_through_scalar(collection): + """$min through a scalar intermediate must fail with PathNotViable (28).""" + collection.insert_one({"_id": 1, "name": "John"}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$min": {"name.x": 1}}}], + }, + ) + assertFailureCode(result, 28, msg="$min through scalar intermediate must fail with code 28") + + +def test_min_errors_on_id_target(collection): + """$min targeting _id must fail with ImmutableField (66).""" + collection.insert_one({"_id": 1}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$min": {"_id": 0}}}], + }, + ) + assertFailureCode(result, 66, msg="$min against _id must fail with ImmutableField (66)") + + +# --------------------------------------------------------------------------- +# Composition matrix +# --------------------------------------------------------------------------- + + +def test_min_composes_with_set_on_different_path(collection): + """$min and $set on different paths combine without conflict.""" + collection.insert_one({"_id": 1, "n": 10}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$min": {"n": 5}, "$set": {"x": "y"}}, + } + ], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 1, "ok": 1.0}, + msg="$min must compose with $set on a different path", + ) + + +def test_min_errors_on_conflicting_set_on_same_path(collection): + """$min and $set on the same path must conflict with code 40.""" + collection.insert_one({"_id": 1, "n": 10}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$min": {"n": 5}, "$set": {"n": 99}}, + } + ], + }, + ) + assertFailureCode(result, 40, msg="Same-path $min + $set must conflict with code 40") + + +def test_min_errors_on_conflicting_max_on_same_path(collection): + """$min and $max on the same path must conflict with code 40.""" + collection.insert_one({"_id": 1, "n": 10}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 1}, + "u": {"$min": {"n": 5}, "$max": {"n": 20}}, + } + ], + }, + ) + assertFailureCode(result, 40, msg="Same-path $min + $max must conflict with code 40") + + +# --------------------------------------------------------------------------- +# Command-path matrix +# --------------------------------------------------------------------------- + + +def test_min_multi_true_modifies_only_docs_that_change(collection): + """multi:true reports n=matched-docs and nModified=actually-replaced.""" + collection.insert_many([{"_id": 1, "n": 10}, {"_id": 2, "n": 1}]) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {}, "u": {"$min": {"n": 5}}, "multi": True}], + }, + ) + assertSuccessPartial( + result, + {"n": 2, "nModified": 1, "ok": 1.0}, + msg="multi:true must match both but only modify the doc whose existing > 5", + ) + + +def test_min_multi_true_keeps_smaller_existing_values(collection): + """After multi:true, the doc whose existing value was smaller is unchanged.""" + collection.insert_many([{"_id": 1, "n": 10}, {"_id": 2, "n": 1}]) + execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {}, "u": {"$min": {"n": 5}}, "multi": True}], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {}, "sort": {"_id": 1}}, + ) + assertProperties( + result, + { + "cursor.firstBatch": Len(2), + "cursor.firstBatch.0.n": Eq(5), + "cursor.firstBatch.1.n": Eq(1), + }, + raw_res=True, + msg="Doc 1 (was 10) becomes 5; doc 2 (was 1) stays at 1", + ) + + +def test_min_upsert_creates_document_with_supplied_value(collection): + """Upsert with $min reports n=1 / nModified=0 on the insert path.""" + result = execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 99}, + "u": {"$min": {"n": 5}}, + "upsert": True, + } + ], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 0, "ok": 1.0}, + msg="Upsert with $min must report n=1 / nModified=0", + ) + + +def test_min_upsert_inserted_document_has_supplied_value(collection): + """The upserted document contains the field with the supplied value.""" + execute_command( + collection, + { + "update": collection.name, + "updates": [ + { + "q": {"_id": 99}, + "u": {"$min": {"n": 5}}, + "upsert": True, + } + ], + }, + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"_id": 99}, "sort": {"_id": 1}}, + ) + assertProperties(result, {"n": Eq(5)}, msg="Upserted document must contain n=5") + + +def test_min_via_findAndModify_returns_updated_doc(collection): + """findAndModify applies $min and returns the updated document.""" + collection.insert_one({"_id": 1, "n": 10}) + result = execute_command( + collection, + { + "findAndModify": collection.name, + "query": {"_id": 1}, + "update": {"$min": {"n": 3}}, + "new": True, + }, + ) + assertProperties( + result, + { + "ok": Eq(1.0), + "lastErrorObject.n": Eq(1), + "lastErrorObject.updatedExisting": Eq(True), + "value.n": Eq(3), + }, + raw_res=True, + msg="findAndModify with $min must return updated document with n=3", + ) + + +# --------------------------------------------------------------------------- +# Engine divergence +# --------------------------------------------------------------------------- + + +@pytest.mark.engine_xfail( + engine="pgmongo", + reason=( + "$min comparison against existing finite numeric when supplied is " + "NaN: native MongoDB treats NaN as the smallest numeric (replaces " + "with NaN, nModified=1); documentdb treats the existing finite " + "value as smaller (no-op, nModified=0)." + ), + raises=AssertionError, +) +def test_min_supplied_nan_replaces_existing_finite(collection): + """Supplied NaN should win over existing finite numeric (NaN is smallest).""" + collection.insert_one({"_id": 1, "n": 10}) + result = execute_command( + collection, + { + "update": collection.name, + "updates": [{"q": {"_id": 1}, "u": {"$min": {"n": float("nan")}}}], + }, + ) + assertSuccessPartial( + result, + {"n": 1, "nModified": 1, "ok": 1.0}, + msg="Supplied NaN must replace existing finite (NaN < any finite number)", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/window/addToSet/test_window_addToSet_behaviors.py b/documentdb_tests/compatibility/tests/core/operator/window/addToSet/test_window_addToSet_behaviors.py new file mode 100644 index 000000000..dc8dc97a8 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/window/addToSet/test_window_addToSet_behaviors.py @@ -0,0 +1,289 @@ +""" +Behavior coverage for the $addToSet window operator inside $setWindowFields. + +Oracle: MongoDB 7.0. The accumulator returns the set of distinct values seen +inside the window; tests assert set equality (not array order) for engine +agnosticism. +""" + +import pytest + +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccess +from documentdb_tests.framework.executor import execute_command + +pytestmark = pytest.mark.aggregate + + +def test_window_addToSet_bounded_documents_window(collection): + """Sliding [-1, 0] window yields the distinct values from current + prior doc.""" + collection.insert_many( + [ + {"_id": 1, "t": 1, "v": 10}, + {"_id": 2, "t": 2, "v": 20}, + {"_id": 3, "t": 3, "v": 10}, + {"_id": 4, "t": 4, "v": 30}, + ] + ) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "sortBy": {"t": 1}, + "output": { + "unique": { + "$addToSet": "$v", + "window": {"documents": [-1, 0]}, + } + }, + } + }, + {"$sort": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + assertSuccess( + result, + [ + {"_id": 1, "t": 1, "v": 10, "unique": [10]}, + {"_id": 2, "t": 2, "v": 20, "unique": [10, 20]}, + {"_id": 3, "t": 3, "v": 10, "unique": [10, 20]}, + {"_id": 4, "t": 4, "v": 30, "unique": [10, 30]}, + ], + ignore_order_in=["unique"], + msg="Bounded window $addToSet must yield distinct values per window", + ) + + +def test_window_addToSet_partitionBy_resets_per_partition(collection): + """partitionBy restricts the window to documents within the same partition.""" + collection.insert_many( + [ + {"_id": 1, "p": "x", "v": 1}, + {"_id": 2, "p": "x", "v": 1}, + {"_id": 3, "p": "y", "v": 1}, + ] + ) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "partitionBy": "$p", + "sortBy": {"_id": 1}, + "output": {"unique": {"$addToSet": "$v"}}, + } + }, + {"$sort": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + assertSuccess( + result, + [ + {"_id": 1, "p": "x", "v": 1, "unique": [1]}, + {"_id": 2, "p": "x", "v": 1, "unique": [1]}, + {"_id": 3, "p": "y", "v": 1, "unique": [1]}, + ], + ignore_order_in=["unique"], + msg="Each partition computes its own set independently", + ) + + +# --------------------------------------------------------------------------- +# Window-bounds variants +# --------------------------------------------------------------------------- + + +def test_window_addToSet_unbounded_window_collects_full_partition(collection): + """An unbounded window yields the union of all values in the partition for every row.""" + collection.insert_many( + [ + {"_id": 1, "t": 1, "v": 10}, + {"_id": 2, "t": 2, "v": 20}, + {"_id": 3, "t": 3, "v": 10}, + ] + ) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "sortBy": {"t": 1}, + "output": { + "all_unique": { + "$addToSet": "$v", + "window": {"documents": ["unbounded", "unbounded"]}, + } + }, + } + }, + {"$sort": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + assertSuccess( + result, + [ + {"_id": 1, "t": 1, "v": 10, "all_unique": [10, 20]}, + {"_id": 2, "t": 2, "v": 20, "all_unique": [10, 20]}, + {"_id": 3, "t": 3, "v": 10, "all_unique": [10, 20]}, + ], + ignore_order_in=["all_unique"], + msg="Unbounded window must include the full partition for every row", + ) + + +def test_window_addToSet_self_only_window_yields_single_element(collection): + """`documents: [0, 0]` yields a single-element set per row (the row's own value).""" + collection.insert_many( + [{"_id": 1, "v": 10}, {"_id": 2, "v": 20}] + ) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "sortBy": {"_id": 1}, + "output": { + "self": { + "$addToSet": "$v", + "window": {"documents": [0, 0]}, + } + }, + } + }, + {"$sort": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + assertSuccess( + result, + [ + {"_id": 1, "v": 10, "self": [10]}, + {"_id": 2, "v": 20, "self": [20]}, + ], + ignore_order_in=["self"], + msg="`documents: [0, 0]` must contain only the current row's value", + ) + + +def test_window_addToSet_range_based_window(collection): + """A range-based window includes documents whose sort-key value is within [-1, 1].""" + collection.insert_many( + [ + {"_id": 1, "t": 0, "v": 10}, + {"_id": 2, "t": 1, "v": 20}, + {"_id": 3, "t": 5, "v": 30}, + ] + ) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "sortBy": {"t": 1}, + "output": { + "in_range": { + "$addToSet": "$v", + "window": {"range": [-1, 1]}, + } + }, + } + }, + {"$sort": {"_id": 1}}, + ], + "cursor": {}, + }, + ) + assertSuccess( + result, + [ + {"_id": 1, "t": 0, "v": 10, "in_range": [10, 20]}, + {"_id": 2, "t": 1, "v": 20, "in_range": [10, 20]}, + {"_id": 3, "t": 5, "v": 30, "in_range": [30]}, + ], + ignore_order_in=["in_range"], + msg="Range-based window must include rows whose sort-key value is within bounds", + ) + + +# --------------------------------------------------------------------------- +# Shared-error cases (must fail with the same code on native and on documentdb) +# --------------------------------------------------------------------------- + + +def test_window_addToSet_errors_when_documents_and_unit_both_set(collection): + """Window bounds combining `documents` and `unit` must fail with FailedToParse (9).""" + collection.insert_one({"_id": 1, "t": 0, "v": 10}) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "sortBy": {"t": 1}, + "output": { + "bad": { + "$addToSet": "$v", + "window": { + "documents": [-1, 0], + "unit": "second", + }, + } + }, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode( + result, + 9, + msg="`documents` and `unit` set together must fail with FailedToParse (9)", + ) + + +def test_window_addToSet_errors_when_no_sortBy_for_documents_window(collection): + """A document-based window without sortBy must fail with code 5339901.""" + collection.insert_one({"_id": 1, "v": 10}) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$setWindowFields": { + "output": { + "bad": { + "$addToSet": "$v", + "window": {"documents": [-1, 0]}, + } + } + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode( + result, + 5339901, + msg="Document-based window without sortBy must fail with code 5339901", + )