|
| 1 | +# Copyright 2023-present MongoDB, Inc. |
| 2 | +# |
| 3 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +# you may not use this file except in compliance with the License. |
| 5 | +# You may obtain a copy of the License at |
| 6 | +# |
| 7 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +# |
| 9 | +# Unless required by applicable law or agreed to in writing, software |
| 10 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +# See the License for the specific language governing permissions and |
| 13 | +# limitations under the License. |
| 14 | + |
| 15 | +"""Run the auth spec tests.""" |
| 16 | +from __future__ import annotations |
| 17 | + |
| 18 | +import asyncio |
| 19 | +import os |
| 20 | +import pathlib |
| 21 | +import sys |
| 22 | +import time |
| 23 | +import uuid |
| 24 | +from typing import Any, Mapping |
| 25 | + |
| 26 | +import pytest |
| 27 | + |
| 28 | +sys.path[0:0] = [""] |
| 29 | + |
| 30 | +from test.asynchronous import AsyncIntegrationTest, AsyncPyMongoTestCase, unittest |
| 31 | +from test.asynchronous.unified_format import generate_test_classes |
| 32 | +from test.utils import AllowListEventListener, OvertCommandListener |
| 33 | + |
| 34 | +from pymongo.errors import OperationFailure |
| 35 | +from pymongo.operations import SearchIndexModel |
| 36 | +from pymongo.read_concern import ReadConcern |
| 37 | +from pymongo.write_concern import WriteConcern |
| 38 | + |
| 39 | +_IS_SYNC = False |
| 40 | + |
| 41 | +pytestmark = pytest.mark.index_management |
| 42 | + |
| 43 | +# Location of JSON test specifications. |
| 44 | +if _IS_SYNC: |
| 45 | + _TEST_PATH = os.path.join(pathlib.Path(__file__).resolve().parent, "index_management") |
| 46 | +else: |
| 47 | + _TEST_PATH = os.path.join(pathlib.Path(__file__).resolve().parent.parent, "index_management") |
| 48 | + |
| 49 | +_NAME = "test-search-index" |
| 50 | + |
| 51 | + |
| 52 | +class TestCreateSearchIndex(AsyncIntegrationTest): |
| 53 | + async def test_inputs(self): |
| 54 | + if not os.environ.get("TEST_INDEX_MANAGEMENT"): |
| 55 | + raise unittest.SkipTest("Skipping index management tests") |
| 56 | + listener = AllowListEventListener("createSearchIndexes") |
| 57 | + client = self.simple_client(event_listeners=[listener]) |
| 58 | + coll = client.test.test |
| 59 | + await coll.drop() |
| 60 | + definition = dict(mappings=dict(dynamic=True)) |
| 61 | + model_kwarg_list: list[Mapping[str, Any]] = [ |
| 62 | + dict(definition=definition, name=None), |
| 63 | + dict(definition=definition, name="test"), |
| 64 | + ] |
| 65 | + for model_kwargs in model_kwarg_list: |
| 66 | + model = SearchIndexModel(**model_kwargs) |
| 67 | + with self.assertRaises(OperationFailure): |
| 68 | + await coll.create_search_index(model) |
| 69 | + with self.assertRaises(OperationFailure): |
| 70 | + await coll.create_search_index(model_kwargs) |
| 71 | + |
| 72 | + listener.reset() |
| 73 | + with self.assertRaises(OperationFailure): |
| 74 | + await coll.create_search_index({"definition": definition, "arbitraryOption": 1}) |
| 75 | + self.assertEqual( |
| 76 | + {"definition": definition, "arbitraryOption": 1}, |
| 77 | + listener.events[0].command["indexes"][0], |
| 78 | + ) |
| 79 | + |
| 80 | + listener.reset() |
| 81 | + with self.assertRaises(OperationFailure): |
| 82 | + await coll.create_search_index({"definition": definition, "type": "search"}) |
| 83 | + self.assertEqual( |
| 84 | + {"definition": definition, "type": "search"}, listener.events[0].command["indexes"][0] |
| 85 | + ) |
| 86 | + |
| 87 | + |
| 88 | +class SearchIndexIntegrationBase(AsyncPyMongoTestCase): |
| 89 | + db_name = "test_search_index_base" |
| 90 | + |
| 91 | + @classmethod |
| 92 | + def setUpClass(cls) -> None: |
| 93 | + if not os.environ.get("TEST_INDEX_MANAGEMENT"): |
| 94 | + raise unittest.SkipTest("Skipping index management tests") |
| 95 | + cls.url = os.environ.get("MONGODB_URI") |
| 96 | + cls.username = os.environ["DB_USER"] |
| 97 | + cls.password = os.environ["DB_PASSWORD"] |
| 98 | + cls.listener = OvertCommandListener() |
| 99 | + |
| 100 | + async def asyncSetUp(self) -> None: |
| 101 | + self.client = self.simple_client( |
| 102 | + self.url, |
| 103 | + username=self.username, |
| 104 | + password=self.password, |
| 105 | + event_listeners=[self.listener], |
| 106 | + ) |
| 107 | + await self.client.drop_database(_NAME) |
| 108 | + self.db = self.client[self.db_name] |
| 109 | + |
| 110 | + async def asyncTearDown(self): |
| 111 | + await self.client.drop_database(_NAME) |
| 112 | + |
| 113 | + async def wait_for_ready(self, coll, name=_NAME, predicate=None): |
| 114 | + """Wait for a search index to be ready.""" |
| 115 | + indices: list[Mapping[str, Any]] = [] |
| 116 | + if predicate is None: |
| 117 | + predicate = lambda index: index.get("queryable") is True |
| 118 | + |
| 119 | + while True: |
| 120 | + indices = await (await coll.list_search_indexes(name)).to_list() |
| 121 | + if len(indices) and predicate(indices[0]): |
| 122 | + return indices[0] |
| 123 | + await asyncio.sleep(5) |
| 124 | + |
| 125 | + |
| 126 | +class TestSearchIndexIntegration(SearchIndexIntegrationBase): |
| 127 | + db_name = "test_search_index" |
| 128 | + |
| 129 | + async def test_comment_field(self): |
| 130 | + # Create a collection with the "create" command using a randomly generated name (referred to as ``coll0``). |
| 131 | + coll0 = self.db[f"col{uuid.uuid4()}"] |
| 132 | + await coll0.insert_one({}) |
| 133 | + |
| 134 | + # Create a new search index on ``coll0`` that implicitly passes its type. |
| 135 | + search_definition = {"mappings": {"dynamic": False}} |
| 136 | + self.listener.reset() |
| 137 | + implicit_search_resp = await coll0.create_search_index( |
| 138 | + model={"name": _NAME + "-implicit", "definition": search_definition}, comment="foo" |
| 139 | + ) |
| 140 | + event = self.listener.events[0] |
| 141 | + self.assertEqual(event.command["comment"], "foo") |
| 142 | + |
| 143 | + # Get the index definition. |
| 144 | + self.listener.reset() |
| 145 | + await (await coll0.list_search_indexes(name=implicit_search_resp, comment="foo")).next() |
| 146 | + event = self.listener.events[0] |
| 147 | + self.assertEqual(event.command["comment"], "foo") |
| 148 | + |
| 149 | + |
| 150 | +class TestSearchIndexProse(SearchIndexIntegrationBase): |
| 151 | + db_name = "test_search_index_prose" |
| 152 | + |
| 153 | + async def test_case_1(self): |
| 154 | + """Driver can successfully create and list search indexes.""" |
| 155 | + |
| 156 | + # Create a collection with the "create" command using a randomly generated name (referred to as ``coll0``). |
| 157 | + coll0 = self.db[f"col{uuid.uuid4()}"] |
| 158 | + |
| 159 | + # Create a new search index on ``coll0`` with the ``createSearchIndex`` helper. Use the following definition: |
| 160 | + model = {"name": _NAME, "definition": {"mappings": {"dynamic": False}}} |
| 161 | + await coll0.insert_one({}) |
| 162 | + resp = await coll0.create_search_index(model) |
| 163 | + |
| 164 | + # Assert that the command returns the name of the index: ``"test-search-index"``. |
| 165 | + self.assertEqual(resp, _NAME) |
| 166 | + |
| 167 | + # Run ``coll0.listSearchIndexes()`` repeatedly every 5 seconds until the following condition is satisfied and store the value in a variable ``index``: |
| 168 | + # An index with the ``name`` of ``test-search-index`` is present and the index has a field ``queryable`` with a value of ``true``. |
| 169 | + index = await self.wait_for_ready(coll0) |
| 170 | + |
| 171 | + # . Assert that ``index`` has a property ``latestDefinition`` whose value is ``{ 'mappings': { 'dynamic': false } }`` |
| 172 | + self.assertIn("latestDefinition", index) |
| 173 | + self.assertEqual(index["latestDefinition"], model["definition"]) |
| 174 | + |
| 175 | + async def test_case_2(self): |
| 176 | + """Driver can successfully create multiple indexes in batch.""" |
| 177 | + |
| 178 | + # Create a collection with the "create" command using a randomly generated name (referred to as ``coll0``). |
| 179 | + coll0 = self.db[f"col{uuid.uuid4()}"] |
| 180 | + await coll0.insert_one({}) |
| 181 | + |
| 182 | + # Create two new search indexes on ``coll0`` with the ``createSearchIndexes`` helper. |
| 183 | + name1 = "test-search-index-1" |
| 184 | + name2 = "test-search-index-2" |
| 185 | + definition = {"mappings": {"dynamic": False}} |
| 186 | + index_definitions: list[dict[str, Any]] = [ |
| 187 | + {"name": name1, "definition": definition}, |
| 188 | + {"name": name2, "definition": definition}, |
| 189 | + ] |
| 190 | + await coll0.create_search_indexes( |
| 191 | + [SearchIndexModel(i["definition"], i["name"]) for i in index_definitions] |
| 192 | + ) |
| 193 | + |
| 194 | + # .Assert that the command returns an array containing the new indexes' names: ``["test-search-index-1", "test-search-index-2"]``. |
| 195 | + indices = await (await coll0.list_search_indexes()).to_list() |
| 196 | + names = [i["name"] for i in indices] |
| 197 | + self.assertIn(name1, names) |
| 198 | + self.assertIn(name2, names) |
| 199 | + |
| 200 | + # Run ``coll0.listSearchIndexes()`` repeatedly every 5 seconds until the following condition is satisfied. |
| 201 | + # An index with the ``name`` of ``test-search-index-1`` is present and index has a field ``queryable`` with the value of ``true``. Store result in ``index1``. |
| 202 | + # An index with the ``name`` of ``test-search-index-2`` is present and index has a field ``queryable`` with the value of ``true``. Store result in ``index2``. |
| 203 | + index1 = await self.wait_for_ready(coll0, name1) |
| 204 | + index2 = await self.wait_for_ready(coll0, name2) |
| 205 | + |
| 206 | + # Assert that ``index1`` and ``index2`` have the property ``latestDefinition`` whose value is ``{ "mappings" : { "dynamic" : false } }`` |
| 207 | + for index in [index1, index2]: |
| 208 | + self.assertIn("latestDefinition", index) |
| 209 | + self.assertEqual(index["latestDefinition"], definition) |
| 210 | + |
| 211 | + async def test_case_3(self): |
| 212 | + """Driver can successfully drop search indexes.""" |
| 213 | + |
| 214 | + # Create a collection with the "create" command using a randomly generated name (referred to as ``coll0``). |
| 215 | + coll0 = self.db[f"col{uuid.uuid4()}"] |
| 216 | + await coll0.insert_one({}) |
| 217 | + |
| 218 | + # Create a new search index on ``coll0``. |
| 219 | + model = {"name": _NAME, "definition": {"mappings": {"dynamic": False}}} |
| 220 | + resp = await coll0.create_search_index(model) |
| 221 | + |
| 222 | + # Assert that the command returns the name of the index: ``"test-search-index"``. |
| 223 | + self.assertEqual(resp, "test-search-index") |
| 224 | + |
| 225 | + # Run ``coll0.listSearchIndexes()`` repeatedly every 5 seconds until the following condition is satisfied: |
| 226 | + # An index with the ``name`` of ``test-search-index`` is present and index has a field ``queryable`` with the value of ``true``. |
| 227 | + await self.wait_for_ready(coll0) |
| 228 | + |
| 229 | + # Run a ``dropSearchIndex`` on ``coll0``, using ``test-search-index`` for the name. |
| 230 | + await coll0.drop_search_index(_NAME) |
| 231 | + |
| 232 | + # Run ``coll0.listSearchIndexes()`` repeatedly every 5 seconds until ``listSearchIndexes`` returns an empty array. |
| 233 | + t0 = time.time() |
| 234 | + while True: |
| 235 | + indices = await (await coll0.list_search_indexes()).to_list() |
| 236 | + if indices: |
| 237 | + break |
| 238 | + if (time.time() - t0) / 60 > 5: |
| 239 | + raise TimeoutError("Timed out waiting for index deletion") |
| 240 | + await asyncio.sleep(5) |
| 241 | + |
| 242 | + async def test_case_4(self): |
| 243 | + """Driver can update a search index.""" |
| 244 | + # Create a collection with the "create" command using a randomly generated name (referred to as ``coll0``). |
| 245 | + coll0 = self.db[f"col{uuid.uuid4()}"] |
| 246 | + await coll0.insert_one({}) |
| 247 | + |
| 248 | + # Create a new search index on ``coll0``. |
| 249 | + model = {"name": _NAME, "definition": {"mappings": {"dynamic": False}}} |
| 250 | + resp = await coll0.create_search_index(model) |
| 251 | + |
| 252 | + # Assert that the command returns the name of the index: ``"test-search-index"``. |
| 253 | + self.assertEqual(resp, _NAME) |
| 254 | + |
| 255 | + # Run ``coll0.listSearchIndexes()`` repeatedly every 5 seconds until the following condition is satisfied: |
| 256 | + # An index with the ``name`` of ``test-search-index`` is present and index has a field ``queryable`` with the value of ``true``. |
| 257 | + await self.wait_for_ready(coll0) |
| 258 | + |
| 259 | + # Run a ``updateSearchIndex`` on ``coll0``. |
| 260 | + # Assert that the command does not error and the server responds with a success. |
| 261 | + model2: dict[str, Any] = {"name": _NAME, "definition": {"mappings": {"dynamic": True}}} |
| 262 | + await coll0.update_search_index(_NAME, model2["definition"]) |
| 263 | + |
| 264 | + # Run ``coll0.listSearchIndexes()`` repeatedly every 5 seconds until the following condition is satisfied: |
| 265 | + # An index with the ``name`` of ``test-search-index`` is present. This index is referred to as ``index``. |
| 266 | + # The index has a field ``queryable`` with a value of ``true`` and has a field ``status`` with the value of ``READY``. |
| 267 | + predicate = lambda index: index.get("queryable") is True and index.get("status") == "READY" |
| 268 | + await self.wait_for_ready(coll0, predicate=predicate) |
| 269 | + |
| 270 | + # Assert that an index is present with the name ``test-search-index`` and the definition has a property ``latestDefinition`` whose value is ``{ 'mappings': { 'dynamic': true } }``. |
| 271 | + index = (await (await coll0.list_search_indexes(_NAME)).to_list())[0] |
| 272 | + self.assertIn("latestDefinition", index) |
| 273 | + self.assertEqual(index["latestDefinition"], model2["definition"]) |
| 274 | + |
| 275 | + async def test_case_5(self): |
| 276 | + """``dropSearchIndex`` suppresses namespace not found errors.""" |
| 277 | + # Create a driver-side collection object for a randomly generated collection name. Do not create this collection on the server. |
| 278 | + coll0 = self.db[f"col{uuid.uuid4()}"] |
| 279 | + |
| 280 | + # Run a ``dropSearchIndex`` command and assert that no error is thrown. |
| 281 | + await coll0.drop_search_index("foo") |
| 282 | + |
| 283 | + async def test_case_6(self): |
| 284 | + """Driver can successfully create and list search indexes with non-default readConcern and writeConcern.""" |
| 285 | + # Create a collection with the "create" command using a randomly generated name (referred to as ``coll0``). |
| 286 | + coll0 = self.db[f"col{uuid.uuid4()}"] |
| 287 | + await coll0.insert_one({}) |
| 288 | + |
| 289 | + # Apply a write concern ``WriteConcern(w=1)`` and a read concern with ``ReadConcern(level="majority")`` to ``coll0``. |
| 290 | + coll0 = coll0.with_options( |
| 291 | + write_concern=WriteConcern(w="1"), read_concern=ReadConcern(level="majority") |
| 292 | + ) |
| 293 | + |
| 294 | + # Create a new search index on ``coll0`` with the ``createSearchIndex`` helper. |
| 295 | + name = "test-search-index-case6" |
| 296 | + model = {"name": name, "definition": {"mappings": {"dynamic": False}}} |
| 297 | + resp = await coll0.create_search_index(model) |
| 298 | + |
| 299 | + # Assert that the command returns the name of the index: ``"test-search-index-case6"``. |
| 300 | + self.assertEqual(resp, name) |
| 301 | + |
| 302 | + # Run ``coll0.listSearchIndexes()`` repeatedly every 5 seconds until the following condition is satisfied and store the value in a variable ``index``: |
| 303 | + # - An index with the ``name`` of ``test-search-index-case6`` is present and the index has a field ``queryable`` with a value of ``true``. |
| 304 | + index = await self.wait_for_ready(coll0, name) |
| 305 | + |
| 306 | + # Assert that ``index`` has a property ``latestDefinition`` whose value is ``{ 'mappings': { 'dynamic': false } }`` |
| 307 | + self.assertIn("latestDefinition", index) |
| 308 | + self.assertEqual(index["latestDefinition"], model["definition"]) |
| 309 | + |
| 310 | + async def test_case_7(self): |
| 311 | + """Driver handles index types.""" |
| 312 | + |
| 313 | + # Create a collection with the "create" command using a randomly generated name (referred to as ``coll0``). |
| 314 | + coll0 = self.db[f"col{uuid.uuid4()}"] |
| 315 | + await coll0.insert_one({}) |
| 316 | + |
| 317 | + # Use these search and vector search definitions for indexes. |
| 318 | + search_definition = {"mappings": {"dynamic": False}} |
| 319 | + vector_search_definition = { |
| 320 | + "fields": [ |
| 321 | + { |
| 322 | + "type": "vector", |
| 323 | + "path": "plot_embedding", |
| 324 | + "numDimensions": 1536, |
| 325 | + "similarity": "euclidean", |
| 326 | + }, |
| 327 | + ] |
| 328 | + } |
| 329 | + |
| 330 | + # Create a new search index on ``coll0`` that implicitly passes its type. |
| 331 | + implicit_search_resp = await coll0.create_search_index( |
| 332 | + model={"name": _NAME + "-implicit", "definition": search_definition} |
| 333 | + ) |
| 334 | + |
| 335 | + # Get the index definition. |
| 336 | + resp = await (await coll0.list_search_indexes(name=implicit_search_resp)).next() |
| 337 | + |
| 338 | + # Assert that the index model contains the correct index type: ``"search"``. |
| 339 | + self.assertEqual(resp["type"], "search") |
| 340 | + |
| 341 | + # Create a new search index on ``coll0`` that explicitly passes its type. |
| 342 | + explicit_search_resp = await coll0.create_search_index( |
| 343 | + model={"name": _NAME + "-explicit", "type": "search", "definition": search_definition} |
| 344 | + ) |
| 345 | + |
| 346 | + # Get the index definition. |
| 347 | + resp = await (await coll0.list_search_indexes(name=explicit_search_resp)).next() |
| 348 | + |
| 349 | + # Assert that the index model contains the correct index type: ``"search"``. |
| 350 | + self.assertEqual(resp["type"], "search") |
| 351 | + |
| 352 | + # Create a new vector search index on ``coll0`` that explicitly passes its type. |
| 353 | + explicit_vector_resp = await coll0.create_search_index( |
| 354 | + model={ |
| 355 | + "name": _NAME + "-vector", |
| 356 | + "type": "vectorSearch", |
| 357 | + "definition": vector_search_definition, |
| 358 | + } |
| 359 | + ) |
| 360 | + |
| 361 | + # Get the index definition. |
| 362 | + resp = await (await coll0.list_search_indexes(name=explicit_vector_resp)).next() |
| 363 | + |
| 364 | + # Assert that the index model contains the correct index type: ``"vectorSearch"``. |
| 365 | + self.assertEqual(resp["type"], "vectorSearch") |
| 366 | + |
| 367 | + # Catch the error raised when trying to create a vector search index without specifying the type |
| 368 | + with self.assertRaises(OperationFailure) as e: |
| 369 | + await coll0.create_search_index( |
| 370 | + model={"name": _NAME + "-error", "definition": vector_search_definition} |
| 371 | + ) |
| 372 | + self.assertIn("Attribute mappings missing.", e.exception.details["errmsg"]) |
| 373 | + |
| 374 | + |
| 375 | +globals().update( |
| 376 | + generate_test_classes( |
| 377 | + _TEST_PATH, |
| 378 | + module=__name__, |
| 379 | + ) |
| 380 | +) |
| 381 | + |
| 382 | +if __name__ == "__main__": |
| 383 | + unittest.main() |
0 commit comments