diff --git a/documentdb_tests/compatibility/tests/core/indexes/properties/unique/test_unique_bson_types.py b/documentdb_tests/compatibility/tests/core/indexes/properties/unique/test_unique_bson_types.py new file mode 100644 index 00000000..1b7f6552 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/indexes/properties/unique/test_unique_bson_types.py @@ -0,0 +1,85 @@ +"""Tests for unique index option BSON type validation. + +Verifies that createIndexes rejects invalid BSON types for the unique +option and accepts valid numeric/boolean types (truthy values are +treated as unique:true, falsy as unique:false). +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccessPartial +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import TYPE_MISMATCH_ERROR +from documentdb_tests.framework.executor import execute_command + +pytestmark = pytest.mark.index + +UNIQUE_BSON_PARAMS = [ + BsonTypeTestCase( + id="unique", + msg="unique should reject non-numeric, non-boolean types", + keyword="unique", + valid_types=[BsonType.DOUBLE, BsonType.INT, BsonType.LONG, BsonType.DECIMAL, BsonType.BOOL], + default_error_code=TYPE_MISMATCH_ERROR, + valid_inputs={ + BsonType.DOUBLE: 1.0, + BsonType.INT: 1, + BsonType.LONG: Int64(1), + BsonType.DECIMAL: Decimal128("1"), + BsonType.BOOL: True, + }, + ), +] + +REJECTION_CASES = generate_bson_rejection_test_cases(UNIQUE_BSON_PARAMS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", REJECTION_CASES) +def test_unique_bson_type_rejected(collection, bson_type, sample_value, spec): + """Test unique rejects invalid BSON types.""" + result = execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [ + { + "key": {"a": 1}, + "name": "idx_unique_bson", + "unique": sample_value, + } + ], + }, + ) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) + + +ACCEPTANCE_CASES = generate_bson_acceptance_test_cases(UNIQUE_BSON_PARAMS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ACCEPTANCE_CASES) +def test_unique_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test unique accepts valid numeric/boolean BSON types.""" + result = execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [ + { + "key": {"a": 1}, + "name": "idx_unique_bson", + "unique": sample_value, + } + ], + }, + ) + assertSuccessPartial( + result, + {"ok": 1.0}, + msg=f"unique should accept {bson_type.value}", + ) diff --git a/documentdb_tests/compatibility/tests/core/indexes/properties/unique/test_unique_constraint.py b/documentdb_tests/compatibility/tests/core/indexes/properties/unique/test_unique_constraint.py new file mode 100644 index 00000000..13db68ea --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/indexes/properties/unique/test_unique_constraint.py @@ -0,0 +1,227 @@ +"""Tests for unique index constraint enforcement — success cases. + +Validates that unique indexes correctly allow distinct values across +BSON types, compound indexes, multikey indexes, sparse and partial +indexes, nested field paths, case-sensitive collation defaults, and +constraint removal after index drop. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.indexes.commands.utils.index_test_case import ( + IndexTestCase, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import FLOAT_INFINITY, FLOAT_NEGATIVE_INFINITY + +pytestmark = pytest.mark.index + + +# `input` carries the doc inserted under test. `doc` holds any documents +# pre-inserted before the operation under test. + +CONSTRAINT_SUCCESS_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="int_vs_string", + indexes=({"key": {"v": 1}, "name": "idx_unique", "unique": True},), + doc=({"v": 1},), + input={"v": "1"}, + msg="Should treat int 1 and string '1' as distinct", + ), + IndexTestCase( + id="null_vs_bool_false", + indexes=({"key": {"v": 1}, "name": "idx_unique", "unique": True},), + doc=({"v": None},), + input={"v": False}, + msg="Should treat null and bool false as distinct", + ), + IndexTestCase( + id="int_zero_vs_bool_false", + indexes=({"key": {"v": 1}, "name": "idx_unique", "unique": True},), + doc=({"v": 0},), + input={"v": False}, + msg="Should treat int 0 and bool false as distinct", + ), + IndexTestCase( + id="int_one_vs_bool_true", + indexes=({"key": {"v": 1}, "name": "idx_unique", "unique": True},), + doc=({"v": 1},), + input={"v": True}, + msg="Should treat int 1 and bool true as distinct", + ), + IndexTestCase( + id="empty_string_vs_null", + indexes=({"key": {"v": 1}, "name": "idx_unique", "unique": True},), + doc=({"v": ""},), + input={"v": None}, + msg="Should treat empty string and null as distinct", + ), + IndexTestCase( + id="empty_array_vs_null", + indexes=({"key": {"v": 1}, "name": "idx_unique", "unique": True},), + doc=({"v": []},), + input={"v": None}, + msg="Should treat empty array and null as distinct", + ), + IndexTestCase( + id="empty_object_vs_null", + indexes=({"key": {"v": 1}, "name": "idx_unique", "unique": True},), + doc=({"v": {}},), + input={"v": None}, + msg="Should treat empty object and null as distinct", + ), + IndexTestCase( + id="null_first", + indexes=({"key": {"v": 1}, "name": "idx_unique", "unique": True},), + input={"v": None}, + msg="First document with null value should succeed", + ), + IndexTestCase( + id="missing_field_first", + indexes=({"key": {"v": 1}, "name": "idx_unique", "unique": True},), + input={"other": 1}, + msg="First document missing indexed field should succeed", + ), + IndexTestCase( + id="compound_different_combination", + indexes=({"key": {"a": 1, "b": 1}, "name": "idx_unique", "unique": True},), + doc=({"a": 1, "b": 1},), + input={"a": 1, "b": 2}, + msg="Compound unique should allow same value in one field if other differs", + ), + IndexTestCase( + id="compound_null_in_one_field_different_other", + indexes=({"key": {"a": 1, "b": 1}, "name": "idx_unique", "unique": True},), + doc=({"a": None, "b": 1},), + input={"a": None, "b": 2}, + msg="Compound unique should allow null in one field with different values in other", + ), + IndexTestCase( + id="multikey_same_doc_duplicate_elements", + indexes=({"key": {"v": 1}, "name": "idx_unique", "unique": True},), + input={"v": [1, 1, 2]}, + msg="Unique multikey should allow duplicate elements within same document", + ), + IndexTestCase( + id="infinity_and_neg_infinity_distinct", + indexes=({"key": {"v": 1}, "name": "idx_unique", "unique": True},), + doc=({"v": FLOAT_INFINITY},), + input={"v": FLOAT_NEGATIVE_INFINITY}, + msg="Should treat Infinity and -Infinity as distinct", + ), + IndexTestCase( + id="sparse_unique_multiple_missing_field", + indexes=({"key": {"v": 1}, "name": "idx_sparse_unique", "unique": True, "sparse": True},), + doc=({"other": 1},), + input={"other": 2}, + msg="Sparse unique should allow multiple documents missing the indexed field", + ), + IndexTestCase( + id="partial_unique_non_matching_allows_duplicates", + indexes=( + { + "key": {"v": 1}, + "name": "idx_partial_unique", + "unique": True, + "partialFilterExpression": {"status": "active"}, + }, + ), + doc=({"v": 1, "status": "inactive"},), + input={"v": 1, "status": "inactive"}, + msg="Partial unique should allow duplicates for non-matching documents", + ), + IndexTestCase( + id="partial_unique_one_matches_one_not", + indexes=( + { + "key": {"v": 1}, + "name": "idx_partial_unique", + "unique": True, + "partialFilterExpression": {"status": "active"}, + }, + ), + doc=({"v": 1, "status": "active"},), + input={"v": 1, "status": "inactive"}, + msg="Partial unique should allow same value when one doc matches filter and one doesn't", + ), + IndexTestCase( + id="partial_unique_exists_filter_missing_exempt", + indexes=( + { + "key": {"v": 1}, + "name": "idx_partial_unique", + "unique": True, + "partialFilterExpression": {"v": {"$exists": True}}, + }, + ), + doc=({"other": 1},), + input={"other": 2}, + msg="Partial unique with $exists filter should exempt documents missing the field", + ), + IndexTestCase( + id="partial_unique_gt_filter_below_threshold_exempt", + indexes=( + { + "key": {"v": 1}, + "name": "idx_partial_unique", + "unique": True, + "partialFilterExpression": {"v": {"$gt": 5}}, + }, + ), + doc=({"v": 3},), + input={"v": 3}, + msg="Partial unique with $gt filter should exempt values below threshold", + ), + IndexTestCase( + id="nested_different_values", + indexes=({"key": {"a.b": 1}, "name": "idx_nested_unique", "unique": True},), + doc=({"a": {"b": 1}},), + input={"a": {"b": 2}}, + msg="Unique on 'a.b' should allow different nested values", + ), + IndexTestCase( + id="no_collation_case_sensitive", + indexes=({"key": {"v": 1}, "name": "idx_unique", "unique": True},), + doc=({"v": "hello"},), + input={"v": "HELLO"}, + msg="Unique without collation should treat 'hello' and 'HELLO' as distinct", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(CONSTRAINT_SUCCESS_TESTS)) +def test_unique_constraint_success(collection, test): + """Test unique index allows distinct insert.""" + execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + if test.doc: + collection.insert_many(test.doc) + result = execute_command( + collection, + {"insert": collection.name, "documents": [test.input]}, + ) + assertSuccessPartial(result, {"ok": 1.0, "n": 1}, msg=test.msg) + + +def test_unique_drop_allows_duplicates(collection): + """Test dropping unique index removes constraint so duplicates can be inserted.""" + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"v": 1}, "name": "idx_unique", "unique": True}], + }, + ) + collection.insert_one({"v": 1}) + execute_command(collection, {"dropIndexes": collection.name, "index": "idx_unique"}) + result = execute_command( + collection, + {"insert": collection.name, "documents": [{"v": 1}]}, + ) + assertSuccessPartial( + result, {"ok": 1.0, "n": 1}, msg="Should allow duplicate after dropping unique index" + ) diff --git a/documentdb_tests/compatibility/tests/core/indexes/properties/unique/test_unique_create.py b/documentdb_tests/compatibility/tests/core/indexes/properties/unique/test_unique_create.py new file mode 100644 index 00000000..7b3d3d03 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/indexes/properties/unique/test_unique_create.py @@ -0,0 +1,70 @@ +"""Tests for unique index creation — success cases. + +Validates createIndex with unique option for direction variants, +implicit collection creation, coexistence with non-unique indexes +on the same key, and idempotent re-creation. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.indexes.commands.utils.index_test_case import ( + IndexTestCase, + index_created_response, +) +from documentdb_tests.framework.assertions import assertSuccessPartial +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.index + + +UNIQUE_CREATE_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="ascending", + indexes=({"key": {"a": 1}, "name": "idx_asc_unique", "unique": True},), + msg="Should create unique index on ascending field", + ), + IndexTestCase( + id="descending", + indexes=({"key": {"a": -1}, "name": "idx_desc_unique", "unique": True},), + msg="Should create unique index on descending field", + ), + IndexTestCase( + id="on_nonexistent_collection", + indexes=({"key": {"a": 1}, "name": "idx_unique", "unique": True},), + expected={"ok": 1.0, "createdCollectionAutomatically": True, "numIndexesAfter": 2}, + msg="Should implicitly create collection when it doesn't exist", + ), + IndexTestCase( + id="separate_from_basic", + indexes=({"key": {"a": 1}, "name": "idx_unique", "unique": True},), + setup_indexes=[{"key": {"a": 1}, "name": "idx_basic"}], + expected=index_created_response(num_indexes_before=2, num_indexes_after=3), + msg="Should create unique index alongside basic index on same key", + ), + IndexTestCase( + id="duplicate_identical_noop", + indexes=({"key": {"a": 1}, "name": "idx_unique", "unique": True},), + setup_indexes=[{"key": {"a": 1}, "name": "idx_unique", "unique": True}], + expected=index_created_response(num_indexes_before=2, num_indexes_after=2), + msg="Creating identical unique index should be a no-op", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(UNIQUE_CREATE_TESTS)) +def test_unique_create(collection, test): + """Test createIndex with unique option succeeds.""" + if test.doc: + collection.insert_many(list(test.doc)) + if test.setup_indexes: + execute_command( + collection, + {"createIndexes": collection.name, "indexes": test.setup_indexes}, + ) + result = execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + expected = test.expected if test.expected is not None else index_created_response() + assertSuccessPartial(result, expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/indexes/properties/unique/test_unique_errors.py b/documentdb_tests/compatibility/tests/core/indexes/properties/unique/test_unique_errors.py new file mode 100644 index 00000000..59fff067 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/indexes/properties/unique/test_unique_errors.py @@ -0,0 +1,532 @@ +"""Tests for unique index error cases. + +Validates DuplicateKey error responses when inserting documents that +violate unique index constraints across various BSON types, numeric +equivalences, null/missing fields, compound/multikey indexes, sparse +and partial indexes, nested paths, collation settings, batch insert +behavior, and index rebuild failures. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.indexes.commands.utils.index_test_case import ( + IndexTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccessPartial +from documentdb_tests.framework.error_codes import DUPLICATE_KEY_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_ZERO, + DOUBLE_NEGATIVE_ZERO, + FLOAT_NAN, +) + +pytestmark = pytest.mark.index + + +_BASIC_UNIQUE_INDEX = ({"key": {"v": 1}, "name": "idx_unique", "unique": True},) + + +CONSTRAINT_ERROR_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="dup_int", + indexes=_BASIC_UNIQUE_INDEX, + doc=({"v": 1},), + invalid_input={"v": 1}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should reject duplicate dup_int", + ), + IndexTestCase( + id="dup_long", + indexes=_BASIC_UNIQUE_INDEX, + doc=({"v": Int64(1)},), + invalid_input={"v": Int64(1)}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should reject duplicate dup_long", + ), + IndexTestCase( + id="dup_double", + indexes=_BASIC_UNIQUE_INDEX, + doc=({"v": 1.5},), + invalid_input={"v": 1.5}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should reject duplicate dup_double", + ), + IndexTestCase( + id="dup_decimal128", + indexes=_BASIC_UNIQUE_INDEX, + doc=({"v": Decimal128("2.5")},), + invalid_input={"v": Decimal128("2.5")}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should reject duplicate dup_decimal128", + ), + IndexTestCase( + id="dup_string", + indexes=_BASIC_UNIQUE_INDEX, + doc=({"v": "hello"},), + invalid_input={"v": "hello"}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should reject duplicate dup_string", + ), + IndexTestCase( + id="dup_bool_true", + indexes=_BASIC_UNIQUE_INDEX, + doc=({"v": True},), + invalid_input={"v": True}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should reject duplicate dup_bool_true", + ), + IndexTestCase( + id="dup_bool_false", + indexes=_BASIC_UNIQUE_INDEX, + doc=({"v": False},), + invalid_input={"v": False}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should reject duplicate dup_bool_false", + ), + IndexTestCase( + id="dup_date", + indexes=_BASIC_UNIQUE_INDEX, + doc=({"v": datetime(2024, 1, 1, tzinfo=timezone.utc)},), + invalid_input={"v": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should reject duplicate dup_date", + ), + IndexTestCase( + id="dup_objectid", + indexes=_BASIC_UNIQUE_INDEX, + doc=({"v": ObjectId("000000000000000000000001")},), + invalid_input={"v": ObjectId("000000000000000000000001")}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should reject duplicate dup_objectid", + ), + IndexTestCase( + id="dup_array", + indexes=_BASIC_UNIQUE_INDEX, + doc=({"v": [1, 2]},), + invalid_input={"v": [1, 2]}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should reject duplicate dup_array", + ), + IndexTestCase( + id="dup_object", + indexes=_BASIC_UNIQUE_INDEX, + doc=({"v": {"a": 1}},), + invalid_input={"v": {"a": 1}}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should reject duplicate dup_object", + ), + IndexTestCase( + id="dup_bindata", + indexes=_BASIC_UNIQUE_INDEX, + doc=({"v": Binary(b"\x01\x02")},), + invalid_input={"v": Binary(b"\x01\x02")}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should reject duplicate dup_bindata", + ), + IndexTestCase( + id="dup_regex", + indexes=_BASIC_UNIQUE_INDEX, + doc=({"v": Regex("^abc", "i")},), + invalid_input={"v": Regex("^abc", "i")}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should reject duplicate dup_regex", + ), + IndexTestCase( + id="dup_timestamp", + indexes=_BASIC_UNIQUE_INDEX, + doc=({"v": Timestamp(1, 1)},), + invalid_input={"v": Timestamp(1, 1)}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should reject duplicate dup_timestamp", + ), + IndexTestCase( + id="dup_minkey", + indexes=_BASIC_UNIQUE_INDEX, + doc=({"v": MinKey()},), + invalid_input={"v": MinKey()}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should reject duplicate dup_minkey", + ), + IndexTestCase( + id="dup_maxkey", + indexes=_BASIC_UNIQUE_INDEX, + doc=({"v": MaxKey()},), + invalid_input={"v": MaxKey()}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should reject duplicate dup_maxkey", + ), + IndexTestCase( + id="one_int_eq_long", + indexes=_BASIC_UNIQUE_INDEX, + doc=({"v": 1},), + invalid_input={"v": Int64(1)}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should treat Int64(1) as duplicate of 1", + ), + IndexTestCase( + id="one_int_eq_double", + indexes=_BASIC_UNIQUE_INDEX, + doc=({"v": 1},), + invalid_input={"v": 1.0}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should treat 1.0 as duplicate of 1", + ), + IndexTestCase( + id="one_int_eq_decimal128", + indexes=_BASIC_UNIQUE_INDEX, + doc=({"v": 1},), + invalid_input={"v": Decimal128("1")}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should treat Decimal128('1') as duplicate of 1", + ), + IndexTestCase( + id="zero_int_eq_long", + indexes=_BASIC_UNIQUE_INDEX, + doc=({"v": 0},), + invalid_input={"v": Int64(0)}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should treat Int64(0) as duplicate of 0", + ), + IndexTestCase( + id="zero_int_eq_double", + indexes=_BASIC_UNIQUE_INDEX, + doc=({"v": 0},), + invalid_input={"v": 0.0}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should treat 0.0 as duplicate of 0", + ), + IndexTestCase( + id="zero_int_eq_decimal128", + indexes=_BASIC_UNIQUE_INDEX, + doc=({"v": 0},), + invalid_input={"v": Decimal128("0")}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should treat Decimal128('0') as duplicate of 0", + ), + IndexTestCase( + id="negative_zero_vs_positive_zero", + indexes=({"key": {"v": 1}, "name": "idx_unique", "unique": True},), + doc=({"v": 0.0},), + invalid_input={"v": DOUBLE_NEGATIVE_ZERO}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should treat -0.0 as duplicate of 0.0", + ), + IndexTestCase( + id="decimal128_negative_zero_vs_zero", + indexes=({"key": {"v": 1}, "name": "idx_unique", "unique": True},), + doc=({"v": Decimal128("0")},), + invalid_input={"v": DECIMAL128_NEGATIVE_ZERO}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should treat Decimal128(-0) as duplicate of Decimal128(0)", + ), + IndexTestCase( + id="decimal128_trailing_zeros", + indexes=({"key": {"v": 1}, "name": "idx_unique", "unique": True},), + doc=({"v": Decimal128("1.0")},), + invalid_input={"v": Decimal128("1.00")}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should treat Decimal128 1.0 and 1.00 as same", + ), + IndexTestCase( + id="nan_duplicate", + indexes=({"key": {"v": 1}, "name": "idx_unique", "unique": True},), + doc=({"v": FLOAT_NAN},), + invalid_input={"v": FLOAT_NAN}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should treat NaN as duplicate of NaN", + ), + IndexTestCase( + id="decimal128_nan_duplicate", + indexes=({"key": {"v": 1}, "name": "idx_unique", "unique": True},), + doc=({"v": DECIMAL128_NAN},), + invalid_input={"v": DECIMAL128_NAN}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should treat Decimal128 NaN as duplicate", + ), + IndexTestCase( + id="null_and_missing_are_same", + indexes=({"key": {"v": 1}, "name": "idx_unique", "unique": True},), + doc=({"v": None},), + invalid_input={"other": 1}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should treat missing field as duplicate of null", + ), + IndexTestCase( + id="compound_same_combination", + indexes=({"key": {"a": 1, "b": 1}, "name": "idx_unique", "unique": True},), + doc=({"a": 1, "b": 1},), + invalid_input={"a": 1, "b": 1}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should reject same combination", + ), + IndexTestCase( + id="multikey_overlapping_arrays", + indexes=({"key": {"v": 1}, "name": "idx_unique", "unique": True},), + doc=({"v": [1, 2, 3]},), + invalid_input={"v": [3, 4, 5]}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should reject overlapping array elements", + ), + IndexTestCase( + id="multikey_array_vs_scalar", + indexes=({"key": {"v": 1}, "name": "idx_unique", "unique": True},), + doc=({"v": [1, 2, 3]},), + invalid_input={"v": 2}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should reject scalar matching array element", + ), + IndexTestCase( + id="empty_string_duplicate", + indexes=({"key": {"v": 1}, "name": "idx_unique", "unique": True},), + doc=({"v": ""},), + invalid_input={"v": ""}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should reject duplicate empty string", + ), + IndexTestCase( + id="empty_object_duplicate", + indexes=({"key": {"v": 1}, "name": "idx_unique", "unique": True},), + doc=({"v": {}},), + invalid_input={"v": {}}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should reject duplicate empty object", + ), + IndexTestCase( + id="dup_empty_array", + indexes=_BASIC_UNIQUE_INDEX, + doc=({"v": []},), + invalid_input={"v": []}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should reject duplicate empty array", + ), + IndexTestCase( + id="sparse_unique_rejects_duplicate", + indexes=({"key": {"v": 1}, "name": "idx_sparse_unique", "unique": True, "sparse": True},), + doc=({"v": 1},), + invalid_input={"v": 1}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should reject duplicate in sparse unique", + ), + IndexTestCase( + id="sparse_unique_null_only_one", + indexes=({"key": {"v": 1}, "name": "idx_sparse_unique", "unique": True, "sparse": True},), + doc=({"v": None},), + invalid_input={"v": None}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should reject second null in sparse unique", + ), + IndexTestCase( + id="partial_unique_matching_rejects", + indexes=( + { + "key": {"v": 1}, + "name": "idx_partial_unique", + "unique": True, + "partialFilterExpression": {"status": "active"}, + }, + ), + doc=({"v": 1, "status": "active"},), + invalid_input={"v": 1, "status": "active"}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should reject duplicate among matching docs", + ), + IndexTestCase( + id="nested_duplicate", + indexes=({"key": {"a.b": 1}, "name": "idx_nested_unique", "unique": True},), + doc=({"a": {"b": 1}},), + invalid_input={"a": {"b": 1}}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should reject duplicate nested value", + ), + IndexTestCase( + id="nested_multikey_array_of_objects", + indexes=({"key": {"a.b": 1}, "name": "idx_nested_unique", "unique": True},), + doc=({"a": [{"b": 1}, {"b": 2}]},), + invalid_input={"a": {"b": 1}}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should reject value matching multikey entry", + ), + IndexTestCase( + id="nested_missing_parent_null_vs_empty", + indexes=({"key": {"a.b": 1}, "name": "idx_nested_unique", "unique": True},), + doc=({"a": None},), + invalid_input={"a": {}}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should treat both as missing a.b (null)", + ), + IndexTestCase( + id="deep_nested_path", + indexes=({"key": {"a.b.c": 1}, "name": "idx_nested_unique", "unique": True},), + doc=({"a": {"b": {"c": 1}}},), + invalid_input={"a": {"b": {"c": 1}}}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should reject duplicate deep nested value", + ), + IndexTestCase( + id="collation_strength2_case_insensitive", + indexes=( + { + "key": {"v": 1}, + "name": "idx_coll", + "unique": True, + "collation": {"locale": "en", "strength": 2}, + }, + ), + doc=({"v": "hello"},), + invalid_input={"v": "HELLO"}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should treat case-different strings as duplicates with strength:2", + ), + IndexTestCase( + id="collation_strength1_accent_insensitive", + indexes=( + { + "key": {"v": 1}, + "name": "idx_coll", + "unique": True, + "collation": {"locale": "en", "strength": 1}, + }, + ), + doc=({"v": "cafe"},), + invalid_input={"v": "café"}, + error_code=DUPLICATE_KEY_ERROR, + msg="Should treat accent-different strings as duplicates with strength:1", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(CONSTRAINT_ERROR_TESTS)) +def test_unique_constraint_violation(collection, test): + """Test unique index rejects duplicate insert.""" + execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + collection.insert_many(test.doc) + result = execute_command( + collection, + {"insert": collection.name, "documents": [test.invalid_input]}, + ) + assertFailureCode(result, test.error_code, msg=test.msg) + + +def test_unique_ordered_batch_stops_at_first_duplicate(collection): + """Test ordered batch insert stops at first duplicate key violation.""" + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"v": 1}, "name": "idx_unique", "unique": True}], + }, + ) + collection.insert_one({"v": 1}) + result = execute_command( + collection, + {"insert": collection.name, "documents": [{"v": 2}, {"v": 1}, {"v": 3}], "ordered": True}, + ) + assertSuccessPartial( + result, + {"ok": 1.0, "n": 1}, + msg="Ordered batch should insert only first doc before duplicate", + ) + + +def test_unique_ordered_batch_single_write_error(collection): + """Test ordered batch returns one writeError at index of first duplicate.""" + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"v": 1}, "name": "idx_unique", "unique": True}], + }, + ) + collection.insert_one({"v": 1}) + result = execute_command( + collection, + {"insert": collection.name, "documents": [{"v": 2}, {"v": 1}, {"v": 3}], "ordered": True}, + ) + errors = [{"index": e["index"], "code": e["code"]} for e in result["writeErrors"]] + assertSuccessPartial( + {"ok": 1.0, "errors": errors}, + {"ok": 1.0, "errors": [{"index": 1, "code": DUPLICATE_KEY_ERROR}]}, + msg="Ordered batch should have one writeError at index 1 with DuplicateKey code", + ) + + +def test_unique_unordered_batch_continues_past_duplicates(collection): + """Test unordered batch insert continues past duplicate key violations.""" + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"v": 1}, "name": "idx_unique", "unique": True}], + }, + ) + collection.insert_one({"v": 1}) + result = execute_command( + collection, + {"insert": collection.name, "documents": [{"v": 2}, {"v": 1}, {"v": 1}], "ordered": False}, + ) + assertSuccessPartial( + result, {"ok": 1.0, "n": 1}, msg="Unordered batch should insert non-duplicate docs" + ) + + +def test_unique_unordered_batch_reports_all_duplicates(collection): + """Test unordered batch returns writeErrors for all duplicate violations.""" + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"v": 1}, "name": "idx_unique", "unique": True}], + }, + ) + collection.insert_one({"v": 1}) + result = execute_command( + collection, + {"insert": collection.name, "documents": [{"v": 2}, {"v": 1}, {"v": 1}], "ordered": False}, + ) + errors = [{"index": e["index"], "code": e["code"]} for e in result["writeErrors"]] + assertSuccessPartial( + {"ok": 1.0, "errors": errors}, + { + "ok": 1.0, + "errors": [ + {"index": 1, "code": DUPLICATE_KEY_ERROR}, + {"index": 2, "code": DUPLICATE_KEY_ERROR}, + ], + }, + msg="Unordered batch should report writeErrors at indices 1 and 2", + ) + + +def test_unique_recreate_with_existing_duplicates_fails(collection): + """Test recreating unique index fails when collection has duplicates.""" + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"v": 1}, "name": "idx_unique", "unique": True}], + }, + ) + collection.insert_one({"v": 1}) + execute_command(collection, {"dropIndexes": collection.name, "index": "idx_unique"}) + collection.insert_one({"v": 1}) + result = execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"v": 1}, "name": "idx_unique", "unique": True}], + }, + ) + assertFailureCode( + result, + DUPLICATE_KEY_ERROR, + msg="Should fail to create unique index with existing duplicates", + )