From b6252bcb14be90679ffc872cf6187278255b2596 Mon Sep 17 00:00:00 2001 From: Daniel Frankcom Date: Mon, 15 Jun 2026 09:03:53 -0700 Subject: [PATCH] Add collMod command tests Signed-off-by: Daniel Frankcom --- .../collMod/test_collMod_capped_max.py | 335 ++++++++++++++ .../collMod/test_collMod_capped_size.py | 347 +++++++++++++++ .../collMod/test_collMod_change_streams.py | 235 ++++++++++ .../commands/collMod/test_collMod_comment.py | 91 ++++ .../commands/collMod/test_collMod_dry_run.py | 278 ++++++++++++ .../collMod/test_collMod_index_argument.py | 155 +++++++ .../collMod/test_collMod_index_expire.py | 353 +++++++++++++++ .../collMod/test_collMod_index_hidden.py | 274 ++++++++++++ .../collMod/test_collMod_index_identifier.py | 321 ++++++++++++++ .../test_collMod_index_prepare_unique.py | 317 ++++++++++++++ .../collMod/test_collMod_index_unique.py | 241 ++++++++++ .../collMod/test_collMod_interactions.py | 394 +++++++++++++++++ .../commands/collMod/test_collMod_pipeline.py | 411 ++++++++++++++++++ .../collMod/test_collMod_read_concern.py | 201 +++++++++ .../collMod/test_collMod_target_name.py | 151 +++++++ .../test_collMod_time_series_bucketing.py | 369 ++++++++++++++++ .../test_collMod_time_series_document.py | 171 ++++++++ .../test_collMod_time_series_expire.py | 371 ++++++++++++++++ .../test_collMod_time_series_granularity.py | 190 ++++++++ .../test_collMod_validation_level_action.py | 254 +++++++++++ .../collMod/test_collMod_validator.py | 410 +++++++++++++++++ .../commands/collMod/test_collMod_view_on.py | 184 ++++++++ .../collMod/test_collMod_write_concern.py | 343 +++++++++++++++ documentdb_tests/framework/error_codes.py | 1 + documentdb_tests/framework/test_constants.py | 1 + 25 files changed, 6398 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_capped_max.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_capped_size.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_change_streams.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_comment.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_dry_run.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_argument.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_expire.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_hidden.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_identifier.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_prepare_unique.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_unique.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_interactions.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_pipeline.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_read_concern.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_target_name.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_bucketing.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_document.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_expire.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_granularity.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_validation_level_action.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_validator.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_view_on.py create mode 100644 documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_write_concern.py diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_capped_max.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_capped_max.py new file mode 100644 index 000000000..7dfd95807 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_capped_max.py @@ -0,0 +1,335 @@ +"""Tests for collMod cappedMax and cappedSize/cappedMax coexistence on capped collections.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + INVALID_OPTIONS_ERROR, + NAMESPACE_NOT_FOUND_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.target_collection import ( + CappedCollection, + ClusteredCollection, + ExistingDatabase, + ViewCollection, +) +from documentdb_tests.framework.test_constants import ( + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_OVERFLOW, +) + +# Property [cappedMax Numeric Type Acceptance]: cappedMax accepts any numeric type. +COLLMOD_CAPPED_MAX_NUMERIC_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "max_numeric_int32", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": 1_000}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an int32 cappedMax", + ), + CommandTestCase( + "max_numeric_int64", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": Int64(1_000)}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an int64 cappedMax", + ), + CommandTestCase( + "max_numeric_double", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": 1_000.0}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a double cappedMax", + ), + CommandTestCase( + "max_numeric_decimal", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": Decimal128("1000")}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a decimal128 cappedMax", + ), +] + +# Property [cappedMax Null No-Op]: a null cappedMax is accepted as a no-op. +COLLMOD_CAPPED_MAX_NULL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "max_null", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null cappedMax as a no-op", + ), +] + +# Property [cappedMax No Lower Bound]: any value at or below 0, NaN, or +# -Infinity is accepted and means "no document limit"; cappedMax has no lower +# bound, unlike cappedSize. +COLLMOD_CAPPED_MAX_NO_LOWER_BOUND_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "max_zero", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": 0}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a cappedMax of 0 as no document limit", + ), + CommandTestCase( + "max_negative", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": -1}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a negative cappedMax as no document limit", + ), + CommandTestCase( + "max_nan", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": FLOAT_NAN}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a NaN cappedMax as no document limit", + ), + CommandTestCase( + "max_negative_infinity", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": FLOAT_NEGATIVE_INFINITY}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a -Infinity cappedMax as no document limit", + ), +] + +# Property [cappedMax Upper Boundary]: the boundary value INT32_MAX is accepted. +COLLMOD_CAPPED_MAX_BOUNDARY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "max_upper_boundary_int32_max", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": INT32_MAX}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept the upper boundary cappedMax of INT32_MAX", + ), +] + +# Property [cappedMax Fractional Coercion]: a fractional cappedMax is coerced to +# an integer before the range check, with a double truncating toward zero and a +# decimal128 using banker's (round-half-to-even) rounding; a value that lands +# below the exclusive upper bound is accepted. +COLLMOD_CAPPED_MAX_FRACTIONAL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "max_double_truncates_toward_zero", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": INT32_MAX + 0.5}, + expected={"ok": Eq(1.0)}, + msg="collMod should truncate a fractional double cappedMax toward zero to INT32_MAX, " + "below the exclusive upper bound", + ), + CommandTestCase( + "max_decimal_bankers_rounds_down", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": Decimal128(f"{INT32_MAX}.4")}, + expected={"ok": Eq(1.0)}, + msg="collMod should banker's-round a fractional decimal128 cappedMax down to INT32_MAX, " + "below the exclusive upper bound", + ), +] + +# Property [cappedMax Type Rejection]: any non-numeric cappedMax type produces a +# TypeMismatch error. +COLLMOD_CAPPED_MAX_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"max_type_reject_{tid}", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "cappedMax": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} cappedMax as a non-numeric type", + ) + for tid, val in [ + ("string", "1000"), + ("bool_true", True), + ("bool_false", False), + ("array", [1000]), + ("object", {"x": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"hello")), + ("regex", Regex("abc", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [cappedMax Strict Upper Bound]: a value at or above the upper bound +# produces a BadValue error, since the bound is exclusive unlike cappedSize's +# inclusive bound, including a decimal128 that banker's-rounds up onto the bound +# and +Infinity which coerces above the bound. +COLLMOD_CAPPED_MAX_HIGH_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "max_high_at_bound", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": INT32_OVERFLOW}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a cappedMax of 2^31 as at or above the strict upper bound", + ), + CommandTestCase( + "max_high_decimal_bankers_rounds_onto_bound", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": Decimal128(f"{INT32_MAX}.5")}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a decimal128 cappedMax that banker's-rounds half-to-even up " + "onto the strict upper bound", + ), + CommandTestCase( + "max_high_positive_infinity", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": FLOAT_INFINITY}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a +Infinity cappedMax, which coerces to INT64_MAX, " + "as above the upper bound", + ), +] + +# Property [cappedMax Target Collection Restrictions]: cappedMax is rejected on +# any non-capped target: a regular or clustered collection produces an +# InvalidOptions error, a non-existent collection produces a NamespaceNotFound +# error, and the oplog produces an InvalidOptions error. +COLLMOD_CAPPED_MAX_TARGET_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "max_target_regular", + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": 1_000}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject cappedMax on a regular collection", + ), + CommandTestCase( + "max_target_clustered", + target_collection=ClusteredCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": 1_000}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject cappedMax on a clustered collection", + ), + CommandTestCase( + "max_target_nonexistent", + docs=None, + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": 1_000}, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="collMod should reject cappedMax on a non-existent collection", + ), + CommandTestCase( + "max_target_oplog", + target_collection=ExistingDatabase(db_name="local"), + docs=None, + command=lambda ctx: {"collMod": "oplog.rs", "cappedMax": 1_000}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject cappedMax on the oplog", + marks=(pytest.mark.replica_set,), + ), +] + +# Property [cappedMax View Crash]: applying cappedMax to a view must not crash +# the engine and must return a clean InvalidOptions error; the reference engine +# is skipped because it crashes (SIGSEGV) on this input. +COLLMOD_CAPPED_MAX_VIEW_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "max_target_view", + target_collection=ViewCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedMax": 1_000}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject cappedMax on a view with a clean error and not crash", + marks=( + pytest.mark.engine_xcrash( + engine="mongodb", + reason="Server crashes (SIGSEGV) when cappedMax is applied to a view", + ), + ), + ), +] + +# Property [cappedSize And cappedMax Coexistence]: cappedSize and cappedMax, the +# two capped-group sub-options, apply together in one command on a capped +# collection, each taking effect independently. +COLLMOD_CAPPED_SIZE_AND_MAX_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_and_max_together", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "cappedSize": 16384, + "cappedMax": 99, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should apply cappedSize and cappedMax together in one command", + ), +] + +COLLMOD_CAPPED_MAX_TESTS: list[CommandTestCase] = ( + COLLMOD_CAPPED_MAX_NUMERIC_TESTS + + COLLMOD_CAPPED_MAX_NULL_TESTS + + COLLMOD_CAPPED_MAX_NO_LOWER_BOUND_TESTS + + COLLMOD_CAPPED_MAX_BOUNDARY_TESTS + + COLLMOD_CAPPED_MAX_FRACTIONAL_TESTS + + COLLMOD_CAPPED_MAX_TYPE_ERROR_TESTS + + COLLMOD_CAPPED_MAX_HIGH_ERROR_TESTS + + COLLMOD_CAPPED_MAX_TARGET_ERROR_TESTS + + COLLMOD_CAPPED_MAX_VIEW_ERROR_TESTS + + COLLMOD_CAPPED_SIZE_AND_MAX_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_CAPPED_MAX_TESTS)) +def test_collMod_capped_max(database_client, collection, test): + """Test collMod cappedMax acceptance, rejection, and coexistence with cappedSize.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_capped_size.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_capped_size.py new file mode 100644 index 000000000..ee0361689 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_capped_size.py @@ -0,0 +1,347 @@ +"""Tests for collMod cappedSize on capped collections.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + INVALID_OPTIONS_ERROR, + NAMESPACE_NOT_FOUND_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.target_collection import ( + CappedCollection, + ClusteredCollection, + ExistingDatabase, + ViewCollection, +) +from documentdb_tests.framework.test_constants import ( + CAPPED_SIZE_LIMIT_BYTES, + DECIMAL128_HALF, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Property [cappedSize Numeric Type Acceptance]: cappedSize accepts any numeric type. +COLLMOD_CAPPED_SIZE_NUMERIC_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_numeric_int32", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 100_000}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an int32 cappedSize", + ), + CommandTestCase( + "size_numeric_int64", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": Int64(100_000)}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an int64 cappedSize", + ), + CommandTestCase( + "size_numeric_double", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 100_000.0}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a double cappedSize", + ), + CommandTestCase( + "size_numeric_decimal", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": Decimal128("100000")}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a decimal128 cappedSize", + ), +] + +# Property [cappedSize Null No-Op]: a null cappedSize is accepted as a no-op. +COLLMOD_CAPPED_SIZE_NULL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_null", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null cappedSize as a no-op", + ), +] + +# Property [cappedSize Range Boundaries]: the inclusive lower and upper +# boundaries are both accepted. +COLLMOD_CAPPED_SIZE_BOUNDARY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_lower_boundary_1", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 1}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept the lower boundary cappedSize of 1", + ), + CommandTestCase( + "size_upper_boundary_max", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": CAPPED_SIZE_LIMIT_BYTES}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept the upper boundary cappedSize of 2^50", + ), +] + +# Property [cappedSize Fractional Coercion]: a fractional cappedSize is coerced +# to an integer before the range check, with a double truncating toward zero and +# a decimal128 using banker's (round-half-to-even) rounding; a value that lands +# at or above the lower boundary is accepted. +COLLMOD_CAPPED_SIZE_FRACTIONAL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_double_truncates_to_one", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 1.5}, + expected={"ok": Eq(1.0)}, + msg="collMod should truncate a fractional double cappedSize toward zero to the " + "lower boundary", + ), + CommandTestCase( + "size_decimal_bankers_rounds_up_to_one", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": Decimal128("0.7")}, + expected={"ok": Eq(1.0)}, + msg="collMod should banker's-round a fractional decimal128 cappedSize up to the " + "lower boundary", + ), +] + +# Property [cappedSize Type Rejection]: any non-numeric cappedSize type produces +# a TypeMismatch error. +COLLMOD_CAPPED_SIZE_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"size_type_reject_{tid}", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "cappedSize": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} cappedSize as a non-numeric type", + ) + for tid, val in [ + ("string", "8192"), + ("bool_true", True), + ("bool_false", False), + ("array", [8192]), + ("object", {"x": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"hello")), + ("regex", Regex("abc", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [cappedSize Below Lower Bound]: a value below the inclusive lower +# bound after coercion produces a BadValue error, whether a double truncates +# toward zero or a decimal128 banker's-rounds down to zero. +COLLMOD_CAPPED_SIZE_LOW_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_low_zero", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 0}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a cappedSize of 0 as below the lower bound", + ), + CommandTestCase( + "size_low_double_truncates_to_zero", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 0.7}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a double cappedSize that truncates toward zero to 0 " + "as below the lower bound", + ), + CommandTestCase( + "size_low_decimal_bankers_rounds_to_zero", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": DECIMAL128_HALF}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a decimal128 cappedSize that banker's-rounds half-to-even " + "to 0 as below the lower bound", + ), + CommandTestCase( + "size_low_negative_fraction_truncates_to_zero", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": -0.5}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a negative fractional cappedSize that truncates toward zero " + "to 0 as below the lower bound", + ), + CommandTestCase( + "size_low_nan", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": FLOAT_NAN}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a NaN cappedSize, which coerces to 0, as below the lower bound", + ), + CommandTestCase( + "size_low_negative_infinity", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": FLOAT_NEGATIVE_INFINITY}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a -Infinity cappedSize, which coerces to INT64_MIN, " + "as below the lower bound", + ), +] + +# Property [cappedSize Above Upper Bound]: a value above the inclusive upper +# bound produces a BadValue error, including a decimal128 that banker's-rounds up +# over the bound and +Infinity which coerces above the bound. +COLLMOD_CAPPED_SIZE_HIGH_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_high_above_max", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": CAPPED_SIZE_LIMIT_BYTES + 1}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a cappedSize just above the 2^50 upper bound", + ), + CommandTestCase( + "size_high_decimal_bankers_rounds_over_bound", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "cappedSize": Decimal128(f"{CAPPED_SIZE_LIMIT_BYTES}.7"), + }, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a decimal128 cappedSize that banker's-rounds up over the " + "upper bound", + ), + CommandTestCase( + "size_high_positive_infinity", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": FLOAT_INFINITY}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a +Infinity cappedSize, which coerces to INT64_MAX, " + "as above the upper bound", + ), +] + +# Property [cappedSize Target Collection Restrictions]: cappedSize is rejected on +# any non-capped target: a regular or clustered collection produces an +# InvalidOptions error, a non-existent collection produces a NamespaceNotFound +# error, and the oplog produces an InvalidOptions error. +COLLMOD_CAPPED_SIZE_TARGET_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_target_regular", + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 100_000}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject cappedSize on a regular collection", + ), + CommandTestCase( + "size_target_clustered", + target_collection=ClusteredCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 100_000}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject cappedSize on a clustered collection", + ), + CommandTestCase( + "size_target_nonexistent", + docs=None, + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 100_000}, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="collMod should reject cappedSize on a non-existent collection", + ), + CommandTestCase( + "size_target_oplog", + target_collection=ExistingDatabase(db_name="local"), + docs=None, + command=lambda ctx: {"collMod": "oplog.rs", "cappedSize": 100_000}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject cappedSize on the oplog", + marks=(pytest.mark.replica_set,), + ), +] + +# Property [cappedSize View Crash]: applying cappedSize to a view must not crash +# the engine and must return a clean InvalidOptions error; the reference engine +# is skipped because it crashes (SIGSEGV) on this input. +COLLMOD_CAPPED_SIZE_VIEW_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "size_target_view", + target_collection=ViewCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "cappedSize": 100_000}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject cappedSize on a view with a clean error and not crash", + marks=( + pytest.mark.engine_xcrash( + engine="mongodb", + reason="Server crashes (SIGSEGV) when cappedSize is applied to a view", + ), + ), + ), +] + +COLLMOD_CAPPED_SIZE_TESTS: list[CommandTestCase] = ( + COLLMOD_CAPPED_SIZE_NUMERIC_TESTS + + COLLMOD_CAPPED_SIZE_NULL_TESTS + + COLLMOD_CAPPED_SIZE_BOUNDARY_TESTS + + COLLMOD_CAPPED_SIZE_FRACTIONAL_TESTS + + COLLMOD_CAPPED_SIZE_TYPE_ERROR_TESTS + + COLLMOD_CAPPED_SIZE_LOW_ERROR_TESTS + + COLLMOD_CAPPED_SIZE_HIGH_ERROR_TESTS + + COLLMOD_CAPPED_SIZE_TARGET_ERROR_TESTS + + COLLMOD_CAPPED_SIZE_VIEW_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_CAPPED_SIZE_TESTS)) +def test_collMod_capped_size(database_client, collection, test): + """Test collMod cappedSize acceptance and rejection on capped collections.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_change_streams.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_change_streams.py new file mode 100644 index 000000000..458434d03 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_change_streams.py @@ -0,0 +1,235 @@ +"""Tests for the collMod changeStreamPreAndPostImages option.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INVALID_OPTIONS_ERROR, + MISSING_FIELD_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.target_collection import ( + TimeseriesCollection, + ViewCollection, +) +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF + +# Property [changeStreamPreAndPostImages Success]: a null value is accepted as +# an omitted field, and an object with a boolean enabled sub-field is accepted +# for either truth value on a regular collection. +COLLMOD_CHANGE_STREAM_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "null", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": None, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null changeStreamPreAndPostImages as an omitted field", + ), + CommandTestCase( + "enabled_true", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": {"enabled": True}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept changeStreamPreAndPostImages with enabled true", + ), + CommandTestCase( + "enabled_false", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": {"enabled": False}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept changeStreamPreAndPostImages with enabled false", + ), +] + +# Property [changeStreamPreAndPostImages Top-Level Type Rejection]: a +# changeStreamPreAndPostImages value that is neither an object nor null produces +# a TypeMismatch error. +COLLMOD_CHANGE_STREAM_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"toplevel_type_{tid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": v, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} changeStreamPreAndPostImages as a non-object", + ) + for tid, val in [ + ("string", "x"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("array", [{"enabled": True}]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [changeStreamPreAndPostImages Missing enabled]: a +# changeStreamPreAndPostImages object whose enabled sub-field is absent or null +# produces a FailedToParse error because enabled is required and a null value is +# treated as missing rather than as a type mismatch. +COLLMOD_CHANGE_STREAM_MISSING_ENABLED_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "missing_enabled", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": {}, + }, + error_code=MISSING_FIELD_ERROR, + msg="collMod should reject changeStreamPreAndPostImages with no enabled sub-field", + ), + CommandTestCase( + "enabled_null", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": {"enabled": None}, + }, + error_code=MISSING_FIELD_ERROR, + msg="collMod should treat a null enabled as a missing required sub-field", + ), +] + +# Property [changeStreamPreAndPostImages Unknown Sub-Field]: a +# changeStreamPreAndPostImages object containing an unrecognized sub-field +# produces an UnknownField error, and that error fires even when the required +# enabled sub-field is also present. +COLLMOD_CHANGE_STREAM_UNKNOWN_FIELD_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_field_only", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": {"bogus": 1}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="collMod should reject an unknown changeStreamPreAndPostImages sub-field", + ), + CommandTestCase( + "unknown_field_with_enabled", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": {"bogus": 1, "enabled": True}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="collMod should reject an unknown sub-field before the missing-enabled check", + ), +] + +# Property [changeStreamPreAndPostImages enabled Type Rejection]: an enabled +# sub-field whose value is neither a boolean nor absent produces a TypeMismatch +# error. +COLLMOD_CHANGE_STREAM_ENABLED_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"enabled_type_{tid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": {"enabled": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} enabled value as a non-boolean", + ) + for tid, val in [ + ("string", "true"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("array", [True]), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [changeStreamPreAndPostImages Unsupported Collection Type]: applying +# changeStreamPreAndPostImages to a view or a time series collection produces an +# InvalidOptions error regardless of the enabled truth value. +COLLMOD_CHANGE_STREAM_UNSUPPORTED_TARGET_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"{target_id}_enabled_{enabled_id}", + docs=[], + target_collection=target, + command=lambda ctx, e=enabled: { + "collMod": ctx.collection, + "changeStreamPreAndPostImages": {"enabled": e}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject changeStreamPreAndPostImages on a {target_id}", + ) + for target_id, target in [ + ("view", ViewCollection()), + ("timeseries", TimeseriesCollection()), + ] + for enabled_id, enabled in [("true", True), ("false", False)] +] + +COLLMOD_CHANGE_STREAM_ERROR_TESTS: list[CommandTestCase] = ( + COLLMOD_CHANGE_STREAM_TYPE_ERROR_TESTS + + COLLMOD_CHANGE_STREAM_MISSING_ENABLED_ERROR_TESTS + + COLLMOD_CHANGE_STREAM_UNKNOWN_FIELD_ERROR_TESTS + + COLLMOD_CHANGE_STREAM_ENABLED_TYPE_ERROR_TESTS + + COLLMOD_CHANGE_STREAM_UNSUPPORTED_TARGET_ERROR_TESTS +) + +COLLMOD_CHANGE_STREAM_ALL_TESTS: list[CommandTestCase] = ( + COLLMOD_CHANGE_STREAM_SUCCESS_TESTS + COLLMOD_CHANGE_STREAM_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_CHANGE_STREAM_ALL_TESTS)) +def test_collMod_change_streams(database_client, collection, test): + """Test collMod changeStreamPreAndPostImages option behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_comment.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_comment.py new file mode 100644 index 000000000..3401ba6c4 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_comment.py @@ -0,0 +1,91 @@ +"""Tests for the collMod comment option.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, NotExists +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF + +# Property [comment Type Acceptance]: a comment of any BSON type representable +# by pymongo is accepted without changing the command result and is never echoed +# back in the response. +COLLMOD_COMMENT_TYPE_ACCEPTANCE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"type_{tid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "comment": v, + }, + expected={"ok": Eq(1.0), "comment": NotExists()}, + msg=f"collMod should accept a {tid} comment without echoing it", + ) + for tid, val in [ + ("string", "a note"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("null", None), + ("array", [1, "two", {"three": 3}]), + ("object", {"reason": "audit"}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [comment Does Not Suppress Errors]: a comment paired with an +# otherwise-invalid option does not suppress the option's error. +COLLMOD_COMMENT_NO_SUPPRESS_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "no_suppress_index_type_error", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "comment": "a note", + "index": "not_an_object", + }, + error_code=TYPE_MISMATCH_ERROR, + msg="collMod should still reject an invalid index when a comment is present", + ), +] + +COLLMOD_COMMENT_ALL_TESTS: list[CommandTestCase] = ( + COLLMOD_COMMENT_TYPE_ACCEPTANCE_TESTS + COLLMOD_COMMENT_NO_SUPPRESS_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_COMMENT_ALL_TESTS)) +def test_collMod_comment(database_client, collection, test): + """Test collMod comment option behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_dry_run.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_dry_run.py new file mode 100644 index 000000000..332359917 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_dry_run.py @@ -0,0 +1,278 @@ +"""Tests for collMod dryRun behavior.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + CANNOT_CONVERT_INDEX_TO_UNIQUE_ERROR, + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, NotExists +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT64_ZERO, +) + +# Property [dryRun Truthy Checks Without Converting]: a bool true or any numeric +# type that coerces to true (any nonzero value, including negatives, NaN, and +# Infinity) runs the unique conversion as a dry run, validating without +# converting, so the result omits the unique_new echo that an actual conversion +# would produce. +COLLMOD_DRY_RUN_TRUTHY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"truthy_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1", prepareUnique=True)], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": True}, + "dryRun": v, + }, + expected={"ok": Eq(1.0), "unique_new": NotExists(), "unique_old": NotExists()}, + msg=f"collMod should treat a {tid} dryRun as a dry run that checks without converting", + ) + for tid, val in [ + ("bool_true", True), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("int32_negative", -1), + ("double_negative", -2.0), + ("decimal128_negative", Decimal128("-1")), + ("float_nan", FLOAT_NAN), + ("decimal128_nan", DECIMAL128_NAN), + ("float_infinity", FLOAT_INFINITY), + ("decimal128_infinity", DECIMAL128_INFINITY), + ("float_negative_infinity", FLOAT_NEGATIVE_INFINITY), + ("decimal128_negative_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + +# Property [dryRun Falsy Performs Conversion]: a bool false, any numeric zero +# (including negative zero), or null disables dry run, so the unique conversion +# is actually performed and the result echoes unique_new true. +COLLMOD_DRY_RUN_FALSY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"falsy_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1", prepareUnique=True)], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": True}, + "dryRun": v, + }, + expected={"ok": Eq(1.0), "unique_new": Eq(True)}, + msg=f"collMod should treat a {tid} dryRun as disabled and perform the conversion", + ) + for tid, val in [ + ("bool_false", False), + ("int32_zero", 0), + ("int64_zero", INT64_ZERO), + ("double_zero", DOUBLE_ZERO), + ("double_negative_zero", DOUBLE_NEGATIVE_ZERO), + ("decimal128_zero", DECIMAL128_ZERO), + ("decimal128_negative_zero", DECIMAL128_NEGATIVE_ZERO), + ("null", None), + ] +] + +# Property [dryRun Falsy Accepted Without A Unique Conversion]: a falsy dryRun +# (bool false, numeric zero, or null) is accepted with ok:1.0 even when no unique +# conversion is present (whether paired with a non-unique modification such as +# hidden, which still applies, or with no index modification at all), unlike a +# truthy dryRun, which requires a unique conversion. +COLLMOD_DRY_RUN_FALSY_NO_CONVERSION_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"falsy_no_conversion_hidden_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": True}, + "dryRun": v, + }, + expected={"ok": Eq(1.0), "hidden_old": Eq(False), "hidden_new": Eq(True)}, + msg=f"collMod should accept a {tid} dryRun on a non-unique modification and apply it", + ) + for tid, val in [ + ("bool_false", False), + ("int32_zero", 0), + ("null", None), + ] + ], + *[ + CommandTestCase( + f"falsy_no_conversion_no_index_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "dryRun": v, + }, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept a {tid} dryRun with no index modification present", + ) + for tid, val in [ + ("bool_false", False), + ("int32_zero", 0), + ] + ], +] + +COLLMOD_DRY_RUN_SUCCESS_TESTS: list[CommandTestCase] = ( + COLLMOD_DRY_RUN_TRUTHY_TESTS + + COLLMOD_DRY_RUN_FALSY_TESTS + + COLLMOD_DRY_RUN_FALSY_NO_CONVERSION_TESTS +) + +# Property [dryRun Type Rejection]: a dryRun value that is neither a bool nor a +# numeric type is rejected as a type mismatch, since only bool and numeric +# values can be coerced to the dry-run flag. +COLLMOD_DRY_RUN_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"type_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1", prepareUnique=True)], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": True}, + "dryRun": v, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} dryRun value as a type mismatch", + ) + for tid, val in [ + ("string", "yes"), + ("array", [True]), + ("object", {"x": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [dryRun Truthy Requires A Unique Conversion]: a truthy dryRun with no +# index modification, or with an index modification that is not a unique +# conversion (an identify-only index or a non-unique change such as hidden), is +# rejected as an invalid option because dry run mode validates a pending unique +# conversion and has nothing to check otherwise. +COLLMOD_DRY_RUN_REQUIRES_CONVERSION_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "no_index_modification", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx: {"collMod": ctx.collection, "dryRun": True}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a truthy dryRun with no index modification as an " + "invalid option", + ), + CommandTestCase( + "identify_only_index", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1"}, + "dryRun": True, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a truthy dryRun with an identify-only index as an " + "invalid option", + ), + CommandTestCase( + "non_unique_modification", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": True}, + "dryRun": True, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a truthy dryRun with a non-unique index " + "modification as an invalid option", + ), +] + +# Property [dryRun Truthy Reports Conversion Violations]: a truthy dryRun on a +# unique conversion of an index whose documents contain duplicate values is +# rejected because the conversion would violate uniqueness, reported without +# converting. +COLLMOD_DRY_RUN_DUPLICATE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unique_conversion_with_duplicates", + indexes=[IndexModel([("a", 1)], name="a_1")], + # Documents that share a value on the indexed field so a unique + # conversion has a violation to report. + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 1}], + setup=lambda coll: execute_command( + coll, + {"collMod": coll.name, "index": {"name": "a_1", "prepareUnique": True}}, + ), + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": True}, + "dryRun": True, + }, + error_code=CANNOT_CONVERT_INDEX_TO_UNIQUE_ERROR, + msg="collMod should reject a dry-run unique conversion that has duplicate " + "values without converting", + ), +] + +COLLMOD_DRY_RUN_ERROR_TESTS: list[CommandTestCase] = ( + COLLMOD_DRY_RUN_TYPE_ERROR_TESTS + + COLLMOD_DRY_RUN_REQUIRES_CONVERSION_ERROR_TESTS + + COLLMOD_DRY_RUN_DUPLICATE_ERROR_TESTS +) + +COLLMOD_DRY_RUN_TESTS: list[CommandTestCase] = ( + COLLMOD_DRY_RUN_SUCCESS_TESTS + COLLMOD_DRY_RUN_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_DRY_RUN_TESTS)) +def test_collMod_dry_run(database_client, collection, test): + """Test collMod dryRun behavior.""" + collection = test.prepare(database_client, collection) + if test.setup: + test.setup(collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_argument.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_argument.py new file mode 100644 index 000000000..7e4818c7b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_argument.py @@ -0,0 +1,155 @@ +"""Tests for collMod index argument presence, type, structure, and applicability.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, NotExists +from documentdb_tests.framework.target_collection import ViewCollection + +# Property [Index Null No-Op]: an index value of null is accepted and treated +# as an omitted field, yielding a no-op success with no index modification +# echoed in the result. +COLLMOD_INDEX_NULL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "null_no_op", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "index": None}, + expected={ + "ok": Eq(1.0), + "expireAfterSeconds_new": NotExists(), + "expireAfterSeconds_old": NotExists(), + "hidden_new": NotExists(), + "hidden_old": NotExists(), + "prepareUnique_new": NotExists(), + "prepareUnique_old": NotExists(), + "unique_new": NotExists(), + "unique_old": NotExists(), + }, + msg="collMod should accept a null index as an omitted no-op", + ), +] + +# Property [Index Non-Document Type Rejection]: a non-document, non-null value +# for index produces a type-mismatch error. +COLLMOD_INDEX_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"type_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: {"collMod": ctx.collection, "index": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} index value as a type mismatch", + ) + for tid, val in [ + ("string", "a_1"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("array", [{"name": "a_1"}]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Empty Index Document Rejection]: an empty index document specifies +# neither an index name nor a key pattern and is rejected as an invalid option. +COLLMOD_INDEX_EMPTY_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_document", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "index": {}}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject an empty index document as an invalid option", + ), +] + +# Property [Unknown Index Sub-Field Rejection]: an unrecognized sub-field inside +# the index document is rejected as an unrecognized field. +COLLMOD_INDEX_UNKNOWN_FIELD_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_subfield", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "index": {"name": "a_1", "bogus": 1}}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="collMod should reject an unknown index sub-field as an unrecognized field", + ), +] + +# Property [Index Unsupported Collection Type Rejection]: an index modification +# applied to a view is rejected as an invalid option, since the index option is +# not supported on a view. +COLLMOD_INDEX_VIEW_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "view_index_not_supported", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": True}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject an index modification on a view as an invalid option", + ), +] + +COLLMOD_INDEX_ARGUMENT_TESTS: list[CommandTestCase] = ( + COLLMOD_INDEX_NULL_TESTS + + COLLMOD_INDEX_TYPE_ERROR_TESTS + + COLLMOD_INDEX_EMPTY_ERROR_TESTS + + COLLMOD_INDEX_UNKNOWN_FIELD_ERROR_TESTS + + COLLMOD_INDEX_VIEW_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_INDEX_ARGUMENT_TESTS)) +def test_collMod_index_argument(database_client, collection, test): + """Test collMod index argument acceptance and structural rejection.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_expire.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_expire.py new file mode 100644 index 000000000..6d0b6fa2f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_expire.py @@ -0,0 +1,353 @@ +"""Tests for collMod index expireAfterSeconds.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, NotExists +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_INT64_OVERFLOW, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_OVERFLOW, + INT64_ZERO, +) + +# Property [Index expireAfterSeconds Numeric Coercion]: setting +# index.expireAfterSeconds on an existing TTL index accepts any numeric type and +# coerces it to the new TTL, with a double truncating toward zero and a +# decimal128 using banker's (round-half-to-even) rounding, echoing the prior TTL +# as expireAfterSeconds_old and the coerced value as expireAfterSeconds_new. +COLLMOD_INDEX_EXPIRE_COERCION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"expire_coerce_{tid}", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": v}, + }, + expected={ + "ok": Eq(1.0), + "expireAfterSeconds_old": Eq(Int64(100)), + "expireAfterSeconds_new": Eq(Int64(expected_new)), + }, + msg=f"collMod should accept a {tid} expireAfterSeconds and coerce it to {expected_new}", + ) + for tid, val, expected_new in [ + ("int32", 50, 50), + ("int64", Int64(50), 50), + ("double_exact", 50.0, 50), + ("double_truncates_toward_zero", 50.7, 50), + ("decimal128_rounds_down", Decimal128("50.4"), 50), + ("decimal128_half_to_even_down", Decimal128("50.5"), 50), + ("decimal128_rounds_up", Decimal128("50.7"), 51), + ("decimal128_half_to_even_up", Decimal128("51.5"), 52), + ] +] + +# Property [Index expireAfterSeconds Boundaries]: the lower bound and the upper +# bound are accepted, and a negative value that truncates toward zero to the +# lower bound is accepted. +COLLMOD_INDEX_EXPIRE_BOUNDARY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"expire_boundary_{tid}", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": v}, + }, + expected={ + "ok": Eq(1.0), + "expireAfterSeconds_old": Eq(Int64(100)), + "expireAfterSeconds_new": Eq(Int64(expected_new)), + }, + msg=f"collMod should accept a {tid} expireAfterSeconds as {expected_new}", + ) + for tid, val, expected_new in [ + ("zero", 0, 0), + ("int32_max", INT32_MAX, INT32_MAX), + ("negative_fraction_truncates_to_zero", -0.9, 0), + ] +] + +# Property [Index expireAfterSeconds Clamp]: a value exceeding int32 max +# (an int64 above int32 max, a double or decimal128 overflow, and positive +# Infinity) is silently clamped to int32 max with no overflow error. +COLLMOD_INDEX_EXPIRE_CLAMP_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"expire_clamp_{tid}", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": v}, + }, + expected={ + "ok": Eq(1.0), + "expireAfterSeconds_old": Eq(Int64(100)), + "expireAfterSeconds_new": Eq(Int64(INT32_MAX)), + }, + msg=f"collMod should clamp a {tid} expireAfterSeconds to 2147483647", + ) + for tid, val in [ + ("int64_above_int32_max", Int64(INT32_OVERFLOW)), + ("double_above_int32_max", float(INT32_OVERFLOW)), + ("decimal128_overflow", DECIMAL128_INT64_OVERFLOW), + ("float_infinity", FLOAT_INFINITY), + ("decimal128_infinity", DECIMAL128_INFINITY), + ] +] + +# Property [Index expireAfterSeconds NaN]: a NaN value (float or decimal128) is +# coerced to 0. +COLLMOD_INDEX_EXPIRE_NAN_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"expire_nan_{tid}", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": v}, + }, + expected={ + "ok": Eq(1.0), + "expireAfterSeconds_old": Eq(Int64(100)), + "expireAfterSeconds_new": Eq(INT64_ZERO), + }, + msg=f"collMod should coerce a {tid} NaN expireAfterSeconds to 0", + ) + for tid, val in [ + ("float", FLOAT_NAN), + ("decimal128", DECIMAL128_NAN), + ] +] + +# Property [Index expireAfterSeconds Same Value]: setting expireAfterSeconds to +# the same value on an existing TTL index echoes both expireAfterSeconds_old and +# expireAfterSeconds_new. +COLLMOD_INDEX_EXPIRE_SAME_VALUE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "expire_same_value", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": 100}, + }, + expected={ + "ok": Eq(1.0), + "expireAfterSeconds_old": Eq(Int64(100)), + "expireAfterSeconds_new": Eq(Int64(100)), + }, + msg="collMod should echo both old and new TTL when the value is unchanged", + ), +] + +# Property [Index expireAfterSeconds Converts Non-TTL]: setting +# expireAfterSeconds on a single-field non-TTL index converts it to a TTL index, +# echoing expireAfterSeconds_new with no expireAfterSeconds_old. +COLLMOD_INDEX_EXPIRE_CONVERT_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "expire_converts_non_ttl", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "expireAfterSeconds": 50}, + }, + expected={ + "ok": Eq(1.0), + "expireAfterSeconds_old": NotExists(), + "expireAfterSeconds_new": Eq(Int64(50)), + }, + msg="collMod should convert a non-TTL index to TTL with new value and no old value", + ), +] + +# Property [Index Field Combination expireAfterSeconds And hidden]: setting +# expireAfterSeconds and hidden in one index document applies both, echoing the +# new TTL as expireAfterSeconds_new and the hidden change as hidden_old and +# hidden_new. +COLLMOD_INDEX_COMBO_EXPIRE_HIDDEN_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "combo_expire_and_hidden", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100, hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": 200, "hidden": True}, + }, + expected={ + "ok": Eq(1.0), + "expireAfterSeconds_old": Eq(Int64(100)), + "expireAfterSeconds_new": Eq(Int64(200)), + "hidden_old": Eq(False), + "hidden_new": Eq(True), + }, + msg="collMod should apply both expireAfterSeconds and hidden in one index document", + ), +] + +# Property [Index expireAfterSeconds Non-Numeric Rejection]: a bool value and +# every other non-numeric type for index.expireAfterSeconds produce a +# type-mismatch error (bool is not treated as numeric here). +COLLMOD_INDEX_EXPIRE_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"expire_type_{tid}", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} expireAfterSeconds as a type mismatch", + ) + for tid, val in [ + ("string", "100"), + ("bool_true", True), + ("bool_false", False), + ("array", [100]), + ("object", {"x": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Index expireAfterSeconds Null Rejection]: a null +# index.expireAfterSeconds leaves no modification field and is rejected as an +# invalid option. +COLLMOD_INDEX_EXPIRE_NULL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "expire_null", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": None}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a null expireAfterSeconds as leaving no modification field", + ), +] + +# Property [Index expireAfterSeconds Negative Rejection]: a value that truncates +# toward zero to a negative number is rejected as an invalid option, including +# -Infinity, which is rejected as below zero rather than clamped like +Infinity. +COLLMOD_INDEX_EXPIRE_NEGATIVE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"expire_negative_{tid}", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": v}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject a {tid} expireAfterSeconds that truncates to a negative value", + ) + for tid, val in [ + ("int32", -1), + ("int64", Int64(-1)), + ("double", -1.5), + ("decimal128", Decimal128("-1")), + ("float_negative_infinity", FLOAT_NEGATIVE_INFINITY), + ("decimal128_negative_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + +# Property [Index expireAfterSeconds Non-Single-Field Rejection]: applying +# expireAfterSeconds to a compound index or to the _id_ index is rejected as an +# invalid option, since TTL is supported only on single-field non-_id indexes. +COLLMOD_INDEX_EXPIRE_NON_SINGLE_FIELD_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "expire_compound_index", + indexes=[IndexModel([("a", 1), ("b", 1)], name="ab_1")], + docs=[{"_id": 1, "a": 1, "b": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "ab_1", "expireAfterSeconds": 100}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject expireAfterSeconds on a compound index as an invalid option", + ), + CommandTestCase( + "expire_id_index", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "_id_", "expireAfterSeconds": 100}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject expireAfterSeconds on the _id_ index as an invalid option", + ), +] + +COLLMOD_INDEX_EXPIRE_TESTS: list[CommandTestCase] = ( + COLLMOD_INDEX_EXPIRE_COERCION_TESTS + + COLLMOD_INDEX_EXPIRE_BOUNDARY_TESTS + + COLLMOD_INDEX_EXPIRE_CLAMP_TESTS + + COLLMOD_INDEX_EXPIRE_NAN_TESTS + + COLLMOD_INDEX_EXPIRE_SAME_VALUE_TESTS + + COLLMOD_INDEX_EXPIRE_CONVERT_TESTS + + COLLMOD_INDEX_COMBO_EXPIRE_HIDDEN_TESTS + + COLLMOD_INDEX_EXPIRE_TYPE_ERROR_TESTS + + COLLMOD_INDEX_EXPIRE_NULL_ERROR_TESTS + + COLLMOD_INDEX_EXPIRE_NEGATIVE_ERROR_TESTS + + COLLMOD_INDEX_EXPIRE_NON_SINGLE_FIELD_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_INDEX_EXPIRE_TESTS)) +def test_collMod_index_expire(database_client, collection, test): + """Test collMod index expireAfterSeconds acceptance and rejection.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_hidden.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_hidden.py new file mode 100644 index 000000000..e212f5430 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_hidden.py @@ -0,0 +1,274 @@ +"""Tests for collMod index hidden.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + INDEX_NOT_FOUND_ERROR, + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, NotExists +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT64_ZERO, +) + +# Property [Index hidden Truthy Coercion]: a bool true or any numeric type that +# coerces to true (any nonzero value, including negatives, NaN, and Infinity) +# sets hidden on a previously unhidden index, echoing hidden_old false and +# hidden_new true, despite the docs describing the field as boolean-only. +COLLMOD_INDEX_HIDDEN_TRUTHY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"hidden_truthy_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": v}, + }, + expected={"ok": Eq(1.0), "hidden_old": Eq(False), "hidden_new": Eq(True)}, + msg=f"collMod should coerce a {tid} hidden value to true and echo the change", + ) + for tid, val in [ + ("bool_true", True), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("int32_negative", -1), + ("double_negative", -2.0), + ("decimal128_negative", Decimal128("-1")), + ("float_nan", FLOAT_NAN), + ("decimal128_nan", DECIMAL128_NAN), + ("float_infinity", FLOAT_INFINITY), + ("decimal128_infinity", DECIMAL128_INFINITY), + ("float_negative_infinity", FLOAT_NEGATIVE_INFINITY), + ("decimal128_negative_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + +# Property [Index hidden Falsy Coercion]: any numeric zero (including negative +# zero) coerces to false, so applying it to an already unhidden index does not +# change the state and the result omits hidden_old and hidden_new. +COLLMOD_INDEX_HIDDEN_FALSY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"hidden_falsy_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": v}, + }, + expected={ + "ok": Eq(1.0), + "hidden_old": NotExists(), + "hidden_new": NotExists(), + }, + msg=f"collMod should coerce a {tid} hidden value to false, leaving the state unchanged", + ) + for tid, val in [ + ("int32_zero", 0), + ("int64_zero", INT64_ZERO), + ("double_zero", DOUBLE_ZERO), + ("double_negative_zero", DOUBLE_NEGATIVE_ZERO), + ("decimal128_zero", DECIMAL128_ZERO), + ("decimal128_negative_zero", DECIMAL128_NEGATIVE_ZERO), + ] +] + +# Property [Index hidden Unchanged State]: setting hidden to its current value +# (hiding an already-hidden index or unhiding an already-unhidden index) does +# not change the state, so the result omits hidden_old and hidden_new, while a +# genuine state change echoes both. +COLLMOD_INDEX_HIDDEN_UNCHANGED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "hidden_already_hidden", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=True)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": True}, + }, + expected={ + "ok": Eq(1.0), + "hidden_old": NotExists(), + "hidden_new": NotExists(), + }, + msg="collMod should omit hidden_old and hidden_new when hiding an already-hidden index", + ), + CommandTestCase( + "hidden_already_unhidden", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": False}, + }, + expected={ + "ok": Eq(1.0), + "hidden_old": NotExists(), + "hidden_new": NotExists(), + }, + msg="collMod should omit hidden_old and hidden_new when unhiding an already-unhidden " + "index", + ), + CommandTestCase( + "hidden_unhide_changes", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=True)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": False}, + }, + expected={"ok": Eq(1.0), "hidden_old": Eq(True), "hidden_new": Eq(False)}, + msg="collMod should echo the change when unhiding a hidden index", + ), +] + +# Property [Index Id Index Hidden Rejection]: identifying the _id_ index by name +# or keyPattern for a hidden change is rejected as a bad value. +COLLMOD_INDEX_ID_HIDDEN_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "id_index_hidden_by_name", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "_id_", "hidden": True}, + }, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject hiding the _id_ index identified by name as a bad value", + ), + CommandTestCase( + "id_index_hidden_by_key_pattern", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"keyPattern": {"_id": 1}, "hidden": True}, + }, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject hiding the _id_ index identified by keyPattern as a bad value", + ), +] + +# Property [Index hidden Non-Bool-Non-Numeric Rejection]: a type outside bool +# and the numeric types for index.hidden produces a type-mismatch error. +COLLMOD_INDEX_HIDDEN_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"hidden_type_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} hidden value as a type mismatch", + ) + for tid, val in [ + ("string", "true"), + ("array", [True]), + ("object", {"x": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Index hidden Null Rejection]: a null index.hidden is treated as +# absent, leaving no modification field, and is rejected as an invalid option. +COLLMOD_INDEX_HIDDEN_NULL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "hidden_null", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": None}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should treat a null hidden value as absent and reject it as an invalid option", + ), +] + +# Property [Index hidden Text Index Identification]: a text index can be hidden +# by name but not by keyPattern, because a text index stores a different stored +# key pattern, so identifying it by keyPattern is index-not-found. +COLLMOD_INDEX_HIDDEN_TEXT_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "hidden_text_index_by_key_pattern", + indexes=[IndexModel([("a", "text")], name="a_text")], + docs=[{"_id": 1, "a": "hello world"}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"keyPattern": {"a": "text"}, "hidden": True}, + }, + error_code=INDEX_NOT_FOUND_ERROR, + msg="collMod should reject hiding a text index by keyPattern as index-not-found", + ), +] + +COLLMOD_INDEX_HIDDEN_TESTS: list[CommandTestCase] = ( + COLLMOD_INDEX_HIDDEN_TRUTHY_TESTS + + COLLMOD_INDEX_HIDDEN_FALSY_TESTS + + COLLMOD_INDEX_HIDDEN_UNCHANGED_TESTS + + COLLMOD_INDEX_ID_HIDDEN_ERROR_TESTS + + COLLMOD_INDEX_HIDDEN_TYPE_ERROR_TESTS + + COLLMOD_INDEX_HIDDEN_NULL_ERROR_TESTS + + COLLMOD_INDEX_HIDDEN_TEXT_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_INDEX_HIDDEN_TESTS)) +def test_collMod_index_hidden(database_client, collection, test): + """Test collMod index hidden acceptance and rejection.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_identifier.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_identifier.py new file mode 100644 index 000000000..f1c9abb11 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_identifier.py @@ -0,0 +1,321 @@ +"""Tests for collMod index identifier resolution by name and keyPattern.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INDEX_NOT_FOUND_ERROR, + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq + +# Property [Index Identifier Resolution]: an index identifier given by name or +# by key pattern resolves to the matching existing index, so a paired +# modification applies and its old/new values are echoed in the result. +COLLMOD_INDEX_RESOLUTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "resolution_by_name", + indexes=[IndexModel([("a", 1)], name="a_ttl", expireAfterSeconds=100)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_ttl", "expireAfterSeconds": 200}, + }, + expected={ + "ok": Eq(1.0), + "expireAfterSeconds_old": Eq(Int64(100)), + "expireAfterSeconds_new": Eq(Int64(200)), + }, + msg="collMod should resolve an index by its name", + ), + CommandTestCase( + "resolution_by_key_pattern", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"keyPattern": {"a": 1}, "hidden": True}, + }, + expected={"ok": Eq(1.0), "hidden_old": Eq(False), "hidden_new": Eq(True)}, + msg="collMod should resolve an index by its key pattern", + ), +] + +# Property [Index Identifier Ambiguity Rejection]: supplying both name and +# keyPattern, or neither, fails to unambiguously identify an index and is +# rejected as an invalid option, even when both refer to the same index. +COLLMOD_INDEX_IDENTIFIER_AMBIGUITY_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "identifier_both_name_and_key_pattern", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "keyPattern": {"a": 1}, "hidden": True}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject specifying both name and keyPattern as an invalid option", + ), + CommandTestCase( + "identifier_neither_name_nor_key_pattern", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "index": {"hidden": True}}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject specifying neither name nor keyPattern as an invalid option", + ), +] + +# Property [Index Identifier No-Match Rejection]: a name or keyPattern that +# matches no existing index is rejected as index-not-found, including an empty +# keyPattern and a text-index keyPattern. +COLLMOD_INDEX_IDENTIFIER_NO_MATCH_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "no_match_name", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "nonexistent", "hidden": True}, + }, + error_code=INDEX_NOT_FOUND_ERROR, + msg="collMod should reject a name that matches no existing index as index-not-found", + ), + CommandTestCase( + "no_match_key_pattern", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"keyPattern": {"b": 1}, "hidden": True}, + }, + error_code=INDEX_NOT_FOUND_ERROR, + msg="collMod should reject a keyPattern that matches no existing index as index-not-found", + ), + CommandTestCase( + "no_match_empty_key_pattern", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"keyPattern": {}, "hidden": True}, + }, + error_code=INDEX_NOT_FOUND_ERROR, + msg="collMod should reject an empty keyPattern as index-not-found", + ), + CommandTestCase( + "no_match_text_key_pattern", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"keyPattern": {"a": "text"}, "hidden": True}, + }, + error_code=INDEX_NOT_FOUND_ERROR, + msg="collMod should reject a text-index keyPattern as index-not-found", + ), +] + +# Property [Index Name Null Rejection]: a null index.name is treated as absent, +# so no identifier is supplied and the command is rejected as an invalid option. +COLLMOD_INDEX_NAME_NULL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "name_null", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": None, "hidden": True}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should treat a null index name as absent and reject it as an invalid option", + ), +] + +# Property [Index Name Non-String Rejection]: a non-string, non-null index.name +# produces a type-mismatch error. +COLLMOD_INDEX_NAME_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"name_type_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": v, "hidden": True}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} index name as a type mismatch", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("array", ["a_1"]), + ("object", {"x": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Index Name Literal Lookup]: an index.name string is always a literal +# lookup key, never a field path or variable, so any string content that names +# no existing index is rejected as index-not-found regardless of length. +COLLMOD_INDEX_NAME_LITERAL_ERROR_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"name_literal_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": v, "hidden": True}, + }, + error_code=INDEX_NOT_FOUND_ERROR, + msg=f"collMod should treat a {tid} index name as a literal lookup key", + ) + for tid, val in [ + ("empty", ""), + ("space", " "), + ("tab", "\t"), + ("newline", "\n"), + ("cr", "\r"), + ("nbsp", "\u00a0"), # U+00A0 no-break space. + ("name_with_space", "a b"), + ("unicode_2byte", "caf\u00e9"), # U+00E9 with accent. + ("unicode_3byte", "\u4e2d"), # U+4E2D CJK character. + ("unicode_4byte", "\U0001f600"), # U+1F600 emoji. + ("zwsp", "\u200b"), # U+200B zero-width space. + ("bom", "\ufeff"), # U+FEFF byte order mark. + ("dollar", "$"), + ("double_dollar", "$$"), + ("dotted", "a.b.c"), + ("control_low", "\x01"), # U+0001 control char. + ("control_high", "\x1f"), # U+001F control char. + ] + ], + *[ + CommandTestCase( + f"name_literal_large_{size}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, n=size: { + "collMod": ctx.collection, + "index": {"name": "x" * n, "hidden": True}, + }, + error_code=INDEX_NOT_FOUND_ERROR, + msg="collMod should treat a large index name as a literal lookup key " + "with no length limit", + ) + for size in [16_777_215, 16_777_216, 16_777_217] + ], +] + +# Property [Index Key Pattern Null Rejection]: a null index.keyPattern is treated +# as absent, so no identifier is supplied and the command is rejected as an +# invalid option. +COLLMOD_INDEX_KEY_PATTERN_NULL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "key_pattern_null", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"keyPattern": None, "hidden": True}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should treat a null keyPattern as absent and reject it as an invalid option", + ), +] + +# Property [Index Key Pattern Non-Object Rejection]: a non-object, non-null +# index.keyPattern produces a type-mismatch error. +COLLMOD_INDEX_KEY_PATTERN_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"key_pattern_type_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"keyPattern": v, "hidden": True}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} keyPattern as a type mismatch", + ) + for tid, val in [ + ("string", "a_1"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("array", [{"a": 1}]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +COLLMOD_INDEX_IDENTIFIER_TESTS: list[CommandTestCase] = ( + COLLMOD_INDEX_RESOLUTION_TESTS + + COLLMOD_INDEX_IDENTIFIER_AMBIGUITY_ERROR_TESTS + + COLLMOD_INDEX_IDENTIFIER_NO_MATCH_ERROR_TESTS + + COLLMOD_INDEX_NAME_NULL_ERROR_TESTS + + COLLMOD_INDEX_NAME_TYPE_ERROR_TESTS + + COLLMOD_INDEX_NAME_LITERAL_ERROR_TESTS + + COLLMOD_INDEX_KEY_PATTERN_NULL_ERROR_TESTS + + COLLMOD_INDEX_KEY_PATTERN_TYPE_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_INDEX_IDENTIFIER_TESTS)) +def test_collMod_index_identifier(database_client, collection, test): + """Test collMod index identifier resolution and rejection.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_prepare_unique.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_prepare_unique.py new file mode 100644 index 000000000..62821d94d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_prepare_unique.py @@ -0,0 +1,317 @@ +"""Tests for collMod index prepareUnique and its combination rule.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, NotExists +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT64_ZERO, +) + +# Property [Index prepareUnique Truthy Coercion]: a bool true or any numeric type +# that coerces to true (any nonzero value, including negatives, NaN, and +# Infinity) sets prepareUnique on an index that does not have it, echoing +# prepareUnique_old false and prepareUnique_new true, despite the docs describing +# the field as boolean-only. +COLLMOD_INDEX_PREPARE_UNIQUE_TRUTHY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"prepare_unique_truthy_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": v}, + }, + expected={ + "ok": Eq(1.0), + "prepareUnique_old": Eq(False), + "prepareUnique_new": Eq(True), + }, + msg=f"collMod should coerce a {tid} prepareUnique value to true and echo the change", + ) + for tid, val in [ + ("bool_true", True), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("int32_negative", -1), + ("double_negative", -2.0), + ("decimal128_negative", Decimal128("-1")), + ("float_nan", FLOAT_NAN), + ("decimal128_nan", DECIMAL128_NAN), + ("float_infinity", FLOAT_INFINITY), + ("decimal128_infinity", DECIMAL128_INFINITY), + ("float_negative_infinity", FLOAT_NEGATIVE_INFINITY), + ("decimal128_negative_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + +# Property [Index prepareUnique Falsy Coercion]: any numeric zero (including +# negative zero) coerces to false, so applying it to an index that does not have +# prepareUnique does not change the state and the result omits prepareUnique_old +# and prepareUnique_new. +COLLMOD_INDEX_PREPARE_UNIQUE_FALSY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"prepare_unique_falsy_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": v}, + }, + expected={ + "ok": Eq(1.0), + "prepareUnique_old": NotExists(), + "prepareUnique_new": NotExists(), + }, + msg=f"collMod should coerce a {tid} prepareUnique value to false, leaving the state " + "unchanged", + ) + for tid, val in [ + ("int32_zero", 0), + ("int64_zero", INT64_ZERO), + ("double_zero", DOUBLE_ZERO), + ("double_negative_zero", DOUBLE_NEGATIVE_ZERO), + ("decimal128_zero", DECIMAL128_ZERO), + ("decimal128_negative_zero", DECIMAL128_NEGATIVE_ZERO), + ] +] + +# Property [Index prepareUnique Unchanged State]: setting prepareUnique to its +# current value (re-setting an index that already has prepareUnique or clearing +# one that does not) does not change the state, so the result omits +# prepareUnique_old and prepareUnique_new, while a genuine state change echoes +# both. +COLLMOD_INDEX_PREPARE_UNIQUE_UNCHANGED_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "prepare_unique_already_set", + indexes=[IndexModel([("a", 1)], name="a_1", prepareUnique=True)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": True}, + }, + expected={ + "ok": Eq(1.0), + "prepareUnique_old": NotExists(), + "prepareUnique_new": NotExists(), + }, + msg="collMod should omit prepareUnique_old and prepareUnique_new when re-setting an " + "already-prepareUnique index", + ), + CommandTestCase( + "prepare_unique_already_unset", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": False}, + }, + expected={ + "ok": Eq(1.0), + "prepareUnique_old": NotExists(), + "prepareUnique_new": NotExists(), + }, + msg="collMod should omit prepareUnique_old and prepareUnique_new when clearing an index " + "that has no prepareUnique", + ), + CommandTestCase( + "prepare_unique_clear_changes", + indexes=[IndexModel([("a", 1)], name="a_1", prepareUnique=True)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": False}, + }, + expected={ + "ok": Eq(1.0), + "prepareUnique_old": Eq(True), + "prepareUnique_new": Eq(False), + }, + msg="collMod should echo the change when clearing prepareUnique on an index that has it", + ), +] + +# Property [Index Field Combination prepareUnique No-Change]: a prepareUnique +# value that does not change the index state may be combined with another +# modification field, since the cannot-be-combined rule is gated on an actual +# prepareUnique state change, so the other field applies and prepareUnique_old +# and prepareUnique_new are omitted. +COLLMOD_INDEX_COMBO_PREPARE_UNIQUE_NO_CHANGE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "combo_prepare_unique_unset_no_change_with_hidden", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": False, "hidden": True}, + }, + expected={ + "ok": Eq(1.0), + "hidden_old": Eq(False), + "hidden_new": Eq(True), + "prepareUnique_old": NotExists(), + "prepareUnique_new": NotExists(), + }, + msg="collMod should allow a no-change prepareUnique combined with a hidden change", + ), + CommandTestCase( + "combo_prepare_unique_set_no_change_with_hidden", + indexes=[IndexModel([("a", 1)], name="a_1", prepareUnique=True, hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": True, "hidden": True}, + }, + expected={ + "ok": Eq(1.0), + "hidden_old": Eq(False), + "hidden_new": Eq(True), + "prepareUnique_old": NotExists(), + "prepareUnique_new": NotExists(), + }, + msg="collMod should allow a no-change prepareUnique combined with a hidden change", + ), +] + +# Property [Index prepareUnique Non-Bool-Non-Numeric Rejection]: a type outside +# bool and the numeric types for index.prepareUnique produces a type-mismatch +# error. +COLLMOD_INDEX_PREPARE_UNIQUE_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"prepare_unique_type_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} prepareUnique value as a type mismatch", + ) + for tid, val in [ + ("string", "true"), + ("array", [True]), + ("object", {"x": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Index prepareUnique Null Rejection]: a null index.prepareUnique is +# treated as absent, leaving no modification field, and is rejected as an +# invalid option. +COLLMOD_INDEX_PREPARE_UNIQUE_NULL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "prepare_unique_null", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": None}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should treat a null prepareUnique value as absent and reject it as an " + "invalid option", + ), +] + +# Property [Index Field Combination prepareUnique Change Rejection]: a prepareUnique +# change combined with any other index modification field is rejected as an +# invalid option, since a prepareUnique state change cannot be combined with +# another modification. +COLLMOD_INDEX_COMBO_PREPARE_UNIQUE_CHANGE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "combo_prepare_unique_change_with_hidden", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": True, "hidden": True}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a prepareUnique change combined with a hidden change " + "as an invalid option", + ), + CommandTestCase( + "combo_prepare_unique_change_with_expire", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "prepareUnique": True, "expireAfterSeconds": 100}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a prepareUnique change combined with an expireAfterSeconds " + "change as an invalid option", + ), +] + +COLLMOD_INDEX_PREPARE_UNIQUE_TESTS: list[CommandTestCase] = ( + COLLMOD_INDEX_PREPARE_UNIQUE_TRUTHY_TESTS + + COLLMOD_INDEX_PREPARE_UNIQUE_FALSY_TESTS + + COLLMOD_INDEX_PREPARE_UNIQUE_UNCHANGED_TESTS + + COLLMOD_INDEX_COMBO_PREPARE_UNIQUE_NO_CHANGE_TESTS + + COLLMOD_INDEX_PREPARE_UNIQUE_TYPE_ERROR_TESTS + + COLLMOD_INDEX_PREPARE_UNIQUE_NULL_ERROR_TESTS + + COLLMOD_INDEX_COMBO_PREPARE_UNIQUE_CHANGE_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_INDEX_PREPARE_UNIQUE_TESTS)) +def test_collMod_index_prepare_unique(database_client, collection, test): + """Test collMod index prepareUnique acceptance, rejection, and combination rule.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_unique.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_unique.py new file mode 100644 index 000000000..483737710 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_index_unique.py @@ -0,0 +1,241 @@ +"""Tests for collMod index unique conversion.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + CANNOT_CONVERT_INDEX_TO_UNIQUE_ERROR, + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, NotExists +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT64_ZERO, +) + +# Property [Index unique Truthy Conversion]: on an index with prepareUnique +# already committed and no duplicate entries, a bool true or any numeric type +# that coerces to true (any nonzero value, including negatives, NaN, and +# Infinity) converts the index to unique and echoes unique_new true. +COLLMOD_INDEX_UNIQUE_TRUTHY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"unique_truthy_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1", prepareUnique=True)], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": v}, + }, + expected={"ok": Eq(1.0), "unique_new": Eq(True), "unique_old": NotExists()}, + msg=f"collMod should coerce a {tid} unique value to true and convert the index", + ) + for tid, val in [ + ("bool_true", True), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("int32_negative", -1), + ("double_negative", -2.0), + ("decimal128_negative", Decimal128("-1")), + ("float_nan", FLOAT_NAN), + ("decimal128_nan", DECIMAL128_NAN), + ("float_infinity", FLOAT_INFINITY), + ("decimal128_infinity", DECIMAL128_INFINITY), + ("float_negative_infinity", FLOAT_NEGATIVE_INFINITY), + ("decimal128_negative_infinity", DECIMAL128_NEGATIVE_INFINITY), + ] +] + +# Property [Index unique Already Unique No-Op]: setting unique true on an index +# that is already unique is an accepted no-op that omits unique_old and +# unique_new from the result. +COLLMOD_INDEX_UNIQUE_NO_OP_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unique_already_unique", + indexes=[IndexModel([("a", 1)], name="a_1", unique=True)], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": True}, + }, + expected={ + "ok": Eq(1.0), + "unique_old": NotExists(), + "unique_new": NotExists(), + }, + msg="collMod should accept unique true on an already-unique index as a no-op", + ), +] + +# Property [Index unique Non-Bool-Non-Numeric Rejection]: a type outside bool +# and the numeric types for index.unique produces a type-mismatch error. +COLLMOD_INDEX_UNIQUE_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"unique_type_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1", prepareUnique=True)], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} unique value as a type mismatch", + ) + for tid, val in [ + ("string", "true"), + ("array", [True]), + ("object", {"x": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Index unique Null Rejection]: a null index.unique is treated as +# absent, leaving no modification field, and is rejected as an invalid option. +COLLMOD_INDEX_UNIQUE_NULL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unique_null", + indexes=[IndexModel([("a", 1)], name="a_1", prepareUnique=True)], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": None}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should treat a null unique value as absent and reject it as an invalid " + "option", + ), +] + +# Property [Index unique True Without prepareUnique Rejection]: a truthy unique +# value on an index that has no committed prepareUnique is rejected as an invalid +# option, since the conversion requires prepareUnique to be set first. +COLLMOD_INDEX_UNIQUE_NO_PREPARE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unique_true_no_prepare", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": True}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a truthy unique value without a prior prepareUnique " + "as an invalid option", + ), +] + +# Property [Index unique Falsy Rejection]: a falsy unique value (bool false or +# any numeric zero) attempts to make the index non-unique, which is unsupported, +# and is rejected as a bad value. +COLLMOD_INDEX_UNIQUE_FALSY_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"unique_falsy_{tid}", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 2}], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": v}, + }, + error_code=BAD_VALUE_ERROR, + msg=f"collMod should reject a {tid} falsy unique value as a bad value", + ) + for tid, val in [ + ("bool_false", False), + ("int32_zero", 0), + ("int64_zero", INT64_ZERO), + ("double_zero", DOUBLE_ZERO), + ("double_negative_zero", DOUBLE_NEGATIVE_ZERO), + ("decimal128_zero", DECIMAL128_ZERO), + ("decimal128_negative_zero", DECIMAL128_NEGATIVE_ZERO), + ] +] + +# Property [Index unique Conversion With Duplicates Rejection]: the conversion +# workflow of prepareUnique then unique true on an index whose data contains +# duplicate entries cannot convert and is rejected as a cannot-convert error. +COLLMOD_INDEX_UNIQUE_CONVERT_DUP_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unique_convert_with_duplicates", + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[{"_id": 1, "a": 1}, {"_id": 2, "a": 1}], + setup=lambda coll: execute_command( + coll, + {"collMod": coll.name, "index": {"name": "a_1", "prepareUnique": True}}, + ), + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "unique": True}, + }, + error_code=CANNOT_CONVERT_INDEX_TO_UNIQUE_ERROR, + msg="collMod should reject a unique conversion on an index with duplicate entries", + ), +] + +COLLMOD_INDEX_UNIQUE_TESTS: list[CommandTestCase] = ( + COLLMOD_INDEX_UNIQUE_TRUTHY_TESTS + + COLLMOD_INDEX_UNIQUE_NO_OP_TESTS + + COLLMOD_INDEX_UNIQUE_TYPE_ERROR_TESTS + + COLLMOD_INDEX_UNIQUE_NULL_ERROR_TESTS + + COLLMOD_INDEX_UNIQUE_NO_PREPARE_ERROR_TESTS + + COLLMOD_INDEX_UNIQUE_FALSY_ERROR_TESTS + + COLLMOD_INDEX_UNIQUE_CONVERT_DUP_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_INDEX_UNIQUE_TESTS)) +def test_collMod_index_unique(database_client, collection, test): + """Test collMod index unique conversion acceptance and rejection.""" + collection = test.prepare(database_client, collection) + if test.setup: + test.setup(collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_interactions.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_interactions.py new file mode 100644 index 000000000..9d5740c4b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_interactions.py @@ -0,0 +1,394 @@ +"""Tests for collMod cross-option parameter interactions.""" + +from __future__ import annotations + +import pytest +from pymongo import IndexModel + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INDEX_NOT_FOUND_ERROR, + INVALID_OPTIONS_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq, NotExists +from documentdb_tests.framework.target_collection import ( + CappedCollection, + ClusteredCollection, + TimeseriesCollection, + TimeseriesTTLCollection, + ViewCollection, +) + +# Property [Cross-Group Coexistence]: independent option groups that target the +# same collection type apply together in one command, and each group's effect is +# reflected in the result independently of the others. +COLLMOD_CROSS_GROUP_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "index_hidden_and_validator", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": True}, + "validator": {"a": 1}, + }, + expected={"ok": Eq(1.0), "hidden_old": Eq(False), "hidden_new": Eq(True)}, + msg="collMod should apply an index hidden change together with a validator", + ), + CommandTestCase( + "index_hidden_and_validation_level", + indexes=[IndexModel([("a", 1)], name="a_1", hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": True}, + "validationLevel": "moderate", + }, + expected={"ok": Eq(1.0), "hidden_old": Eq(False), "hidden_new": Eq(True)}, + msg="collMod should apply an index hidden change together with a validationLevel", + ), + CommandTestCase( + "validator_and_change_stream_pre_and_post_images", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"a": 1}, + "changeStreamPreAndPostImages": {"enabled": True}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should apply a validator together with changeStreamPreAndPostImages", + ), + CommandTestCase( + "validator_and_validation_level_and_action", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"a": 1}, + "validationLevel": "strict", + "validationAction": "error", + }, + expected={"ok": Eq(1.0)}, + msg="collMod should apply a validator together with validationLevel and validationAction", + ), +] + +# Property [Clustered Index And Top-Level expireAfterSeconds]: on a clustered +# collection, an index hidden change and a top-level expireAfterSeconds both +# apply in one command, echoing the index hidden state change. +COLLMOD_CLUSTERED_INDEX_AND_EXPIRE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "clustered_index_hidden_and_expire", + target_collection=ClusteredCollection(), + indexes=[IndexModel([("a", 1)], name="a_1", hidden=False)], + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "a_1", "hidden": True}, + "expireAfterSeconds": 100, + }, + expected={"ok": Eq(1.0), "hidden_old": Eq(False), "hidden_new": Eq(True)}, + msg="collMod should apply an index hidden change and a top-level expireAfterSeconds " + "on a clustered collection", + ), +] + +# Property [Validation Options Without Existing Validator]: validationLevel and +# validationAction set together on a collection that never had a validator are +# accepted, so they do not require a pre-existing validator. +COLLMOD_VALIDATION_NO_PRIOR_VALIDATOR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "validation_level_and_action_together", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validationLevel": "moderate", + "validationAction": "warn", + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept validationLevel and validationAction together with no " + "pre-existing validator", + ), +] + +# Property [All-Null Across Groups]: when every cross-group option is null, the +# command is a no-op success that echoes no modification fields. +COLLMOD_ALL_NULL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "all_null_across_groups", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": None, + "validationLevel": None, + "validationAction": None, + "index": None, + "changeStreamPreAndPostImages": None, + }, + expected={ + "ok": Eq(1.0), + "hidden_old": NotExists(), + "hidden_new": NotExists(), + "expireAfterSeconds_old": NotExists(), + "expireAfterSeconds_new": NotExists(), + }, + msg="collMod should treat all-null cross-group options as a no-op", + ), +] + +# Property [Time Series Cross-Group Coexistence]: a timeseries modification +# applies together with a top-level expireAfterSeconds, a comment, or a +# writeConcern on a time series collection. +COLLMOD_TIMESERIES_CROSS_GROUP_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "timeseries_and_top_level_expire", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"granularity": "minutes"}, + "expireAfterSeconds": 100, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should apply a timeseries modification and a top-level expireAfterSeconds " + "on a time series collection", + ), + CommandTestCase( + "timeseries_and_comment", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"granularity": "minutes"}, + "comment": "hello", + }, + expected={"ok": Eq(1.0)}, + msg="collMod should apply a timeseries modification together with a comment", + ), + CommandTestCase( + "timeseries_and_write_concern", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"granularity": "minutes"}, + "writeConcern": {}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should apply a timeseries modification together with a writeConcern", + ), +] + +# Property [Time Series Ignores Capped Options]: a timeseries modification +# combined with cappedSize or cappedMax on a time series collection is a silent +# no-op success, since the capped options are ignored rather than rejected. +COLLMOD_TIMESERIES_CAPPED_NOOP_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "timeseries_and_capped_size", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {}, + "cappedSize": 100_000, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should silently ignore cappedSize on a time series collection", + ), + CommandTestCase( + "timeseries_and_capped_max", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {}, + "cappedMax": 1000, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should silently ignore cappedMax on a time series collection", + ), +] + +# Property [View Cross-Group Coexistence]: view options coexist in one command, +# so a null viewOn paired with a null pipeline is a no-op success and a viewOn +# value paired with a comment is accepted. +COLLMOD_VIEW_CROSS_GROUP_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "view_on_null_and_pipeline_null", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "viewOn": None, "pipeline": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should treat a null viewOn and null pipeline as a no-op", + ), + CommandTestCase( + "view_on_and_comment", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "viewOn": "some_source", + "comment": "hello", + }, + expected={"ok": Eq(1.0)}, + msg="collMod should apply a viewOn together with a comment", + ), +] + +# Property [Capped Size And Validator Coexistence]: a cappedSize modification +# and a validator apply together in one command on a capped collection. +COLLMOD_CAPPED_SIZE_AND_VALIDATOR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "capped_size_and_validator", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "cappedSize": 100_000, + "validator": {"a": 1}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should apply a cappedSize and a validator together on a capped collection", + ), +] + +# Property [Validation Change Result Shape]: a successful validator, +# validationLevel, or validationAction change returns ok:1.0 and echoes no +# old/new modification fields, unlike an index hidden or expireAfterSeconds +# change which does echo them. +COLLMOD_VALIDATION_RESULT_SHAPE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "result_shape_validator", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a": 1}}, + expected={ + "ok": Eq(1.0), + "validator_old": NotExists(), + "validator_new": NotExists(), + }, + msg="collMod should return ok:1.0 with no old/new echo for a validator change", + ), + CommandTestCase( + "result_shape_validation_level", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validationLevel": "moderate"}, + expected={ + "ok": Eq(1.0), + "validationLevel_old": NotExists(), + "validationLevel_new": NotExists(), + }, + msg="collMod should return ok:1.0 with no old/new echo for a validationLevel change", + ), + CommandTestCase( + "result_shape_validation_action", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validationAction": "warn"}, + expected={ + "ok": Eq(1.0), + "validationAction_old": NotExists(), + "validationAction_new": NotExists(), + }, + msg="collMod should return ok:1.0 with no old/new echo for a validationAction change", + ), +] + +COLLMOD_INTERACTIONS_SUCCESS_TESTS: list[CommandTestCase] = ( + COLLMOD_CROSS_GROUP_TESTS + + COLLMOD_CLUSTERED_INDEX_AND_EXPIRE_TESTS + + COLLMOD_VALIDATION_NO_PRIOR_VALIDATOR_TESTS + + COLLMOD_ALL_NULL_TESTS + + COLLMOD_TIMESERIES_CROSS_GROUP_TESTS + + COLLMOD_TIMESERIES_CAPPED_NOOP_TESTS + + COLLMOD_VIEW_CROSS_GROUP_TESTS + + COLLMOD_CAPPED_SIZE_AND_VALIDATOR_TESTS + + COLLMOD_VALIDATION_RESULT_SHAPE_TESTS +) + +# Property [Unrelated Option Does Not Suppress Index Resolution]: when an index +# modification names a nonexistent index, an unrelated option group present in +# the same command does not suppress the index lookup, so the index-not-found +# error still surfaces. +COLLMOD_INDEX_RESOLUTION_NOT_SUPPRESSED_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "capped_size_and_index_missing", + target_collection=CappedCollection(), + indexes=[IndexModel([("a", 1)], name="a_1")], + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "cappedSize": 200_000, + "index": {"name": "nonexistent", "hidden": True}, + }, + error_code=INDEX_NOT_FOUND_ERROR, + msg="collMod should surface an index-not-found error when a missing index is combined " + "with a cappedSize", + ), +] + +# Property [View Options On A Regular Collection]: a view-only option applied to +# a regular (non-view) collection is rejected rather than converting the +# collection into a view. +COLLMOD_VIEW_OPTION_ON_REGULAR_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "regular_collection_view_on", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "viewOn": "some_source"}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a viewOn applied to a regular collection", + ), + CommandTestCase( + "regular_collection_pipeline", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "pipeline": []}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a pipeline applied to a regular collection", + ), +] + +# Property [Time Series Index Resolution]: an index modification on a time +# series collection resolves against the system.buckets namespace that backs the +# collection, so a nonexistent index produces an index-not-found error +# referencing that namespace. +COLLMOD_TIMESERIES_INDEX_RESOLUTION_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "timeseries_index_resolves_against_bucket_namespace", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "index": {"name": "nonexistent", "hidden": True}, + }, + error_code=INDEX_NOT_FOUND_ERROR, + msg="collMod should resolve an index on a time series collection against the " + "bucket-backing namespace, producing an index-not-found error", + ), +] + +COLLMOD_INTERACTIONS_ERROR_TESTS: list[CommandTestCase] = ( + COLLMOD_INDEX_RESOLUTION_NOT_SUPPRESSED_ERROR_TESTS + + COLLMOD_VIEW_OPTION_ON_REGULAR_ERROR_TESTS + + COLLMOD_TIMESERIES_INDEX_RESOLUTION_ERROR_TESTS +) + +COLLMOD_INTERACTIONS_TESTS: list[CommandTestCase] = ( + COLLMOD_INTERACTIONS_SUCCESS_TESTS + COLLMOD_INTERACTIONS_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_INTERACTIONS_TESTS)) +def test_collMod_interactions(database_client, collection, test): + """Test collMod cross-option parameter interactions.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_pipeline.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_pipeline.py new file mode 100644 index 000000000..6e4fde4e5 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_pipeline.py @@ -0,0 +1,411 @@ +"""Tests for collMod view pipeline.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + CHANGE_STREAM_NOT_ALLOWED_ERROR, + FACET_PIPELINE_INVALID_STAGE_ERROR, + GRAPH_CONTAINS_CYCLE_ERROR, + INVALID_NAMESPACE_ERROR, + LOOKUP_SUB_PIPELINE_NOT_ALLOWED_ERROR, + OPTION_NOT_SUPPORTED_ON_VIEW_ERROR, + PIPELINE_STAGE_EXTRA_FIELD_ERROR, + TYPE_MISMATCH_ERROR, + UNION_WITH_SUB_PIPELINE_NOT_ALLOWED_ERROR, + UNKNOWN_PIPELINE_STAGE_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.target_collection import ViewCollection +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, +) + +# Property [pipeline Success]: an array pipeline is accepted as a view +# definition, null is accepted as an omitted field, null elements are silently +# dropped, a large stage count is accepted, and a range of stages valid in a +# view definition are accepted. +COLLMOD_PIPELINE_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_array", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": []}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an empty pipeline array", + ), + CommandTestCase( + "single_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$match": {"a": 1}}]}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a pipeline with a single valid object stage", + ), + CommandTestCase( + "pipeline_null", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null pipeline as an omitted field", + ), + CommandTestCase( + "single_null_element", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [None]}, + expected={"ok": Eq(1.0)}, + msg="collMod should silently drop a lone null pipeline element", + ), + CommandTestCase( + "stage_then_null_element", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [{"$match": {"a": 1}}, None], + }, + expected={"ok": Eq(1.0)}, + msg="collMod should silently drop a trailing null pipeline element while keeping the stage", + ), + CommandTestCase( + "many_stages", + target_collection=ViewCollection(), + # The server caps a view pipeline's stage count below the standard 10_000 + # stress value, so 1000 stages is the largest count shown to be accepted. + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$match": {"a": 1}}] * 1000}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a pipeline with 1000 stages", + ), + CommandTestCase( + "coll_stats_stage", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [{"$collStats": {"storageStats": {}}}], + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $collStats stage in a view definition", + ), + CommandTestCase( + "index_stats_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$indexStats": {}}]}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an $indexStats stage in a view definition", + ), + CommandTestCase( + "sample_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$sample": {"size": 1}}]}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $sample stage in a view definition", + ), + CommandTestCase( + "lookup_stage", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [ + { + "$lookup": { + "from": "other", + "localField": "a", + "foreignField": "a", + "as": "r", + } + } + ], + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $lookup stage in a view definition", + ), + CommandTestCase( + "graph_lookup_stage", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [ + { + "$graphLookup": { + "from": "other", + "startWith": "$a", + "connectFromField": "a", + "connectToField": "a", + "as": "r", + } + } + ], + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $graphLookup stage in a view definition", + ), + CommandTestCase( + "facet_stage", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [{"$facet": {"f": [{"$match": {"a": 1}}]}}], + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $facet stage in a view definition", + ), + CommandTestCase( + "union_with_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$unionWith": "other"}]}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $unionWith stage in a view definition", + ), +] + +# Property [pipeline Type Rejection]: any non-array value for pipeline produces +# a TypeMismatch error. +COLLMOD_PIPELINE_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"pipeline_type_{tid}", + target_collection=ViewCollection(), + command=lambda ctx, v=val: {"collMod": ctx.collection, "pipeline": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} pipeline as a non-array", + ) + for tid, val in [ + ("string", "x"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [pipeline Element Type Rejection]: any non-object, non-null array +# element produces a TypeMismatch error. +COLLMOD_PIPELINE_ELEMENT_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "element_string", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": ["x"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="collMod should reject a non-object pipeline element", + ), + CommandTestCase( + "element_null_then_string", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [None, "x"]}, + error_code=TYPE_MISMATCH_ERROR, + msg="collMod should reject a non-object element after a dropped null element", + ), +] + +# Property [pipeline Stage Shape Rejection]: a stage object that does not contain +# exactly one field produces a stage-shape error. +COLLMOD_PIPELINE_STAGE_SHAPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{}]}, + error_code=PIPELINE_STAGE_EXTRA_FIELD_ERROR, + msg="collMod should reject an empty stage object", + ), + CommandTestCase( + "two_key_stage", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [{"$match": {}, "$limit": 1}], + }, + error_code=PIPELINE_STAGE_EXTRA_FIELD_ERROR, + msg="collMod should reject a stage object with two fields", + ), +] + +# Property [pipeline Unknown Stage Rejection]: a stage whose key is not a +# recognized pipeline stage name produces an unrecognized-stage error, including +# a key with no dollar prefix. +COLLMOD_PIPELINE_UNKNOWN_STAGE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$nope": {}}]}, + error_code=UNKNOWN_PIPELINE_STAGE_ERROR, + msg="collMod should reject an unknown pipeline stage name", + ), + CommandTestCase( + "dollarless_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"match": {}}]}, + error_code=UNKNOWN_PIPELINE_STAGE_ERROR, + msg="collMod should reject a pipeline stage key with no dollar prefix", + ), +] + +# Property [pipeline Prohibited Output Stage Rejection]: a $out or $merge stage +# is prohibited in a view definition, with the error code determined by its +# nesting location. +COLLMOD_PIPELINE_OUTPUT_STAGE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "out_top_level", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$out": "dest"}]}, + error_code=OPTION_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="collMod should reject a top-level $out stage in a view definition", + ), + CommandTestCase( + "merge_top_level", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$merge": "dest"}]}, + error_code=OPTION_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="collMod should reject a top-level $merge stage in a view definition", + ), + CommandTestCase( + "out_in_lookup", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [{"$lookup": {"from": "other", "as": "r", "pipeline": [{"$out": "dest"}]}}], + }, + error_code=LOOKUP_SUB_PIPELINE_NOT_ALLOWED_ERROR, + msg="collMod should reject a $out stage inside a $lookup sub-pipeline", + ), + CommandTestCase( + "out_in_facet", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [{"$facet": {"f": [{"$out": "dest"}]}}], + }, + error_code=FACET_PIPELINE_INVALID_STAGE_ERROR, + msg="collMod should reject a $out stage inside a $facet", + ), + CommandTestCase( + "out_in_union_with", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [{"$unionWith": {"coll": "other", "pipeline": [{"$out": "dest"}]}}], + }, + error_code=UNION_WITH_SUB_PIPELINE_NOT_ALLOWED_ERROR, + msg="collMod should reject a $out stage inside a $unionWith sub-pipeline", + ), +] + +# Property [pipeline View-Incompatible Stage Rejection]: a $changeStream stage is +# rejected as an option not supported on a view, and $documents, $currentOp, and +# $listSessions stages are rejected as invalid namespaces in a view definition. +COLLMOD_PIPELINE_VIEW_INCOMPATIBLE_STAGE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "change_stream_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$changeStream": {}}]}, + error_code=OPTION_NOT_SUPPORTED_ON_VIEW_ERROR, + msg="collMod should reject a $changeStream stage in a view definition", + marks=(pytest.mark.replica_set,), + ), + CommandTestCase( + "change_stream_stage_unavailable", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$changeStream": {}}]}, + error_code=CHANGE_STREAM_NOT_ALLOWED_ERROR, + msg="collMod should reject a $changeStream stage where change streams are unavailable", + ), + CommandTestCase( + "documents_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$documents": []}]}, + error_code=INVALID_NAMESPACE_ERROR, + msg="collMod should reject a $documents stage in a view definition", + ), + CommandTestCase( + "current_op_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$currentOp": {}}]}, + error_code=INVALID_NAMESPACE_ERROR, + msg="collMod should reject a $currentOp stage in a view definition", + ), + CommandTestCase( + "list_sessions_stage", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "pipeline": [{"$listSessions": {}}]}, + error_code=INVALID_NAMESPACE_ERROR, + msg="collMod should reject a $listSessions stage in a view definition", + ), +] + +# Property [pipeline Self Reference Rejection]: a $lookup or $unionWith stage +# that references the view's own name produces a GraphContainsCycle error. +COLLMOD_PIPELINE_CYCLE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "lookup_self_reference", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [ + { + "$lookup": { + "from": ctx.collection, + "localField": "a", + "foreignField": "a", + "as": "r", + } + } + ], + }, + error_code=GRAPH_CONTAINS_CYCLE_ERROR, + msg="collMod should reject a $lookup self-reference as a cycle", + ), + CommandTestCase( + "union_with_self_reference", + target_collection=ViewCollection(), + command=lambda ctx: { + "collMod": ctx.collection, + "pipeline": [{"$unionWith": ctx.collection}], + }, + error_code=GRAPH_CONTAINS_CYCLE_ERROR, + msg="collMod should reject a $unionWith self-reference as a cycle", + ), +] + +COLLMOD_PIPELINE_TESTS: list[CommandTestCase] = ( + COLLMOD_PIPELINE_SUCCESS_TESTS + + COLLMOD_PIPELINE_TYPE_ERROR_TESTS + + COLLMOD_PIPELINE_ELEMENT_TYPE_ERROR_TESTS + + COLLMOD_PIPELINE_STAGE_SHAPE_ERROR_TESTS + + COLLMOD_PIPELINE_UNKNOWN_STAGE_ERROR_TESTS + + COLLMOD_PIPELINE_OUTPUT_STAGE_ERROR_TESTS + + COLLMOD_PIPELINE_VIEW_INCOMPATIBLE_STAGE_ERROR_TESTS + + COLLMOD_PIPELINE_CYCLE_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_PIPELINE_TESTS)) +def test_collMod_pipeline(database_client, collection, test): + """Test collMod view pipeline acceptance and rejection.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_read_concern.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_read_concern.py new file mode 100644 index 000000000..f4402477e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_read_concern.py @@ -0,0 +1,201 @@ +"""Tests for the collMod readConcern option.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF + +# Property [readConcern Acceptance]: readConcern is accepted when it is an empty +# document, the supported "local" level, null (treated as omitted), or an object +# whose level sub-field is null (treated as absent), without changing the +# command result. +COLLMOD_READ_CONCERN_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_document", + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "readConcern": {}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an empty readConcern document", + ), + CommandTestCase( + "level_local", + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "readConcern": {"level": "local"}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a readConcern level of local", + ), + CommandTestCase( + "null", + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "readConcern": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null readConcern as an omitted field", + ), + CommandTestCase( + "level_null", + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "readConcern": {"level": None}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null readConcern level as an absent sub-field", + ), +] + +# Property [readConcern Unsupported Level Rejection]: a recognized read concern +# level that collMod does not support (majority, available, snapshot, +# linearizable) produces an InvalidOptions error. +COLLMOD_READ_CONCERN_UNSUPPORTED_LEVEL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"unsupported_{level}", + docs=[], + command=lambda ctx, v=level: {"collMod": ctx.collection, "readConcern": {"level": v}}, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject the {level} readConcern level as unsupported", + ) + for level in ["majority", "available", "snapshot", "linearizable"] +] + +# Property [readConcern Invalid Level Enum Rejection]: the level enum is +# case-sensitive and applies no whitespace trimming, so any string that is not a +# recognized level produces a BadValue error. +COLLMOD_READ_CONCERN_INVALID_LEVEL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"invalid_level_{tid}", + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "readConcern": {"level": v}}, + error_code=BAD_VALUE_ERROR, + msg=f"collMod should reject {tid} as a readConcern level enum value", + ) + for tid, val in [ + ("empty", ""), + ("arbitrary", "bogus"), + ("capitalized", "Local"), + ("uppercase", "LOCAL"), + ("trailing_space", "local "), + ] +] + +# Property [readConcern.level Type Rejection]: a level sub-field value that is +# neither a string nor null produces a TypeMismatch error. +COLLMOD_READ_CONCERN_LEVEL_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"level_type_{tid}", + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "readConcern": {"level": v}}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} readConcern level as a non-string", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("array", ["local"]), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [readConcern Top-Level Type Rejection]: a readConcern value that is +# neither an object nor null produces a TypeMismatch error. +COLLMOD_READ_CONCERN_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"type_{tid}", + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "readConcern": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} readConcern as a non-object", + ) + for tid, val in [ + ("string", "local"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("array", [{"level": "local"}]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [readConcern Unknown Sub-Field Rejection]: an unrecognized +# readConcern sub-field produces an UnknownField error, and that error fires +# even when the supported level sub-field is also present. +COLLMOD_READ_CONCERN_UNKNOWN_FIELD_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_field_only", + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "readConcern": {"bogus": 1}}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="collMod should reject an unknown readConcern sub-field", + ), + CommandTestCase( + "unknown_field_with_level", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "readConcern": {"level": "local", "bogus": 1}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="collMod should reject an unknown readConcern sub-field even with a level present", + ), +] + +COLLMOD_READ_CONCERN_ALL_TESTS: list[CommandTestCase] = ( + COLLMOD_READ_CONCERN_SUCCESS_TESTS + + COLLMOD_READ_CONCERN_UNSUPPORTED_LEVEL_ERROR_TESTS + + COLLMOD_READ_CONCERN_INVALID_LEVEL_ERROR_TESTS + + COLLMOD_READ_CONCERN_LEVEL_TYPE_ERROR_TESTS + + COLLMOD_READ_CONCERN_TYPE_ERROR_TESTS + + COLLMOD_READ_CONCERN_UNKNOWN_FIELD_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_READ_CONCERN_ALL_TESTS)) +def test_collMod_read_concern(database_client, collection, test): + """Test collMod readConcern option behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_target_name.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_target_name.py new file mode 100644 index 000000000..6138c9a27 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_target_name.py @@ -0,0 +1,151 @@ +"""Tests for collMod target name (the collMod command value) acceptance.""" + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INVALID_NAMESPACE_ERROR, + NAMESPACE_NOT_FOUND_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.target_collection import ( + CappedCollection, + ClusteredCollection, + TimeseriesCollection, + ViewCollection, +) + +# Property [Target Name Resolution]: a string naming an existing collection or +# view in the current database resolves successfully, and with no option field +# present the command is a no-op success regardless of the target's type. +COLLMOD_TARGET_NAME_RESOLUTION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "plain_collection", + docs=[{"_id": 1}], + command=lambda ctx: {"collMod": ctx.collection}, + expected={"ok": Eq(1.0)}, + msg="collMod should resolve a plain collection name as a no-op success", + ), + CommandTestCase( + "capped_collection", + target_collection=CappedCollection(size=4096), + docs=[{"_id": 1}], + command=lambda ctx: {"collMod": ctx.collection}, + expected={"ok": Eq(1.0)}, + msg="collMod should resolve a capped collection name as a no-op success", + ), + CommandTestCase( + "view", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection}, + expected={"ok": Eq(1.0)}, + msg="collMod should resolve a view name as a no-op success", + ), + CommandTestCase( + "timeseries_collection", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection}, + expected={"ok": Eq(1.0)}, + msg="collMod should resolve a time series collection name as a no-op success", + ), + CommandTestCase( + "clustered_collection", + target_collection=ClusteredCollection(), + docs=[{"_id": 1}], + command=lambda ctx: {"collMod": ctx.collection}, + expected={"ok": Eq(1.0)}, + msg="collMod should resolve a clustered collection name as a no-op success", + ), +] + +# Property [Target Name Validation Wiring]: a structurally invalid name and a +# valid name that matches no collection each produce the expected namespace +# error. +COLLMOD_TARGET_NAME_WIRING_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "invalid_namespace_empty_string", + command={"collMod": ""}, + error_code=INVALID_NAMESPACE_ERROR, + msg="collMod should reject a structurally invalid namespace as an invalid namespace", + ), + CommandTestCase( + "not_found_nonexistent", + docs=None, + command=lambda ctx: {"collMod": ctx.collection}, + error_code=NAMESPACE_NOT_FOUND_ERROR, + msg="collMod should reject a valid name that matches no collection as not found", + ), +] + +# Property [Non-String Target Type Errors]: any non-string type for the target +# (including null and any array shape) produces an invalid namespace error. +COLLMOD_TARGET_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"type_{tid}", + command={"collMod": val}, + error_code=INVALID_NAMESPACE_ERROR, + msg=f"collMod should reject a {tid} target as an invalid namespace type", + ) + for tid, val in [ + ("int32", 123), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal128", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("null", None), + ("object", {"x": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ("array_empty", []), + ("array_string", ["target"]), + ] +] + +COLLMOD_TARGET_NAME_TESTS: list[CommandTestCase] = ( + COLLMOD_TARGET_NAME_RESOLUTION_TESTS + + COLLMOD_TARGET_NAME_WIRING_ERROR_TESTS + + COLLMOD_TARGET_TYPE_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_TARGET_NAME_TESTS)) +def test_collMod_target_name(database_client, collection, test): + """Test collMod target name resolution, no-op success, and validation wiring.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_bucketing.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_bucketing.py new file mode 100644 index 000000000..54280422b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_bucketing.py @@ -0,0 +1,369 @@ +"""Tests for collMod time series bucketing (bucketRoundingSeconds / bucketMaxSpanSeconds).""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.target_collection import ( + TimeseriesCollection, + TimeseriesCustomBucketCollection, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + FLOAT_INFINITY, + FLOAT_NAN, + INT64_MAX, +) + +# Property [Bucketing Numeric Type Coercion]: the coupled `bucketRoundingSeconds` +# and `bucketMaxSpanSeconds` accept any numeric type. +COLLMOD_TS_BUCKET_NUMERIC_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "numeric_int32", + target_collection=TimeseriesCustomBucketCollection(bucket_seconds=100), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": 200, "bucketMaxSpanSeconds": 200}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept int32 bucketing seconds", + ), + CommandTestCase( + "numeric_int64", + target_collection=TimeseriesCustomBucketCollection(bucket_seconds=100), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": { + "bucketRoundingSeconds": Int64(200), + "bucketMaxSpanSeconds": Int64(200), + }, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept int64 bucketing seconds", + ), + CommandTestCase( + "numeric_double", + target_collection=TimeseriesCustomBucketCollection(bucket_seconds=100), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": 200.0, "bucketMaxSpanSeconds": 200.0}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept double bucketing seconds", + ), + CommandTestCase( + "numeric_decimal", + target_collection=TimeseriesCustomBucketCollection(bucket_seconds=100), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": { + "bucketRoundingSeconds": Decimal128("200"), + "bucketMaxSpanSeconds": Decimal128("200"), + }, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept decimal128 bucketing seconds", + ), +] + +# Property [Bucketing Equality After Truncation]: the two coupled fields must be +# equal, and equality is checked after truncation toward zero, so distinct +# fractional inputs that truncate to the same integer are accepted as equal. +COLLMOD_TS_BUCKET_EQUALITY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "equal_after_truncation", + target_collection=TimeseriesCustomBucketCollection(bucket_seconds=100), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": { + "bucketRoundingSeconds": 200.9, + "bucketMaxSpanSeconds": 200.1, + }, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept bucketing seconds that are equal only after truncation", + ), +] + +# Property [Bucketing Range Boundaries]: the lower and upper boundary values +# are both accepted (the range is inclusive at both ends). +COLLMOD_TS_BUCKET_BOUNDARY_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "lower_boundary_1", + target_collection=TimeseriesCustomBucketCollection(bucket_seconds=1), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": 1, "bucketMaxSpanSeconds": 1}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept the lower boundary bucketing seconds of 1", + ), + CommandTestCase( + "upper_boundary_31536000", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": { + "bucketRoundingSeconds": 31_536_000, + "bucketMaxSpanSeconds": 31_536_000, + }, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept the upper boundary bucketing seconds of 31536000", + ), +] + +# Property [Bucketing Increase-Only Inclusive]: a new bucketing value greater +# than or equal to the existing/implied value is accepted, including a new value +# equal to the value implied by the seconds granularity. +COLLMOD_TS_BUCKET_INCREASE_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "equal_implied_3600", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": 3600, "bucketMaxSpanSeconds": 3600}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a new bucketing value equal to the implied 3600", + ), +] + +# Property [Bucketing Null No-Op]: a null value for both coupled fields is an +# accepted no-op. +COLLMOD_TS_BUCKET_NULL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "null_both", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": None, "bucketMaxSpanSeconds": None}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept null bucketing seconds as a no-op", + ), +] + +# Property [Bucketing Type Rejection]: a non-numeric bucketing value is rejected +# with a TypeMismatch error, and an array is not unwrapped into a numeric value. +COLLMOD_TS_BUCKET_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"bucket_type_{tid}", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": v, "bucketMaxSpanSeconds": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} bucketing value as the wrong type", + ) + for tid, val in [ + ("string", "x"), + ("bool_true", True), + ("bool_false", False), + ("array", [1]), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Bucketing Below-Minimum Rejection]: a bucketing value below the +# minimum after truncation toward zero is rejected with a BadValue error, +# including values that coerce to zero (a fraction truncates, NaN coerces). +COLLMOD_TS_BUCKET_BELOW_MIN_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"bucket_below_min_{nid}", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": v, "bucketMaxSpanSeconds": v}, + }, + error_code=BAD_VALUE_ERROR, + msg=f"collMod should reject a {nid} bucketing value as below the minimum of 1", + ) + for nid, val in [ + ("zero", 0), + ("negative", -1), + ("half", 0.5), + ("nan_float", FLOAT_NAN), + ("nan_decimal", DECIMAL128_NAN), + ] +] + +# Property [Bucketing Above-Maximum Rejection]: a bucketing value above the +# maximum after coercion is rejected with a BadValue error, including infinite and +# int64-max inputs that clamp to int32 max and still exceed the ceiling. +COLLMOD_TS_BUCKET_ABOVE_MAX_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"bucket_above_max_{nid}", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": v, "bucketMaxSpanSeconds": v}, + }, + error_code=BAD_VALUE_ERROR, + msg=f"collMod should reject a {nid} bucketing value as above the maximum of 31536000", + ) + for nid, val in [ + ("just_above", 31_536_001), + ("float_infinity", FLOAT_INFINITY), + ("decimal_infinity", DECIMAL128_INFINITY), + ("int64_max", INT64_MAX), + ] +] + +# Property [Bucketing Coupling Rejection]: setting only one of the two coupled +# fields, or setting them to values that are unequal after truncation, is +# rejected with an InvalidOptions error. +COLLMOD_TS_BUCKET_COUPLING_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "only_max_span", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"bucketMaxSpanSeconds": 3600}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject setting only bucketMaxSpanSeconds without bucketRoundingSeconds", + ), + CommandTestCase( + "only_rounding", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": 3600}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject setting only bucketRoundingSeconds without bucketMaxSpanSeconds", + ), + CommandTestCase( + "unequal_after_truncation", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": { + "bucketRoundingSeconds": 3600.9, + "bucketMaxSpanSeconds": 3601.1, + }, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject coupled bucketing values that are unequal after truncation", + ), +] + +# Property [Bucketing Decrease Rejection]: a new bucketing value less than the +# existing/implied bucketMaxSpanSeconds is rejected with an InvalidOptions +# error, with the value implied by the seconds granularity as the threshold. +COLLMOD_TS_BUCKET_DECREASE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "below_implied_3600", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"bucketRoundingSeconds": 3599, "bucketMaxSpanSeconds": 3599}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a new bucketing value below the implied 3600", + ), +] + +# Property [Bucketing With Granularity Rejection]: combining granularity with a +# custom bucketing value that differs from the granularity's default in the same +# timeseries document is rejected with an InvalidOptions error. +COLLMOD_TS_BUCKET_WITH_GRANULARITY_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "granularity_and_both", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": { + "granularity": "seconds", + "bucketRoundingSeconds": 7200, + "bucketMaxSpanSeconds": 7200, + }, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject granularity combined with non-default bucketing fields", + ), +] + +COLLMOD_TS_BUCKETING_TESTS: list[CommandTestCase] = ( + COLLMOD_TS_BUCKET_NUMERIC_TESTS + + COLLMOD_TS_BUCKET_EQUALITY_TESTS + + COLLMOD_TS_BUCKET_BOUNDARY_TESTS + + COLLMOD_TS_BUCKET_INCREASE_TESTS + + COLLMOD_TS_BUCKET_NULL_TESTS + + COLLMOD_TS_BUCKET_TYPE_ERROR_TESTS + + COLLMOD_TS_BUCKET_BELOW_MIN_ERROR_TESTS + + COLLMOD_TS_BUCKET_ABOVE_MAX_ERROR_TESTS + + COLLMOD_TS_BUCKET_COUPLING_ERROR_TESTS + + COLLMOD_TS_BUCKET_DECREASE_ERROR_TESTS + + COLLMOD_TS_BUCKET_WITH_GRANULARITY_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_TS_BUCKETING_TESTS)) +def test_collMod_time_series_bucketing(database_client, collection, test): + """Test collMod time series bucketing acceptance and rejection.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_document.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_document.py new file mode 100644 index 000000000..c2316e6cb --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_document.py @@ -0,0 +1,171 @@ +"""Tests for collMod time series document acceptance and rejection.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.target_collection import ( + CappedCollection, + ClusteredCollection, + TimeseriesCollection, + ViewCollection, +) + +# Property [Timeseries Document Acceptance]: on a time series collection the +# `timeseries` document is accepted, with the empty document and null both +# treated as no-ops. +COLLMOD_TS_DOC_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_document", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "timeseries": {}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an empty timeseries document as a no-op", + ), + CommandTestCase( + "null_document", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "timeseries": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null timeseries document as a no-op", + ), +] + +# Property [Timeseries Type Rejection]: a non-object timeseries value is +# rejected with a TypeMismatch error, and an array is not unwrapped into a +# document. +COLLMOD_TS_DOC_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"doc_type_{tid}", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "timeseries": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} timeseries value as the wrong type", + ) + for tid, val in [ + ("string", "x"), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("array", [{"granularity": "seconds"}]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Timeseries Unknown Sub-Field Rejection]: an unrecognized sub-field +# inside the timeseries document is rejected, including the creation-only +# timeField and metaField sub-fields that are not modifiable through collMod. +COLLMOD_TS_DOC_UNKNOWN_FIELD_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"unknown_{fid}", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx, sub=subdoc: {"collMod": ctx.collection, "timeseries": sub}, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg=f"collMod should reject an unknown {fid} sub-field in the timeseries document", + ) + for fid, subdoc in [ + ("arbitrary", {"bogus": 1}), + ("time_field", {"timeField": "ts"}), + ("meta_field", {"metaField": "meta"}), + ] +] + +# Property [Timeseries Unsupported Target Rejection]: applying the timeseries +# document to a non-time-series collection (regular, capped, clustered, or view) +# is rejected because the option is only supported on time series collections. +COLLMOD_TS_DOC_TARGET_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "doc_target_regular", + docs=[{"_id": 1}], + command=lambda ctx: {"collMod": ctx.collection, "timeseries": {}}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a timeseries document on a regular collection", + ), + CommandTestCase( + "target_capped", + target_collection=CappedCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "timeseries": {}}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a timeseries document on a capped collection", + ), + CommandTestCase( + "target_clustered", + target_collection=ClusteredCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "timeseries": {}}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a timeseries document on a clustered collection", + ), + CommandTestCase( + "doc_target_view", + target_collection=ViewCollection(options={"pipeline": []}), + docs=[{"_id": 1}], + command=lambda ctx: {"collMod": ctx.collection, "timeseries": {}}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a timeseries document on a view", + ), +] + +COLLMOD_TS_DOCUMENT_TESTS: list[CommandTestCase] = ( + COLLMOD_TS_DOC_SUCCESS_TESTS + + COLLMOD_TS_DOC_TYPE_ERROR_TESTS + + COLLMOD_TS_DOC_UNKNOWN_FIELD_ERROR_TESTS + + COLLMOD_TS_DOC_TARGET_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_TS_DOCUMENT_TESTS)) +def test_collMod_time_series_document(database_client, collection, test): + """Test collMod time series document acceptance and rejection.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_expire.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_expire.py new file mode 100644 index 000000000..a2f3f294f --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_expire.py @@ -0,0 +1,371 @@ +"""Tests for collMod top-level expireAfterSeconds on time series collections.""" + +from __future__ import annotations + +import time +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.target_collection import ( + ClusteredCollection, + TimeseriesTTLCollection, + ViewCollection, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_HALF, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# The time series TTL ceiling is the current epoch seconds: a value at or below +# now is accepted, a value above it is rejected. The tests offset from now by +# this margin in each direction so they do not assume the test runner and server +# clocks match to the second. It is generous enough to absorb realistic clock +# skew plus command latency, while still bracketing the ceiling close enough to +# now that a fixed-constant ceiling could not stay inside the window across runs. +_EPOCH_MARGIN_SECONDS = 60 + +# Property [Clear TTL]: the exact lowercase string "off" clears the TTL and is +# accepted on both time series and clustered collections. +COLLMOD_TS_EXPIRE_OFF_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "off_timeseries", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": "off"}, + expected={"ok": Eq(1.0)}, + msg="collMod should clear the TTL with 'off' on a time series collection", + ), + CommandTestCase( + "off_clustered", + target_collection=ClusteredCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": "off"}, + expected={"ok": Eq(1.0)}, + msg="collMod should clear the TTL with 'off' on a clustered collection", + ), +] + +# Property [Numeric Type Acceptance]: any numeric type is accepted despite the +# declared [string, long] type, with the value set as the TTL on both time +# series and clustered collections. +COLLMOD_TS_EXPIRE_NUMERIC_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "numeric_int32_timeseries", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": 100}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an int32 expireAfterSeconds on a time series collection", + ), + CommandTestCase( + "numeric_int64_timeseries", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": Int64(100)}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an int64 expireAfterSeconds on a time series collection", + ), + CommandTestCase( + "numeric_double_timeseries", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": 100.0}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a double expireAfterSeconds on a time series collection", + ), + CommandTestCase( + "numeric_decimal_timeseries", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "expireAfterSeconds": Decimal128("100"), + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a decimal128 expireAfterSeconds on a time series collection", + ), + CommandTestCase( + "numeric_int32_clustered", + target_collection=ClusteredCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": 100}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an int32 expireAfterSeconds on a clustered collection", + ), +] + +# Property [Positive Fractional Acceptance]: a positive fractional value is +# accepted (the command response does not echo the coerced/stored value). +COLLMOD_TS_EXPIRE_POSITIVE_FRACTIONAL_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "positive_fractional_double", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": 100.9}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a positive fractional expireAfterSeconds", + ), + CommandTestCase( + "positive_fractional_decimal", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "expireAfterSeconds": Decimal128("100.9"), + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a positive fractional decimal128 expireAfterSeconds", + ), +] + +# Property [Negative-to-Zero Acceptance]: a negative value that truncates to 0 +# is accepted, exercising the near-boundary partner to the error property where +# a value truncating to <= -1 is rejected. +COLLMOD_TS_EXPIRE_NEGATIVE_ZERO_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "negative_double_near_one", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": -0.9}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a negative double expireAfterSeconds just above -1 as 0", + ), + CommandTestCase( + "negative_decimal", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "expireAfterSeconds": DECIMAL128_NEGATIVE_HALF, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a negative decimal expireAfterSeconds as 0", + ), +] + +# Property [NaN Coercion]: a NaN value (float or decimal) is coerced to 0 and +# accepted rather than rejected. +COLLMOD_TS_EXPIRE_NAN_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "nan_float", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": FLOAT_NAN}, + expected={"ok": Eq(1.0)}, + msg="collMod should coerce a float NaN expireAfterSeconds to 0", + ), + CommandTestCase( + "nan_decimal", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": DECIMAL128_NAN}, + expected={"ok": Eq(1.0)}, + msg="collMod should coerce a decimal NaN expireAfterSeconds to 0", + ), +] + +# Property [Epoch Acceptance on Time Series]: a value just below the current +# wall-clock epoch seconds is accepted on a time series collection (the accepted +# partner to the rejection of values above now). +COLLMOD_TS_EXPIRE_EPOCH_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "epoch_just_below_now", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "expireAfterSeconds": int(time.time()) - _EPOCH_MARGIN_SECONDS, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an expireAfterSeconds just below the current epoch seconds " + "on a time series collection", + ), +] + +# Property [Type Rejection]: a top-level expireAfterSeconds value whose type is +# outside the accepted string and numeric types produces a TypeMismatch error. +COLLMOD_TS_EXPIRE_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"expire_type_{tid}", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "expireAfterSeconds": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} expireAfterSeconds as the wrong type", + ) + for tid, val in [ + ("null", None), + ("bool_true", True), + ("bool_false", False), + ("array", [100]), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Invalid String Rejection]: any string other than the exact lowercase +# "off" is rejected, including case variants and whitespace-padded forms. +COLLMOD_TS_EXPIRE_STRING_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"string_{sid}", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "expireAfterSeconds": v}, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject the {sid} string expireAfterSeconds", + ) + for sid, val in [ + ("title_case", "Off"), + ("upper_case", "OFF"), + ("empty", ""), + ("on", "on"), + ("trailing_space", "off "), + ("leading_space", " off"), + ] +] + +# Property [Below-Zero Rejection]: a numeric value that truncates to <= -1 is +# rejected ("cannot be less than 0"), including -Infinity which is rejected as +# below zero rather than as an overflow. +COLLMOD_TS_EXPIRE_BELOW_ZERO_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"below_zero_{nid}", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "expireAfterSeconds": v}, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject a {nid} expireAfterSeconds as below zero", + ) + for nid, val in [ + ("int", -1), + ("double", -1.0), + ("decimal", Decimal128("-1")), + ("float_negative_infinity", FLOAT_NEGATIVE_INFINITY), + ] +] + +# Property [Overflow Rejection]: a value that overflows the int64 milliseconds +# conversion is rejected as out of int64 range. +COLLMOD_TS_EXPIRE_OVERFLOW_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"overflow_{oid}", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "expireAfterSeconds": v}, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject a {oid} expireAfterSeconds as out of int64 range", + ) + for oid, val in [ + ("float_infinity", FLOAT_INFINITY), + ("decimal_infinity", DECIMAL128_INFINITY), + # The smallest int64 whose conversion to milliseconds (the engine's + # `* 1000` cast) overflows int64, exercising the overflow rejection + # path distinct from the simpler out-of-range checks. + ("int64_millis", Int64(9223372036854776)), + ] +] + +# Property [Epoch Ceiling on Time Series]: on a time series collection, a value +# above the current wall-clock epoch seconds is rejected (the rejected partner +# to the acceptance of values below now). +COLLMOD_TS_EXPIRE_EPOCH_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "epoch_above_now", + target_collection=TimeseriesTTLCollection(), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "expireAfterSeconds": int(time.time()) + _EPOCH_MARGIN_SECONDS, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject an expireAfterSeconds above the current epoch seconds on a " + "time series collection", + ), +] + +# Property [Unsupported Target Rejection]: a top-level expireAfterSeconds applied +# to a regular collection or a view is rejected because the option is only +# supported on collections clustered by _id. +COLLMOD_TS_EXPIRE_TARGET_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "expire_target_regular", + docs=[{"_id": 1}], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": 100}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a top-level expireAfterSeconds on a regular collection", + ), + CommandTestCase( + "expire_target_view", + target_collection=ViewCollection(options={"pipeline": []}), + docs=[{"_id": 1}], + command=lambda ctx: {"collMod": ctx.collection, "expireAfterSeconds": 100}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a top-level expireAfterSeconds on a view", + ), +] + +COLLMOD_TS_EXPIRE_TESTS: list[CommandTestCase] = ( + COLLMOD_TS_EXPIRE_OFF_TESTS + + COLLMOD_TS_EXPIRE_NUMERIC_TESTS + + COLLMOD_TS_EXPIRE_POSITIVE_FRACTIONAL_TESTS + + COLLMOD_TS_EXPIRE_NEGATIVE_ZERO_TESTS + + COLLMOD_TS_EXPIRE_NAN_TESTS + + COLLMOD_TS_EXPIRE_EPOCH_TESTS + + COLLMOD_TS_EXPIRE_TYPE_ERROR_TESTS + + COLLMOD_TS_EXPIRE_STRING_ERROR_TESTS + + COLLMOD_TS_EXPIRE_BELOW_ZERO_ERROR_TESTS + + COLLMOD_TS_EXPIRE_OVERFLOW_ERROR_TESTS + + COLLMOD_TS_EXPIRE_EPOCH_ERROR_TESTS + + COLLMOD_TS_EXPIRE_TARGET_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_TS_EXPIRE_TESTS)) +def test_collMod_time_series_expire(database_client, collection, test): + """Test collMod top-level expireAfterSeconds acceptance and rejection on time series.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_granularity.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_granularity.py new file mode 100644 index 000000000..54f40bd7d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_time_series_granularity.py @@ -0,0 +1,190 @@ +"""Tests for collMod time series granularity.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.target_collection import ( + TimeseriesCollection, +) + +# Property [Granularity Enum Acceptance]: a granularity equal to one of the +# valid enum strings ("seconds", "minutes", "hours") is accepted, and a null +# granularity is an accepted no-op. +COLLMOD_TS_GRANULARITY_ENUM_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"enum_{gid}", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx, v=val: {"collMod": ctx.collection, "timeseries": {"granularity": v}}, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept a {gid} granularity", + ) + for gid, val in [ + ("seconds", "seconds"), + ("minutes", "minutes"), + ("hours", "hours"), + ("null", None), + ] +] + +# Property [Granularity Increase Transition]: a granularity change that +# increases or holds the granularity from a non-default starting granularity is +# accepted, starting from a collection already created at the prior granularity. +COLLMOD_TS_GRANULARITY_TRANSITION_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "transition_minutes_to_hours", + target_collection=TimeseriesCollection( + timeseries_options={"timeField": "ts", "metaField": "meta", "granularity": "minutes"} + ), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"granularity": "hours"}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a granularity increase from minutes to hours", + ), + CommandTestCase( + "transition_hours_same_value", + target_collection=TimeseriesCollection( + timeseries_options={"timeField": "ts", "metaField": "meta", "granularity": "hours"} + ), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"granularity": "hours"}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a same-value hours granularity as a no-op", + ), +] + +# Property [Granularity Type Rejection]: a non-string granularity value is +# rejected with a TypeMismatch error. +COLLMOD_TS_GRANULARITY_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"granularity_type_{tid}", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "timeseries": {"granularity": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} granularity as the wrong type", + ) + for tid, val in [ + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.0), + ("decimal", Decimal128("1")), + ("bool_true", True), + ("bool_false", False), + ("array", ["seconds"]), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [Granularity Enum Rejection]: the granularity enum is case-sensitive +# and accepts only the exact lowercase seconds, minutes, or hours, so any other +# string (case variants, near-misses, the empty string, and an oversized +# invalid string) produces a BadValue error rather than a string-size error. +COLLMOD_TS_GRANULARITY_ENUM_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"granularity_enum_{tid}", + target_collection=TimeseriesCollection(), + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "timeseries": {"granularity": v}, + }, + error_code=BAD_VALUE_ERROR, + msg=f"collMod should reject {tid} as a granularity enum value", + ) + for tid, val in [ + ("empty", ""), + ("capitalized", "Seconds"), + ("uppercase", "SECONDS"), + ("singular", "second"), + ("arbitrary", "days"), + ("large_invalid", "x" * 16_000_000), + ] +] + +# Property [Granularity Decrease Rejection]: a granularity change that decreases +# the granularity is rejected as an invalid transition, starting from a +# collection already created at a higher granularity. +COLLMOD_TS_GRANULARITY_DECREASE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "granularity_decrease_hours_to_minutes", + target_collection=TimeseriesCollection( + timeseries_options={"timeField": "ts", "metaField": "meta", "granularity": "hours"} + ), + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "timeseries": {"granularity": "minutes"}, + }, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a granularity decrease from hours to minutes", + ), +] + +COLLMOD_TS_GRANULARITY_TESTS: list[CommandTestCase] = ( + COLLMOD_TS_GRANULARITY_ENUM_TESTS + + COLLMOD_TS_GRANULARITY_TRANSITION_TESTS + + COLLMOD_TS_GRANULARITY_TYPE_ERROR_TESTS + + COLLMOD_TS_GRANULARITY_ENUM_ERROR_TESTS + + COLLMOD_TS_GRANULARITY_DECREASE_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_TS_GRANULARITY_TESTS)) +def test_collMod_time_series_granularity(database_client, collection, test): + """Test collMod time series granularity acceptance and rejection.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_validation_level_action.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_validation_level_action.py new file mode 100644 index 000000000..f44ee5c71 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_validation_level_action.py @@ -0,0 +1,254 @@ +"""Tests for collMod validationLevel and validationAction.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + INVALID_OPTIONS_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.target_collection import ( + TimeseriesCollection, + ViewCollection, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, +) + +# Property [validationLevel Success]: a validationLevel equal to off, strict, +# or moderate is accepted, and null is accepted as a no-op. +COLLMOD_VALIDATION_LEVEL_SUCCESS_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"level_{lvl}", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=lvl: {"collMod": ctx.collection, "validationLevel": v}, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept the {lvl} validationLevel", + ) + for lvl in ["off", "strict", "moderate"] + ], + CommandTestCase( + "level_null", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validationLevel": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null validationLevel as an omitted field", + ), +] + +# Property [validationLevel Type Rejection]: a validationLevel value that is +# neither a string nor null produces a TypeMismatch error. +COLLMOD_VALIDATION_LEVEL_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"level_type_{tid}", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: {"collMod": ctx.collection, "validationLevel": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} validationLevel as a non-string", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("array", ["strict"]), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [validationLevel Enum Rejection]: the validationLevel enum is +# case-sensitive and applies no whitespace trimming, so any string other than +# the exact lowercase off, strict, or moderate produces a BadValue error, and a +# dollar-prefixed string is rejected as a literal value rather than a field path +# or variable reference. +COLLMOD_VALIDATION_LEVEL_ENUM_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"level_enum_{tid}", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: {"collMod": ctx.collection, "validationLevel": v}, + error_code=BAD_VALUE_ERROR, + msg=f"collMod should reject {tid} as a validationLevel enum value", + ) + for tid, val in [ + ("empty", ""), + ("arbitrary", "nope"), + ("capitalized_strict", "Strict"), + ("uppercase_off", "OFF"), + ("leading_space", " strict"), + ("trailing_space", "strict "), + ("embedded_space", "str ict"), + ("nbsp", "strict\u00a0"), + ("dollar", "$"), + ("dollar_dollar", "$$"), + ("large_invalid", "x" * 16_000_000), + ] +] + +# Property [validationLevel Unsupported Collection Type Rejection]: a +# validationLevel applied to a collection type that does not support validation +# (a view or a time series collection) is rejected. +COLLMOD_VALIDATION_LEVEL_UNSUPPORTED_TARGET_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"level_unsupported_{target_id}", + docs=[], + target_collection=target, + command=lambda ctx: {"collMod": ctx.collection, "validationLevel": "strict"}, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject a validationLevel on a {target_id}", + ) + for target_id, target in [ + ("view", ViewCollection()), + ("timeseries", TimeseriesCollection()), + ] +] + +# Property [validationAction Success]: a validationAction equal to error, warn, +# or errorAndLog is accepted, and null is accepted as a no-op. +COLLMOD_VALIDATION_ACTION_SUCCESS_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"action_{action}", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=action: {"collMod": ctx.collection, "validationAction": v}, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept the {action} validationAction", + ) + for action in ["error", "warn", "errorAndLog"] + ], + CommandTestCase( + "action_null", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validationAction": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null validationAction as an omitted field", + ), +] + +# Property [validationAction Type Rejection]: a validationAction value that is +# neither a string nor null produces a TypeMismatch error. +COLLMOD_VALIDATION_ACTION_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"action_type_{tid}", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: {"collMod": ctx.collection, "validationAction": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} validationAction as a non-string", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("array", ["error"]), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [validationAction Enum Rejection]: the validationAction enum is +# case-sensitive and applies no whitespace trimming, so any string other than +# the exact lowercase error, warn, or errorAndLog produces a BadValue error, and +# a dollar-prefixed string is rejected as a literal value rather than a field +# path or variable reference. +COLLMOD_VALIDATION_ACTION_ENUM_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"action_enum_{tid}", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: {"collMod": ctx.collection, "validationAction": v}, + error_code=BAD_VALUE_ERROR, + msg=f"collMod should reject {tid} as a validationAction enum value", + ) + for tid, val in [ + ("empty", ""), + ("arbitrary", "nope"), + ("capitalized_error", "Error"), + ("uppercase_warn", "WARN"), + ("leading_space", " error"), + ("trailing_space", "error "), + ("embedded_space", "err or"), + ("nbsp", "error\u00a0"), + ("dollar", "$"), + ("dollar_dollar", "$$"), + ("large_invalid", "x" * 16_000_000), + ] +] + +# Property [validationAction Unsupported Collection Type Rejection]: a +# validationAction applied to a collection type that does not support validation +# (a view or a time series collection) is rejected. +COLLMOD_VALIDATION_ACTION_UNSUPPORTED_TARGET_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"action_unsupported_{target_id}", + docs=[], + target_collection=target, + command=lambda ctx: {"collMod": ctx.collection, "validationAction": "error"}, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject a validationAction on a {target_id}", + ) + for target_id, target in [ + ("view", ViewCollection()), + ("timeseries", TimeseriesCollection()), + ] +] + +COLLMOD_VALIDATION_LEVEL_ACTION_TESTS: list[CommandTestCase] = ( + COLLMOD_VALIDATION_LEVEL_SUCCESS_TESTS + + COLLMOD_VALIDATION_LEVEL_TYPE_ERROR_TESTS + + COLLMOD_VALIDATION_LEVEL_ENUM_ERROR_TESTS + + COLLMOD_VALIDATION_LEVEL_UNSUPPORTED_TARGET_ERROR_TESTS + + COLLMOD_VALIDATION_ACTION_SUCCESS_TESTS + + COLLMOD_VALIDATION_ACTION_TYPE_ERROR_TESTS + + COLLMOD_VALIDATION_ACTION_ENUM_ERROR_TESTS + + COLLMOD_VALIDATION_ACTION_UNSUPPORTED_TARGET_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_VALIDATION_LEVEL_ACTION_TESTS)) +def test_collMod_validation_level_action(database_client, collection, register_db_cleanup, test): + """Test collMod validationLevel and validationAction acceptance and rejection.""" + collection = test.prepare(database_client, collection) + if collection.database.name != database_client.name: + register_db_cleanup(f"{collection.database.name}.{collection.name}") + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_validator.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_validator.py new file mode 100644 index 000000000..caac74280 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_validator.py @@ -0,0 +1,410 @@ +"""Tests for collMod validator.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + FAILED_TO_PARSE_ERROR, + INVALID_OPTIONS_ERROR, + NEAR_NOT_ALLOWED_ERROR, + REGEX_COMPILE_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_EXPRESSION_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.target_collection import ( + ExistingDatabase, + SystemViewsCollection, + TimeseriesCollection, + ViewCollection, +) +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + REGEX_PATTERN_LIMIT_BYTES, +) + +# Property [validator Success]: a validator value that is an object (including +# the empty document) or null (treated as omitted) is accepted, and any object +# expressing a well-formed match query, a valid $expr, or a valid $jsonSchema is +# accepted as a collection validator. +COLLMOD_VALIDATOR_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_document", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an empty document validator", + ), + CommandTestCase( + "null", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null validator as an omitted field", + ), + CommandTestCase( + "field_equality_query", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a": 1}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a simple field-equality query validator", + ), + CommandTestCase( + "dotted_path_query", + docs=[{"_id": 1, "a": {"b": 1}}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a.b": {"$exists": True}}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a dotted-path query validator", + ), + CommandTestCase( + "type_operator", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a": {"$type": "int"}}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $type operator in a validator", + ), + CommandTestCase( + "mod_operator", + docs=[{"_id": 1, "a": 4}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a": {"$mod": [2, 0]}}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $mod operator in a validator", + ), + CommandTestCase( + "large_string_value", + docs=[{"_id": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a": "x" * 10_000}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a validator carrying a large string value", + ), + CommandTestCase( + "expr_bare_true", + docs=[{"_id": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"$expr": True}}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a bare $expr: true validator", + ), + CommandTestCase( + "expr_comparison", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"$expr": {"$gt": ["$a", 0]}}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $expr comparison validator", + ), + CommandTestCase( + "expr_root_variable", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"$expr": {"$eq": ["$$ROOT.a", "$a"]}}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $expr validator referencing the $$ROOT variable", + ), + CommandTestCase( + "expr_now_variable", + docs=[{"_id": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"$expr": {"$lte": ["$created", "$$NOW"]}}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $expr validator referencing the $$NOW variable", + ), + CommandTestCase( + "json_schema_valid_bson_type", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"$jsonSchema": {"properties": {"a": {"bsonType": "int"}}}}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a $jsonSchema validator with a valid bsonType", + ), + CommandTestCase( + "regex_pattern_at_limit", + docs=[{"_id": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"a": {"$regex": "a" * REGEX_PATTERN_LIMIT_BYTES}}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a validator regex pattern at the 16384-byte limit", + ), +] + +# Property [validator Type Rejection]: a validator value that is neither an +# object nor null produces a TypeMismatch error. +COLLMOD_VALIDATOR_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"type_{tid}", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx, v=val: {"collMod": ctx.collection, "validator": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} validator as a non-object", + ) + for tid, val in [ + ("string", "not_an_object"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("array", [{"a": 1}]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [validator Query Operator Rejection]: query operators that cannot be +# used in a collection validator are rejected, with the error code determined by +# the specific operator. +COLLMOD_VALIDATOR_OPERATOR_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "where_operator", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"$where": "true"}}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a $where operator in a validator", + ), + CommandTestCase( + "text_operator", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"$text": {"$search": "x"}}, + }, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a $text operator in a validator", + ), + CommandTestCase( + "unknown_dollar_operator", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a": {"$badOp": 1}}}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject an unknown query operator in a validator", + ), + CommandTestCase( + "near_operator", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"a": {"$near": [0, 0]}}, + }, + error_code=NEAR_NOT_ALLOWED_ERROR, + msg="collMod should reject a $near operator in a validator", + ), + CommandTestCase( + "near_sphere_operator", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"a": {"$nearSphere": [0, 0]}}, + }, + error_code=NEAR_NOT_ALLOWED_ERROR, + msg="collMod should reject a $nearSphere operator in a validator", + ), + CommandTestCase( + "geo_near_operator", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"a": {"$geoNear": [0, 0]}}, + }, + error_code=NEAR_NOT_ALLOWED_ERROR, + msg="collMod should reject a $geoNear operator in a validator", + ), + CommandTestCase( + "expr_unknown_aggregation_operator", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"$expr": {"$unknownAggOp": [1, 2]}}, + }, + error_code=UNRECOGNIZED_EXPRESSION_ERROR, + msg="collMod should reject an unknown aggregation operator inside $expr in a validator", + ), +] + +# Property [validator JSON Schema Rejection]: a $jsonSchema with an invalid +# keyword, an invalid type alias, or a conflicting type specification produces a +# FailedToParse error. +COLLMOD_VALIDATOR_JSON_SCHEMA_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "json_schema_unknown_keyword", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"$jsonSchema": {"unknownKeyword": 1}}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="collMod should reject a $jsonSchema with an unknown keyword", + ), + CommandTestCase( + "json_schema_type_integer", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"$jsonSchema": {"properties": {"a": {"type": "integer"}}}}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="collMod should reject a $jsonSchema using the integer type alias", + ), + CommandTestCase( + "json_schema_type_and_bson_type", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": { + "$jsonSchema": {"properties": {"a": {"type": "string", "bsonType": "int"}}} + }, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg="collMod should reject a $jsonSchema specifying both type and bsonType on a property", + ), +] + +# Property [validator Logical Operator Rejection]: a malformed top-level logical +# operator or an unknown dollar-prefixed top-level field produces a BadValue +# error. +COLLMOD_VALIDATOR_LOGICAL_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty_and", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"$and": []}}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject an empty $and in a validator", + ), + CommandTestCase( + "empty_or", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"$or": []}}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject an empty $or in a validator", + ), + CommandTestCase( + "non_array_or", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"$or": {"a": 1}}}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject a non-array $or in a validator", + ), + CommandTestCase( + "unknown_dollar_field", + docs=[{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"$nope": 1}}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject an unknown dollar-prefixed top-level field in a validator", + ), +] + +# Property [validator Regex Limit Rejection]: a validator regex pattern one byte +# over the inclusive size limit fails to compile. +COLLMOD_VALIDATOR_REGEX_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "regex_pattern_over_limit", + docs=[{"_id": 1}], + command=lambda ctx: { + "collMod": ctx.collection, + "validator": {"a": {"$regex": "a" * (REGEX_PATTERN_LIMIT_BYTES + 1)}}, + }, + error_code=REGEX_COMPILE_ERROR, + msg="collMod should reject a validator regex pattern one byte over the limit", + ), +] + +# Property [validator Restricted Namespace Rejection]: a validator applied to a +# collection in a restricted database (admin, config, local) or to a system.* +# collection is rejected, regardless of the validator's well-formedness. +COLLMOD_VALIDATOR_RESTRICTED_NAMESPACE_ERROR_TESTS: list[CommandTestCase] = [ + *[ + CommandTestCase( + f"restricted_db_{dbname}", + target_collection=ExistingDatabase(db_name=dbname), + # The local database does not support retryable writes, so the + # collection is created empty rather than seeded with documents; its + # existence alone is enough to trigger the validator rejection. + docs=[] if dbname == "local" else [{"_id": 1, "a": 1}], + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a": 1}}, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject a validator on a collection in the {dbname} database", + ) + for dbname in ["admin", "config", "local"] + ], + CommandTestCase( + "system_views", + target_collection=SystemViewsCollection(), + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a": 1}}, + error_code=INVALID_OPTIONS_ERROR, + msg="collMod should reject a validator on a system.* collection", + ), +] + +# Property [validator Unsupported Collection Type Rejection]: a validator +# applied to a collection type that does not support validation (a view or a +# time series collection) is rejected. +COLLMOD_VALIDATOR_UNSUPPORTED_TARGET_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"unsupported_{target_id}", + docs=[], + target_collection=target, + command=lambda ctx: {"collMod": ctx.collection, "validator": {"a": 1}}, + error_code=INVALID_OPTIONS_ERROR, + msg=f"collMod should reject a validator on a {target_id}", + ) + for target_id, target in [ + ("view", ViewCollection()), + ("timeseries", TimeseriesCollection()), + ] +] + +COLLMOD_VALIDATOR_TESTS: list[CommandTestCase] = ( + COLLMOD_VALIDATOR_SUCCESS_TESTS + + COLLMOD_VALIDATOR_TYPE_ERROR_TESTS + + COLLMOD_VALIDATOR_OPERATOR_ERROR_TESTS + + COLLMOD_VALIDATOR_JSON_SCHEMA_ERROR_TESTS + + COLLMOD_VALIDATOR_LOGICAL_ERROR_TESTS + + COLLMOD_VALIDATOR_REGEX_ERROR_TESTS + + COLLMOD_VALIDATOR_RESTRICTED_NAMESPACE_ERROR_TESTS + + COLLMOD_VALIDATOR_UNSUPPORTED_TARGET_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_VALIDATOR_TESTS)) +def test_collMod_validator(database_client, collection, register_db_cleanup, test): + """Test collMod validator acceptance and rejection.""" + collection = test.prepare(database_client, collection) + if collection.database.name != database_client.name: + register_db_cleanup(f"{collection.database.name}.{collection.name}") + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_view_on.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_view_on.py new file mode 100644 index 000000000..ebfb9cc8d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_view_on.py @@ -0,0 +1,184 @@ +"""Tests for collMod viewOn.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + GRAPH_CONTAINS_CYCLE_ERROR, + INVALID_NAMESPACE_ERROR, + TYPE_MISMATCH_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.target_collection import ViewCollection +from documentdb_tests.framework.test_constants import ( + DECIMAL128_ONE_AND_HALF, + STRING_SIZE_LIMIT_BYTES, +) + +# Property [viewOn Success]: a string viewOn is validated as a namespace but not +# checked for target existence, so any structurally valid name is accepted and +# stored verbatim - including whitespace, control characters, Unicode, and +# interior/trailing dots or database-qualified names - null is accepted as an +# omitted field, and the value has no length limit. (Structurally invalid names +# are rejected by the viewOn Namespace Rejection property.) +COLLMOD_VIEW_ON_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "nonexistent_target", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "viewOn": "no_such_collection"}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a viewOn naming a nonexistent target without validating it", + ), + CommandTestCase( + "null", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "viewOn": None}, + expected={"ok": Eq(1.0)}, + msg="collMod should accept a null viewOn as an omitted field", + ), + *[ + CommandTestCase( + f"content_{tid}", + target_collection=ViewCollection(), + command=lambda ctx, v=val: {"collMod": ctx.collection, "viewOn": v}, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept a viewOn with {tid} content verbatim", + ) + for tid, val in [ + ("single_space", " "), + ("nbsp", "a\u00a0b"), # U+00A0 no-break space. + ("control_char", "\x01"), # U+0001 start of heading. + ("two_byte_unicode", "caf\u00e9"), # U+00E9 latin small e with acute. + ("three_byte_unicode", "\u4e2d"), # U+4E2D CJK ideograph. + ("four_byte_unicode", "\U0001f600coll"), # U+1F600 grinning face. + ("trailing_dot", "trailing."), + ("interior_dots", "a.b.c"), + ("database_qualified", "db.coll"), + ] + ], + *[ + CommandTestCase( + f"length_{tid}", + target_collection=ViewCollection(), + command=lambda ctx, v=val: {"collMod": ctx.collection, "viewOn": v}, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept a {tid} viewOn value with no length-based limit", + ) + # A viewOn value has no length limit, unlike the collMod target name. + # These three sizes bracket the 16 MB BSON document size to show none of + # them hit a length-based limit. + for tid, val in [ + ("below_16mb", "a" * (STRING_SIZE_LIMIT_BYTES - 1)), + ("at_16mb", "a" * STRING_SIZE_LIMIT_BYTES), + ("above_16mb", "a" * (STRING_SIZE_LIMIT_BYTES + 1)), + ] + ], +] + +# Property [viewOn Type Rejection]: any non-string value for viewOn produces a +# TypeMismatch error. +COLLMOD_VIEW_ON_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"type_{tid}", + target_collection=ViewCollection(), + command=lambda ctx, v=val: {"collMod": ctx.collection, "viewOn": v}, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} viewOn as a non-string", + ) + for tid, val in [ + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("array", ["src"]), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [viewOn Empty Rejection]: an empty string viewOn produces a BadValue +# error. +COLLMOD_VIEW_ON_EMPTY_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "empty", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "viewOn": ""}, + error_code=BAD_VALUE_ERROR, + msg="collMod should reject an empty string viewOn", + ), +] + +# Property [viewOn Namespace Rejection]: a viewOn string that is structurally +# invalid as a namespace produces an InvalidNamespace error, where a leading +# dollar is treated as name content rather than a field path. +COLLMOD_VIEW_ON_NAMESPACE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"namespace_{tid}", + target_collection=ViewCollection(), + command=lambda ctx, v=val: {"collMod": ctx.collection, "viewOn": v}, + error_code=INVALID_NAMESPACE_ERROR, + msg=f"collMod should reject a {tid} viewOn as a structurally invalid namespace", + ) + for tid, val in [ + ("dollar_prefixed", "$x"), + ("embedded_null", "a\x00b"), + ("leading_dot", ".leading"), + ] +] + +# Property [viewOn Self Reference Rejection]: a viewOn equal to the view's own +# name produces a GraphContainsCycle error. +COLLMOD_VIEW_ON_CYCLE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "self_reference", + target_collection=ViewCollection(), + command=lambda ctx: {"collMod": ctx.collection, "viewOn": ctx.collection}, + error_code=GRAPH_CONTAINS_CYCLE_ERROR, + msg="collMod should reject a viewOn equal to the view's own name as a cycle", + ), +] + +COLLMOD_VIEW_ON_TESTS: list[CommandTestCase] = ( + COLLMOD_VIEW_ON_SUCCESS_TESTS + + COLLMOD_VIEW_ON_TYPE_ERROR_TESTS + + COLLMOD_VIEW_ON_EMPTY_ERROR_TESTS + + COLLMOD_VIEW_ON_NAMESPACE_ERROR_TESTS + + COLLMOD_VIEW_ON_CYCLE_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_VIEW_ON_TESTS)) +def test_collMod_view_on(database_client, collection, test): + """Test collMod viewOn acceptance and rejection.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_write_concern.py b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_write_concern.py new file mode 100644 index 000000000..a6b34e73e --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/collections/commands/collMod/test_collMod_write_concern.py @@ -0,0 +1,343 @@ +"""Tests for the collMod writeConcern option.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from bson import ( + Binary, + Code, + Decimal128, + Int64, + MaxKey, + MinKey, + ObjectId, + Regex, + Timestamp, +) + +from documentdb_tests.compatibility.tests.core.utils.command_test_case import ( + CommandContext, + CommandTestCase, +) +from documentdb_tests.framework.assertions import assertResult +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + FAILED_TO_PARSE_ERROR, + TYPE_MISMATCH_ERROR, + UNRECOGNIZED_COMMAND_FIELD_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.property_checks import Eq +from documentdb_tests.framework.test_constants import DECIMAL128_ONE_AND_HALF + +# Property [writeConcern Success]: a top-level writeConcern of null is treated +# as omitted and an empty document is accepted, both succeeding without changing +# the command result. +COLLMOD_WRITE_CONCERN_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "null_treated_as_omitted", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "writeConcern": None, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should treat a null writeConcern as omitted", + ), + CommandTestCase( + "empty_document", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "writeConcern": {}, + }, + expected={"ok": Eq(1.0)}, + msg="collMod should accept an empty writeConcern document", + ), +] + +# Property [writeConcern.w Portable Acceptance]: a number that resolves to 0 or +# 1 (after truncation), the "majority" tag, and an object tag are accepted on +# every topology without changing the command result. +COLLMOD_WRITE_CONCERN_W_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"w_{wid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "writeConcern": {"w": v}, + }, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept a {wid} writeConcern.w value on any topology", + ) + for wid, val in [ + ("int_zero", 0), + ("int_one", 1), + ("double_fractional", 1.5), + ("int64_one", Int64(1)), + ("decimal_one", Decimal128("1")), + ("string_majority", "majority"), + ("object", {"a": 1}), + ] +] + +# Property [writeConcern.w Quorum Acceptance On Replica Set]: a quorum write +# concern (a number above 1, an unrecognized string tag, the empty string, or +# null) is accepted on a replica set, where an unsatisfiable concern surfaces +# asynchronously as a writeConcernError so the command still returns ok:1.0. +COLLMOD_WRITE_CONCERN_W_QUORUM_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"w_quorum_{wid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "writeConcern": {"w": v}, + }, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept a {wid} quorum writeConcern.w on a replica set", + marks=(pytest.mark.replica_set,), + ) + for wid, val in [ + ("int_fifty", 50), + ("decimal_above_one", Decimal128("5")), + ("string_arbitrary", "foo"), + ("string_empty", ""), + ("null", None), + ] +] + +# Property [writeConcern.j Acceptance]: a numeric, bool, or null value is +# accepted for writeConcern.j without changing the command result. +COLLMOD_WRITE_CONCERN_J_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"j_{jid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "writeConcern": {"j": v}, + }, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept a {jid} writeConcern.j value", + ) + for jid, val in [ + ("bool_true", True), + ("bool_false", False), + ("int32", 1), + ("int64", Int64(1)), + ("double", 1.5), + ("decimal", Decimal128("1")), + ("null", None), + ] +] + +# Property [writeConcern.wtimeout Acceptance]: writeConcern.wtimeout is not +# validated in this context, so a negative number and an arbitrary string are +# both accepted without changing the command result. +COLLMOD_WRITE_CONCERN_WTIMEOUT_SUCCESS_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"wtimeout_{wid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "writeConcern": {"wtimeout": v}, + }, + expected={"ok": Eq(1.0)}, + msg=f"collMod should accept a {wid} writeConcern.wtimeout value", + ) + for wid, val in [ + ("negative_number", -5), + ("arbitrary_string", "foo"), + ] +] + +# Property [writeConcern.j Type Rejection]: any writeConcern.j value whose type +# is outside the accepted numeric and bool types produces a TypeMismatch error. +COLLMOD_WRITE_CONCERN_J_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"j_type_{tid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "writeConcern": {"j": v}, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} writeConcern.j as a non-numeric/bool", + ) + for tid, val in [ + ("string", "x"), + ("array", [1, 2]), + ("object", {"a": 1}), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [writeConcern Type Rejection]: a non-object, non-null top-level +# writeConcern produces a TypeMismatch error. +COLLMOD_WRITE_CONCERN_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"type_{tid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "writeConcern": v, + }, + error_code=TYPE_MISMATCH_ERROR, + msg=f"collMod should reject a {tid} writeConcern as a non-object", + ) + for tid, val in [ + ("string", "x"), + ("int32", 42), + ("int64", Int64(1)), + ("double", 3.14), + ("decimal128", DECIMAL128_ONE_AND_HALF), + ("bool_true", True), + ("bool_false", False), + ("array", [1, 2]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [writeConcern.w Range Rejection]: a numeric writeConcern.w outside +# the inclusive supported range (after truncation) produces a parse error. +COLLMOD_WRITE_CONCERN_W_RANGE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"w_range_{wid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "writeConcern": {"w": v}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg=f"collMod should reject an out-of-range writeConcern.w of {wid}", + ) + for wid, val in [ + ("fifty_one", 51), + ("negative_one", -1), + ("decimal_above_fifty", Decimal128("123.45")), + ] +] + +# Property [writeConcern.w Type Rejection]: a writeConcern.w value that is not a +# number, string, or object produces a parse error. +COLLMOD_WRITE_CONCERN_W_TYPE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"w_type_{tid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "writeConcern": {"w": v}, + }, + error_code=FAILED_TO_PARSE_ERROR, + msg=f"collMod should reject a {tid} writeConcern.w as non-number/string/object", + ) + for tid, val in [ + ("bool_true", True), + ("bool_false", False), + ("array", [1, 2]), + ("objectid", ObjectId("507f1f77bcf86cd799439011")), + ("datetime", datetime(2024, 1, 1, tzinfo=timezone.utc)), + ("timestamp", Timestamp(1, 1)), + ("binary", Binary(b"\x01\x02\x03")), + ("regex", Regex(".*", "i")), + ("code", Code("function(){}")), + ("minkey", MinKey()), + ("maxkey", MaxKey()), + ] +] + +# Property [writeConcern.w Quorum Rejection On Standalone]: a quorum write +# concern (a number above 1, an unrecognized string tag, the empty string, or +# null) is rejected up front on a standalone with a BadValue error, since a +# standalone can never satisfy a quorum concern. +COLLMOD_WRITE_CONCERN_W_QUORUM_STANDALONE_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + f"w_quorum_reject_{wid}", + docs=[], + command=lambda ctx, v=val: { + "collMod": ctx.collection, + "writeConcern": {"w": v}, + }, + error_code=BAD_VALUE_ERROR, + msg=f"collMod should reject a {wid} quorum writeConcern.w up front on a standalone", + ) + for wid, val in [ + ("int_fifty", 50), + ("decimal_above_one", Decimal128("5")), + ("string_arbitrary", "foo"), + ("string_empty", ""), + ("null", None), + ] +] + +# Property [writeConcern Unknown Field Rejection]: an unrecognized writeConcern +# sub-field, and a write concern option placed at the top level instead of +# nested under writeConcern, each produce an unknown-field error. +COLLMOD_WRITE_CONCERN_UNKNOWN_FIELD_ERROR_TESTS: list[CommandTestCase] = [ + CommandTestCase( + "unknown_subfield", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "writeConcern": {"bogus": 1}, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="collMod should reject an unknown writeConcern sub-field", + ), + CommandTestCase( + "bare_top_level_w", + docs=[], + command=lambda ctx: { + "collMod": ctx.collection, + "w": 1, + }, + error_code=UNRECOGNIZED_COMMAND_FIELD_ERROR, + msg="collMod should reject a bare top-level w not nested under writeConcern", + ), +] + +COLLMOD_WRITE_CONCERN_ALL_TESTS: list[CommandTestCase] = ( + COLLMOD_WRITE_CONCERN_SUCCESS_TESTS + + COLLMOD_WRITE_CONCERN_W_SUCCESS_TESTS + + COLLMOD_WRITE_CONCERN_W_QUORUM_SUCCESS_TESTS + + COLLMOD_WRITE_CONCERN_J_SUCCESS_TESTS + + COLLMOD_WRITE_CONCERN_WTIMEOUT_SUCCESS_TESTS + + COLLMOD_WRITE_CONCERN_J_TYPE_ERROR_TESTS + + COLLMOD_WRITE_CONCERN_TYPE_ERROR_TESTS + + COLLMOD_WRITE_CONCERN_W_RANGE_ERROR_TESTS + + COLLMOD_WRITE_CONCERN_W_TYPE_ERROR_TESTS + + COLLMOD_WRITE_CONCERN_W_QUORUM_STANDALONE_ERROR_TESTS + + COLLMOD_WRITE_CONCERN_UNKNOWN_FIELD_ERROR_TESTS +) + + +@pytest.mark.collection_mgmt +@pytest.mark.parametrize("test", pytest_params(COLLMOD_WRITE_CONCERN_ALL_TESTS)) +def test_collMod_write_concern(database_client, collection, test): + """Test collMod writeConcern option behavior.""" + collection = test.prepare(database_client, collection) + ctx = CommandContext.from_collection(collection) + result = execute_command(collection, test.build_command(ctx)) + assertResult( + result, + expected=test.build_expected(ctx), + error_code=test.error_code, + msg=test.msg, + raw_res=True, + ) diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 2375b9dcd..2f22801ad 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -53,6 +53,7 @@ QUERY_EXCEEDED_MEMORY_NO_DISK_USE_ERROR = 292 API_VERSION_ERROR = 322 API_STRICT_ERROR = 323 +CANNOT_CONVERT_INDEX_TO_UNIQUE_ERROR = 359 COLLECTION_UUID_MISMATCH_ERROR = 361 EXPRESSION_NOT_OBJECT_ERROR = 10065 BSON_OBJECT_TOO_LARGE_ERROR = 10334 diff --git a/documentdb_tests/framework/test_constants.py b/documentdb_tests/framework/test_constants.py index 4583984d7..7e772c09c 100644 --- a/documentdb_tests/framework/test_constants.py +++ b/documentdb_tests/framework/test_constants.py @@ -86,6 +86,7 @@ STRING_SIZE_LIMIT_BYTES = 16 * 1024 * 1024 REGEX_PATTERN_LIMIT_BYTES = 16 * 1024 CLUSTERED_RECORD_ID_LIMIT_BYTES = 8 * 1024 * 1024 +CAPPED_SIZE_LIMIT_BYTES = 2**50 # Int32 lists NUMERIC_INT32_NEGATIVE = [INT32_UNDERFLOW, INT32_MIN]