diff --git a/documentdb_tests/compatibility/tests/core/indexes/properties/sparse/test_sparse_bson_validation.py b/documentdb_tests/compatibility/tests/core/indexes/properties/sparse/test_sparse_bson_validation.py new file mode 100644 index 00000000..42cc858d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/indexes/properties/sparse/test_sparse_bson_validation.py @@ -0,0 +1,59 @@ +""" +Tests for sparse index option BSON type validation. + +Verifies that the sparse field in createIndexes rejects invalid BSON types +with expected error codes and accepts valid BSON types (bool and numerics). +""" + +import pytest + +from documentdb_tests.framework.assertions import assertFailureCode, assertNotError +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 + +SPARSE_OPTION_PARAMS = [ + BsonTypeTestCase( + id="sparse", + msg="sparse should reject non-bool/non-numeric types", + keyword="sparse", + valid_types=[BsonType.BOOL, BsonType.DOUBLE, BsonType.INT, BsonType.LONG, BsonType.DECIMAL], + default_error_code=TYPE_MISMATCH_ERROR, + ), +] + +REJECTION_CASES = generate_bson_rejection_test_cases(SPARSE_OPTION_PARAMS) +ACCEPTANCE_CASES = generate_bson_acceptance_test_cases(SPARSE_OPTION_PARAMS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", REJECTION_CASES) +def test_sparse_rejects_invalid_type(collection, bson_type, sample_value, spec): + """Test createIndexes rejects invalid BSON types for sparse option.""" + result = execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"a": 1}, "name": "idx_sparse", "sparse": sample_value}], + }, + ) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ACCEPTANCE_CASES) +def test_sparse_accepts_valid_type(collection, bson_type, sample_value, spec): + """Test createIndexes accepts valid BSON types for sparse option.""" + result = execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"a": 1}, "name": "idx_sparse", "sparse": sample_value}], + }, + ) + assertNotError(result, msg=f"sparse should accept {bson_type.value}") diff --git a/documentdb_tests/compatibility/tests/core/indexes/properties/sparse/test_sparse_create.py b/documentdb_tests/compatibility/tests/core/indexes/properties/sparse/test_sparse_create.py new file mode 100644 index 00000000..000522ac --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/indexes/properties/sparse/test_sparse_create.py @@ -0,0 +1,129 @@ +"""Tests for sparse index creation — key types, noop, signature behavior, and error cases.""" + +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 assertFailureCode, assertSuccessPartial +from documentdb_tests.framework.error_codes import ( + COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + INDEX_KEY_SPECS_CONFLICT_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.index + + +SPARSE_CREATE_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="single_field", + indexes=({"key": {"x": 1}, "name": "idx_single_sparse", "sparse": True},), + msg="Should create sparse index on single field", + ), + IndexTestCase( + id="compound", + indexes=({"key": {"a": 1, "b": -1}, "name": "idx_compound_sparse", "sparse": True},), + msg="Should create sparse index on compound fields", + ), + IndexTestCase( + id="descending", + indexes=({"key": {"a": -1}, "name": "idx_desc_sparse", "sparse": True},), + msg="Should create sparse index on descending field", + ), + IndexTestCase( + id="hashed", + indexes=({"key": {"a": "hashed"}, "name": "idx_hashed_sparse", "sparse": True},), + msg="Should create sparse index on hashed field", + ), + IndexTestCase( + id="on_nonexistent_collection", + indexes=({"key": {"a": 1}, "name": "idx_sparse", "sparse": True},), + expected={"ok": 1.0, "createdCollectionAutomatically": True, "numIndexesAfter": 2}, + msg="Should implicitly create collection", + ), + IndexTestCase( + id="duplicate_identical_noop", + indexes=({"key": {"a": 1}, "name": "idx_sparse", "sparse": True},), + setup_indexes=[{"key": {"a": 1}, "name": "idx_sparse", "sparse": True}], + expected=index_created_response(num_indexes_before=2, num_indexes_after=2), + msg="Creating identical sparse index should be a no-op", + ), + IndexTestCase( + id="sparse_separate_from_basic", + indexes=({"key": {"a": 1}, "name": "idx_sparse", "sparse": True},), + setup_indexes=[{"key": {"a": 1}, "name": "idx_basic"}], + expected=index_created_response(num_indexes_before=2, num_indexes_after=3), + msg="Sparse index on same key should be a separate index", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SPARSE_CREATE_TESTS)) +def test_sparse_create(collection, test): + """Test createIndex with valid sparse option values 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) + + +def test_sparse_create_on_capped_collection(database_client): + """Test createIndex with sparse on capped collection succeeds.""" + db = database_client + db.create_collection("test_capped", capped=True, size=4096) + coll = db["test_capped"] + result = execute_command( + coll, + { + "createIndexes": coll.name, + "indexes": [{"key": {"a": 1}, "name": "idx_sparse", "sparse": True}], + }, + ) + assertSuccessPartial(result, {"ok": 1.0, "numIndexesAfter": 2}) + + +def test_sparse_create_on_view_error(database_client): + """Test createIndex with sparse on view returns error.""" + db = database_client + db.create_collection("base_coll") + db.command({"create": "test_view", "viewOn": "base_coll", "pipeline": []}) + coll = db["test_view"] + result = execute_command( + coll, + { + "createIndexes": coll.name, + "indexes": [{"key": {"a": 1}, "name": "idx_sparse", "sparse": True}], + }, + ) + assertFailureCode(result, COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR) + + +def test_sparse_create_name_conflict_error(collection): + """Test createIndex with same name but different sparse value returns error.""" + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"a": 1}, "name": "idx_a", "sparse": False}], + }, + ) + result = execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"a": 1}, "name": "idx_a", "sparse": True}], + }, + ) + assertFailureCode(result, INDEX_KEY_SPECS_CONFLICT_ERROR) diff --git a/documentdb_tests/compatibility/tests/core/indexes/properties/sparse/test_sparse_index_coverage.py b/documentdb_tests/compatibility/tests/core/indexes/properties/sparse/test_sparse_index_coverage.py new file mode 100644 index 00000000..c38e2308 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/indexes/properties/sparse/test_sparse_index_coverage.py @@ -0,0 +1,189 @@ +"""Tests for sparse index coverage — document inclusion/exclusion.""" + +import pytest +from bson import Binary + +from documentdb_tests.compatibility.tests.core.indexes.commands.utils.index_test_case import ( + IndexTestCase, +) +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.index + +SPARSE_COVERAGE_COUNT_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="excludes_missing_field", + indexes=({"key": {"a": 1}, "name": "idx_sparse", "sparse": True},), + doc=({"_id": 1, "a": 1}, {"_id": 2, "a": None}, {"_id": 3}), + expected={"n": 2, "ok": 1.0}, + command_options={"hint": {"a": 1}}, + msg="Sparse index excludes documents where indexed field is missing", + ), + IndexTestCase( + id="null_is_indexed", + indexes=({"key": {"a": 1}, "name": "idx_sparse", "sparse": True},), + doc=({"_id": 1, "a": None}, {"_id": 2}), + expected={"n": 1, "ok": 1.0}, + command_options={"hint": {"a": 1}}, + msg="Document with field set to null IS indexed (field exists)", + ), + IndexTestCase( + id="nonsparse_includes_all", + indexes=({"key": {"a": 1}, "name": "idx_nonsparse"},), + doc=({"_id": 1, "a": 1}, {"_id": 2}, {"_id": 3, "a": None}), + expected={"n": 3, "ok": 1.0}, + command_options={"hint": {"a": 1}}, + msg="Non-sparse index includes ALL documents including missing field", + ), + IndexTestCase( + id="nested_empty_parent_excluded", + indexes=({"key": {"a.b": 1}, "name": "idx_nested_sparse", "sparse": True},), + doc=({"_id": 1, "a": {}}, {"_id": 2, "a": {"b": 1}}), + expected={"n": 1, "ok": 1.0}, + command_options={"hint": {"a.b": 1}}, + msg="Sparse index on 'a.b' — document {a: {}} is missing 'a.b' → excluded", + ), + IndexTestCase( + id="nested_null_value_included", + indexes=({"key": {"a.b": 1}, "name": "idx_nested_sparse", "sparse": True},), + doc=({"_id": 1, "a": {"b": None}}, {"_id": 2, "a": {}}), + expected={"n": 1, "ok": 1.0}, + command_options={"hint": {"a.b": 1}}, + msg="Sparse index on 'a.b' — document {a: {b: null}} → included", + ), + IndexTestCase( + id="nested_parent_null_excluded", + indexes=({"key": {"a.b": 1}, "name": "idx_nested_sparse", "sparse": True},), + doc=({"_id": 1, "a": None}, {"_id": 2, "a": {"b": 1}}), + expected={"n": 1, "ok": 1.0}, + command_options={"hint": {"a.b": 1}}, + msg="Sparse index on 'a.b' — document {a: null} → excluded (can't traverse)", + ), + IndexTestCase( + id="nested_array_multikey", + indexes=({"key": {"a.b": 1}, "name": "idx_nested_sparse", "sparse": True},), + doc=({"_id": 1, "a": [{"b": 1}, {"b": 2}]}, {"_id": 2}), + expected={"n": 1, "ok": 1.0}, + command_options={"hint": {"a.b": 1}}, + msg="Sparse index on 'a.b' — document {a: [{b: 1}, {b: 2}]} → multikey behavior", + ), + IndexTestCase( + id="array_empty_indexed", + indexes=({"key": {"a": 1}, "name": "idx_arr_sparse", "sparse": True},), + doc=({"_id": 1, "a": []}, {"_id": 2}), + expected={"n": 1, "ok": 1.0}, + command_options={"hint": {"a": 1}}, + msg="Sparse index on array field — document with empty array [] — field exists, so indexed", + ), + IndexTestCase( + id="compound_missing_all_fields_excluded", + indexes=({"key": {"a": 1, "b": -1}, "name": "idx_compound_sparse", "sparse": True},), + doc=({"_id": 1, "a": 1, "b": 2}, {"_id": 2, "c": 3}), + expected={"n": 1, "ok": 1.0}, + command_options={"hint": {"a": 1, "b": -1}}, + msg="Sparse compound index — document missing all indexed fields is NOT indexed", + ), + IndexTestCase( + id="compound_one_field_present_included", + indexes=({"key": {"a": 1, "b": -1}, "name": "idx_compound_sparse", "sparse": True},), + doc=({"_id": 1, "a": 1}, {"_id": 2, "b": 2}, {"_id": 3}), + expected={"n": 2, "ok": 1.0}, + command_options={"hint": {"a": 1, "b": -1}}, + msg="Sparse compound index — document with one field present IS indexed", + ), + IndexTestCase( + id="compound_geospatial", + indexes=( + { + "key": {"loc": "2dsphere", "name": 1}, + "name": "idx_geo_compound_sparse", + "sparse": True, + }, + ), + doc=( + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}, "name": "A"}, + {"_id": 2, "name": "B"}, + ), + expected={"n": 1, "ok": 1.0}, + command_options={"hint": "idx_geo_compound_sparse"}, + msg="Sparse compound index with geospatial field — only indexes docs with geo field", + ), + IndexTestCase( + id="2dsphere_skips_missing_geo", + indexes=({"key": {"loc": "2dsphere"}, "name": "idx_2dsphere"},), + doc=({"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, {"_id": 2}), + expected={"n": 1, "ok": 1.0}, + command_options={"hint": {"loc": "2dsphere"}}, + msg="2dsphere index is always sparse — skips docs without geo field", + ), + IndexTestCase( + id="2d_skips_missing_geo", + indexes=({"key": {"loc": "2d"}, "name": "idx_2d"},), + doc=({"_id": 1, "loc": [1, 2]}, {"_id": 2}), + expected={"n": 1, "ok": 1.0}, + command_options={"hint": {"loc": "2d"}}, + msg="2d index is always sparse — skips docs without geo field", + ), + IndexTestCase( + id="bson_object_indexed", + indexes=({"key": {"a": 1}, "name": "idx_sparse", "sparse": True},), + doc=({"_id": 1, "a": {"x": 1}}, {"_id": 2}), + expected={"n": 1, "ok": 1.0}, + command_options={"hint": {"a": 1}}, + msg="Sparse index includes document with embedded object value", + ), + IndexTestCase( + id="bson_array_indexed", + indexes=({"key": {"a": 1}, "name": "idx_sparse", "sparse": True},), + doc=({"_id": 1, "a": [1, 2, 3]}, {"_id": 2}), + expected={"n": 1, "ok": 1.0}, + command_options={"hint": {"a": 1}}, + msg="Sparse index includes document with array value", + ), + IndexTestCase( + id="bson_bindata_indexed", + indexes=({"key": {"a": 1}, "name": "idx_sparse", "sparse": True},), + doc=({"_id": 1, "a": Binary(b"\x01\x02\x03")}, {"_id": 2}), + expected={"n": 1, "ok": 1.0}, + command_options={"hint": {"a": 1}}, + msg="Sparse index includes document with binary data value", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SPARSE_COVERAGE_COUNT_TESTS)) +def test_sparse_coverage_count(collection, test): + """Test sparse index document inclusion/exclusion via count.""" + execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + collection.insert_many(list(test.doc)) + result = execute_command( + collection, + {"count": collection.name, "query": {}, "hint": test.command_options["hint"]}, + ) + assertSuccess(result, test.expected, raw_res=True) + + +def test_sparse_agg_sort_returns_all_docs(collection): + """Test $sort in aggregation returns all documents regardless of sparse index.""" + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"a": 1}, "name": "idx_sparse", "sparse": True}], + }, + ) + collection.insert_many([{"_id": 1, "a": 1}, {"_id": 2, "a": 2}, {"_id": 3}, {"_id": 4, "a": 3}]) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [{"$sort": {"a": 1}}, {"$project": {"_id": 1}}], + "cursor": {}, + }, + ) + assertSuccess(result, [{"_id": 3}, {"_id": 1}, {"_id": 2}, {"_id": 4}]) diff --git a/documentdb_tests/compatibility/tests/core/indexes/properties/sparse/test_sparse_other_commands.py b/documentdb_tests/compatibility/tests/core/indexes/properties/sparse/test_sparse_other_commands.py new file mode 100644 index 00000000..cf02130a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/indexes/properties/sparse/test_sparse_other_commands.py @@ -0,0 +1,84 @@ +"""Tests for sparse index behavior with other commands — count hint and edge cases.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.indexes.commands.utils.index_test_case import ( + IndexTestCase, +) +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.index + +QUERY_DOCS = ( + {"_id": 1, "a": 1}, + {"_id": 2, "a": 2}, + {"_id": 3}, + {"_id": 4, "a": None}, +) + +SPARSE_COUNT_HINT_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="hint_returns_fewer_results", + indexes=({"key": {"a": 1}, "name": "idx_sparse", "sparse": True},), + doc=QUERY_DOCS, + expected={"n": 3, "ok": 1.0}, + command_options={"query": {}, "hint": {"a": 1}}, + msg="Hint with sparse index on empty query returns fewer results than total", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SPARSE_COUNT_HINT_TESTS)) +def test_sparse_count_hint(collection, test): + """Test sparse index count behavior when hint forces sparse index usage.""" + execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + if test.doc: + collection.insert_many(list(test.doc)) + cmd = {"count": collection.name} + if "query" in test.command_options: + cmd["query"] = test.command_options["query"] + if "hint" in test.command_options: + cmd["hint"] = test.command_options["hint"] + result = execute_command(collection, cmd) + assertSuccess(result, test.expected, raw_res=True) + + +SPARSE_EDGE_COUNT_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="on_id_field_has_no_effect", + indexes=({"key": {"_id": 1}, "name": "idx_id_sparse", "sparse": True},), + doc=({"_id": 1}, {"_id": 2}, {"_id": 3}), + expected={"n": 3, "ok": 1.0}, + command_options={"query": {}, "hint": {"_id": 1}}, + msg="Sparse index on _id field — _id always exists, so sparse has no effect", + ), + IndexTestCase( + id="mixed_type_array", + indexes=({"key": {"a": 1}, "name": "idx_sparse", "sparse": True},), + doc=({"_id": 1, "a": [1, "two", None, True]}, {"_id": 2}), + expected={"n": 1, "ok": 1.0}, + command_options={"query": {}, "hint": {"a": 1}}, + msg="Sparse index on field that contains arrays of mixed types", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SPARSE_EDGE_COUNT_TESTS)) +def test_sparse_edge_case(collection, test): + """Test sparse index edge cases.""" + execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + if test.doc: + collection.insert_many(list(test.doc)) + result = execute_command( + collection, + {"count": collection.name, **test.command_options}, + ) + assertSuccess(result, test.expected, raw_res=True) diff --git a/documentdb_tests/compatibility/tests/core/indexes/properties/test_properties_combinations.py b/documentdb_tests/compatibility/tests/core/indexes/properties/test_properties_combinations.py index 422c6c43..db07b353 100644 --- a/documentdb_tests/compatibility/tests/core/indexes/properties/test_properties_combinations.py +++ b/documentdb_tests/compatibility/tests/core/indexes/properties/test_properties_combinations.py @@ -1,23 +1,30 @@ """Tests for index property combinations. Validates that indexes work correctly with combined properties: -TTL with sparse/partial/unique/collation, and collation with -sparse/background options. +TTL with sparse/partial/unique/collation, sparse with unique/collation, +and collation with background options. """ +from datetime import datetime, timezone + 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.assertions import ( + assertFailureCode, + assertSuccess, + 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 pytestmark = pytest.mark.index -PROPERTY_COMBINATION_TESTS: list[IndexTestCase] = [ +PROPERTY_COMBINATION_CREATE_TESTS: list[IndexTestCase] = [ IndexTestCase( id="ttl_with_sparse", indexes=( @@ -103,14 +110,136 @@ ), msg="Should create index with background option and collation", ), + IndexTestCase( + id="sparse_separate_from_unique", + indexes=({"key": {"a": 1}, "name": "idx_sparse", "sparse": True},), + setup_indexes=[{"key": {"a": 1}, "name": "idx_unique", "unique": True}], + expected=index_created_response(num_indexes_before=2, num_indexes_after=3), + msg="Sparse index should be separate from unique index on same key", + ), + IndexTestCase( + id="unique_sparse_separate_from_sparse", + indexes=({"key": {"a": 1}, "name": "idx_unique_sparse", "sparse": True, "unique": True},), + setup_indexes=[{"key": {"a": 1}, "name": "idx_sparse", "sparse": True}], + expected=index_created_response(num_indexes_before=2, num_indexes_after=3), + msg="Unique+sparse should be separate from sparse-only on same key", + ), ] -@pytest.mark.parametrize("test", pytest_params(PROPERTY_COMBINATION_TESTS)) -def test_property_combination(collection, test): +@pytest.mark.parametrize("test", pytest_params(PROPERTY_COMBINATION_CREATE_TESTS)) +def test_property_combination_create(collection, test): """Test that indexes can be created with combined properties.""" + if hasattr(test, "setup_indexes") and 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) + + +SPARSE_TTL_COUNT_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="ttl_combination", + indexes=( + { + "key": {"expires": 1}, + "name": "idx_sparse_ttl", + "sparse": True, + "expireAfterSeconds": 3600, + }, + ), + doc=( + {"_id": 1, "expires": datetime(2099, 1, 1, tzinfo=timezone.utc)}, + {"_id": 2}, + ), + expected={"n": 1, "ok": 1.0}, + command_options={"query": {}, "hint": {"expires": 1}}, + msg="Sparse + TTL index — documents without TTL field not indexed", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SPARSE_TTL_COUNT_TESTS)) +def test_sparse_ttl_count(collection, test): + """Test sparse + TTL index behavior via count with hint.""" + execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + if test.doc: + collection.insert_many(list(test.doc)) result = execute_command( + collection, + {"count": collection.name, **test.command_options}, + ) + assertSuccess(result, test.expected, raw_res=True) + + +SPARSE_UNIQUE_SUCCESS_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="unique_allows_multiple_missing", + indexes=({"key": {"a": 1}, "name": "idx_sparse_unique", "sparse": True, "unique": True},), + doc=({"_id": 1},), + expected={"n": 1, "ok": 1.0}, + command_options={"documents": [{"_id": 2}]}, + msg="Sparse + unique allows multiple documents missing the indexed field", + ), + IndexTestCase( + id="unique_allows_one_null", + indexes=({"key": {"a": 1}, "name": "idx_sparse_unique", "sparse": True, "unique": True},), + doc=({"_id": 1, "a": 5},), + expected={"n": 1, "ok": 1.0}, + command_options={"documents": [{"_id": 2, "a": None}]}, + msg="Sparse + unique allows first document with null value", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SPARSE_UNIQUE_SUCCESS_TESTS)) +def test_sparse_unique_success(collection, test): + """Test sparse + unique allows valid inserts.""" + execute_command( collection, {"createIndexes": collection.name, "indexes": list(test.indexes)}, ) - assertSuccessPartial(result, index_created_response(), msg=test.msg) + if test.doc: + collection.insert_many(list(test.doc)) + result = execute_command( + collection, + {"insert": collection.name, **test.command_options}, + ) + assertSuccess(result, test.expected, raw_res=True) + + +SPARSE_UNIQUE_FAILURE_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="unique_rejects_second_null", + indexes=({"key": {"a": 1}, "name": "idx_sparse_unique", "sparse": True, "unique": True},), + doc=({"_id": 1, "a": None},), + error_code=DUPLICATE_KEY_ERROR, + command_options={"documents": [{"_id": 2, "a": None}]}, + msg="Sparse + unique rejects second document with null value", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SPARSE_UNIQUE_FAILURE_TESTS)) +def test_sparse_unique_failure(collection, test): + """Test sparse + unique rejects invalid inserts.""" + execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + if test.doc: + collection.insert_many(list(test.doc)) + result = execute_command( + collection, + {"insert": collection.name, **test.command_options}, + ) + assertFailureCode(result, test.error_code, test.msg)