diff --git a/src/coll.rs b/src/coll.rs index 953906bca..b8064fd74 100644 --- a/src/coll.rs +++ b/src/coll.rs @@ -135,7 +135,7 @@ impl Clone for Collection { } } -#[derive(Debug)] +#[derive(Debug, Clone)] struct CollectionInner { client: Client, db: Database, @@ -183,6 +183,16 @@ impl Collection { } } + pub(crate) fn clone_unconcerned(&self) -> Self { + let mut new_inner = CollectionInner::clone(&self.inner); + new_inner.write_concern = None; + new_inner.read_concern = None; + Self { + inner: Arc::new(new_inner), + _phantom: Default::default(), + } + } + /// Get the `Client` that this collection descended from. pub fn client(&self) -> &Client { &self.inner.client diff --git a/src/lib.rs b/src/lib.rs index bb59688ac..c09e91f14 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -337,6 +337,7 @@ mod index; mod operation; pub mod results; pub(crate) mod runtime; +mod search_index; mod sdam; mod selection_criteria; mod serde_util; @@ -362,7 +363,7 @@ pub use crate::{ gridfs::{GridFsBucket, GridFsDownloadStream, GridFsUploadStream}, }; -pub use {client::session::ClusterTime, coll::Namespace, index::IndexModel, sdam::public::*}; +pub use {client::session::ClusterTime, coll::Namespace, index::IndexModel, sdam::public::*, search_index::SearchIndexModel}; #[cfg(all(feature = "tokio-runtime", feature = "sync",))] compile_error!( diff --git a/src/operation.rs b/src/operation.rs index 67358171c..52f2e8392 100644 --- a/src/operation.rs +++ b/src/operation.rs @@ -20,6 +20,7 @@ mod list_indexes; mod raw_output; mod run_command; mod run_cursor_command; +mod search_index; mod update; #[cfg(test)] @@ -73,6 +74,7 @@ pub(crate) use list_indexes::ListIndexes; pub(crate) use raw_output::RawOutput; pub(crate) use run_command::RunCommand; pub(crate) use run_cursor_command::RunCursorCommand; +pub(crate) use search_index::{CreateSearchIndexes, DropSearchIndex, UpdateSearchIndex}; pub(crate) use update::{Update, UpdateOrReplace}; const SERVER_4_2_0_WIRE_VERSION: i32 = 8; diff --git a/src/operation/search_index.rs b/src/operation/search_index.rs new file mode 100644 index 000000000..57267f8d0 --- /dev/null +++ b/src/operation/search_index.rs @@ -0,0 +1,179 @@ +use bson::{doc, Document}; +use serde::Deserialize; + +use crate::{cmap::Command, error::Result, Namespace, SearchIndexModel}; + +use super::OperationWithDefaults; + +#[derive(Debug)] +pub(crate) struct CreateSearchIndexes { + ns: Namespace, + indexes: Vec, +} + +impl CreateSearchIndexes { + pub(crate) fn new(ns: Namespace, indexes: Vec) -> Self { + Self { ns, indexes } + } +} + +impl OperationWithDefaults for CreateSearchIndexes { + type O = Vec; + type Command = Document; + const NAME: &'static str = "createSearchIndexes"; + + fn build(&mut self, _description: &crate::cmap::StreamDescription) -> Result { + Ok(Command::new( + Self::NAME.to_string(), + self.ns.db.clone(), + doc! { + Self::NAME: self.ns.coll.clone(), + "indexes": bson::to_bson(&self.indexes)?, + }, + )) + } + + fn handle_response( + &self, + response: crate::cmap::RawCommandResponse, + _description: &crate::cmap::StreamDescription, + ) -> Result { + #[derive(Debug, Deserialize)] + #[serde(rename_all = "camelCase")] + struct Response { + indexes_created: Vec, + } + + #[derive(Debug, Deserialize)] + struct CreatedIndex { + #[allow(unused)] + id: String, + name: String, + } + + let response: Response = response.body()?; + Ok(response + .indexes_created + .into_iter() + .map(|ci| ci.name) + .collect()) + } + + fn supports_sessions(&self) -> bool { + false + } + + fn supports_read_concern(&self, _description: &crate::cmap::StreamDescription) -> bool { + false + } +} + +#[derive(Debug)] +pub(crate) struct UpdateSearchIndex { + ns: Namespace, + name: String, + definition: Document, +} + +impl UpdateSearchIndex { + pub(crate) fn new(ns: Namespace, name: String, definition: Document) -> Self { + Self { + ns, + name, + definition, + } + } +} + +impl OperationWithDefaults for UpdateSearchIndex { + type O = (); + type Command = Document; + const NAME: &'static str = "updateSearchIndex"; + + fn build( + &mut self, + _description: &crate::cmap::StreamDescription, + ) -> crate::error::Result> { + Ok(Command::new( + Self::NAME.to_string(), + self.ns.db.clone(), + doc! { + Self::NAME: self.ns.coll.clone(), + "name": &self.name, + "definition": &self.definition, + }, + )) + } + + fn handle_response( + &self, + response: crate::cmap::RawCommandResponse, + _description: &crate::cmap::StreamDescription, + ) -> crate::error::Result { + response.body() + } + + fn supports_sessions(&self) -> bool { + false + } + + fn supports_read_concern(&self, _description: &crate::cmap::StreamDescription) -> bool { + false + } +} + +#[derive(Debug)] +pub(crate) struct DropSearchIndex { + ns: Namespace, + name: String, +} + +impl DropSearchIndex { + pub(crate) fn new(ns: Namespace, name: String) -> Self { + Self { ns, name } + } +} + +impl OperationWithDefaults for DropSearchIndex { + type O = (); + type Command = Document; + const NAME: &'static str = "dropSearchIndex"; + + fn build( + &mut self, + _description: &crate::cmap::StreamDescription, + ) -> Result> { + Ok(Command::new( + Self::NAME.to_string(), + self.ns.db.clone(), + doc! { + Self::NAME: self.ns.coll.clone(), + "name": &self.name, + }, + )) + } + + fn handle_response( + &self, + response: crate::cmap::RawCommandResponse, + _description: &crate::cmap::StreamDescription, + ) -> Result { + response.body() + } + + fn handle_error(&self, error: crate::error::Error) -> Result { + if error.is_ns_not_found() { + Ok(()) + } else { + Err(error) + } + } + + fn supports_sessions(&self) -> bool { + false + } + + fn supports_read_concern(&self, _description: &crate::cmap::StreamDescription) -> bool { + false + } +} diff --git a/src/options.rs b/src/options.rs index 2851fb009..a96ec7967 100644 --- a/src/options.rs +++ b/src/options.rs @@ -25,6 +25,7 @@ pub use crate::{ db::options::*, gridfs::options::*, index::options::*, + search_index::options::*, selection_criteria::*, }; diff --git a/src/search_index.rs b/src/search_index.rs new file mode 100644 index 000000000..91c18cb5c --- /dev/null +++ b/src/search_index.rs @@ -0,0 +1,135 @@ +use self::options::*; +use crate::{ + bson::Document, + coll::options::AggregateOptions, + error::{Error, Result}, + operation::{CreateSearchIndexes, DropSearchIndex, UpdateSearchIndex}, + Collection, + Cursor, +}; + +use bson::doc; +use serde::{Deserialize, Serialize}; +use typed_builder::TypedBuilder; + +impl Collection { + /// Convenience method for creating a single search index. + pub async fn create_search_index( + &self, + model: SearchIndexModel, + options: impl Into>, + ) -> Result { + let mut names = self.create_search_indexes(Some(model), options).await?; + match names.len() { + 1 => Ok(names.pop().unwrap()), + n => Err(Error::internal(format!("expected 1 index name, got {}", n))), + } + } + + /// Creates multiple search indexes on the collection. + pub async fn create_search_indexes( + &self, + models: impl IntoIterator, + _options: impl Into>, + ) -> Result> { + let op = CreateSearchIndexes::new(self.namespace(), models.into_iter().collect()); + self.client().execute_operation(op, None).await + } + + /// Updates the search index with the given name to use the provided definition. + pub async fn update_search_index( + &self, + name: impl AsRef, + definition: Document, + _options: impl Into>, + ) -> Result<()> { + let op = UpdateSearchIndex::new( + self.namespace(), + name.as_ref().to_string(), + definition.clone(), + ); + self.client().execute_operation(op, None).await + } + + /// Drops the search index with the given name. + pub async fn drop_search_index( + &self, + name: impl AsRef, + _options: impl Into>, + ) -> Result<()> { + let op = DropSearchIndex::new(self.namespace(), name.as_ref().to_string()); + self.client().execute_operation(op, None).await + } + + /// Gets index information for one or more search indexes in the collection. + /// + /// If name is not specified, information for all indexes on the specified collection will be + /// returned. + pub async fn list_search_indexes( + &self, + name: impl Into>, + aggregation_options: impl Into>, + _list_index_options: impl Into>, + ) -> Result> { + let mut inner = doc! {}; + if let Some(name) = name.into() { + inner.insert("name", name.to_string()); + } + self.clone_unconcerned() + .aggregate( + vec![doc! { + "$listSearchIndexes": inner, + }], + aggregation_options, + ) + .await + } +} + +/// Specifies the options for a search index. +#[derive(Debug, Clone, Default, TypedBuilder, Serialize, Deserialize)] +#[builder(field_defaults(default, setter(into)))] +#[non_exhaustive] +pub struct SearchIndexModel { + /// The definition for this index. + pub definition: Document, + + /// The name for this index, if present. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +pub mod options { + #[cfg(docsrs)] + use crate::Collection; + use serde::Deserialize; + use typed_builder::TypedBuilder; + + /// Options for [Collection::create_search_index]. Present to allow additional options to be + /// added in the future as a non-breaking change. + #[derive(Clone, Debug, Default, TypedBuilder, Deserialize)] + #[builder(field_defaults(default, setter(into)))] + #[non_exhaustive] + pub struct CreateSearchIndexOptions {} + + /// Options for [Collection::update_search_index]. Present to allow additional options to be + /// added in the future as a non-breaking change. + #[derive(Clone, Debug, Default, TypedBuilder, Deserialize)] + #[builder(field_defaults(default, setter(into)))] + #[non_exhaustive] + pub struct UpdateSearchIndexOptions {} + + /// Options for [Collection::list_search_indexes]. Present to allow additional options to be + /// added in the future as a non-breaking change. + #[derive(Clone, Debug, Default, TypedBuilder, Deserialize)] + #[builder(field_defaults(default, setter(into)))] + #[non_exhaustive] + pub struct ListSearchIndexOptions {} + + /// Options for [Collection::drop_search_index]. Present to allow additional options to be + /// added in the future as a non-breaking change. + #[derive(Clone, Debug, Default, TypedBuilder, Deserialize)] + #[builder(field_defaults(default, setter(into)))] + #[non_exhaustive] + pub struct DropSearchIndexOptions {} +} diff --git a/src/test/spec.rs b/src/test/spec.rs index 4084aaabe..48515f47e 100644 --- a/src/test/spec.rs +++ b/src/test/spec.rs @@ -10,6 +10,7 @@ mod crud; mod crud_v1; mod faas; mod gridfs; +mod index_management; #[cfg(all(not(feature = "sync"), not(feature = "tokio-sync")))] mod initial_dns_seedlist_discovery; mod load_balancers; diff --git a/src/test/spec/index_management.rs b/src/test/spec/index_management.rs new file mode 100644 index 000000000..6113af4a0 --- /dev/null +++ b/src/test/spec/index_management.rs @@ -0,0 +1,7 @@ +use crate::test::spec::unified_runner::run_unified_tests; + +#[cfg_attr(feature = "tokio-runtime", tokio::test)] +#[cfg_attr(feature = "async-std-runtime", async_std::test)] +async fn run() { + run_unified_tests(&["index-management"]).await; +} diff --git a/src/test/spec/json/index-management/README.rst b/src/test/spec/json/index-management/README.rst new file mode 100644 index 000000000..090cda4be --- /dev/null +++ b/src/test/spec/json/index-management/README.rst @@ -0,0 +1,200 @@ +====================== +Index Management Tests +====================== + +.. contents:: + +---- + +Test Plan +========= + +These prose tests are ported from the legacy enumerate-indexes spec. + +Configurations +-------------- + +- standalone node +- replica set primary node +- replica set secondary node +- mongos node + +Preparation +----------- + +For each of the configurations: + +- Create a (new) database +- Create a collection +- Create a single column index, a compound index, and a unique index +- Insert at least one document containing all the fields that the above + indicated indexes act on + +Tests + +- Run the driver's method that returns a list of index names, and: + + - verify that *all* index names are represented in the result + - verify that there are no duplicate index names + - verify there are no returned indexes that do not exist + +- Run the driver's method that returns a list of index information records, and: + + - verify all the indexes are represented in the result + - verify the "unique" flags show up for the unique index + - verify there are no duplicates in the returned list + - if the result consists of statically defined index models that include an ``ns`` field, verify + that its value is accurate + +Search Index Management Helpers +------------------------------- + +These tests are intended to smoke test the search management helpers end-to-end against a live Atlas cluster. + +The search index management commands are asynchronous and mongod/mongos returns before the changes to a clusters' search indexes have completed. When +these prose tests specify "waiting for the changes", drivers should repeatedly poll the cluster with ``listSearchIndexes`` +until the changes are visible. Each test specifies the condition that is considered "ready". For example, when creating a +new search index, waiting until the inserted index has a status ``queryable: true`` indicates that the index was successfully +created. + +The commands tested in these prose tests take a while to successfully complete. Drivers should raise the timeout for each test to avoid timeout errors if +the test timeout is too low. 5 minutes is a sufficiently large timeout that any timeout that occurs indicates a real failure, but this value is not required and can be tweaked per-driver. + +There is a server-side limitation that prevents multiple search indexes from being created with the same name, definition and +collection name. This limitation does not take into account collection uuid. Because these commands are asynchronous, any cleanup +code that may run after a test (cleaning a database or dropping search indexes) may not have completed by the next iteration of the +test (or the next test run, if running locally). To address this issue, each test uses a randomly generated collection name. Drivers +may generate this collection name however they like, but a suggested implementation is a hex representation of an +ObjectId (``new ObjectId().toHexString()`` in Node). + +Setup +~~~~~ + +These tests must run against an Atlas cluster with a 7.0+ server. `Scripts are available `_ in drivers-evergreen-tools which can setup and teardown +Atlas clusters. To ensure that the Atlas cluster is cleaned up after each CI run, drivers should configure evergreen to run these tests +as a part of a task group. Be sure that the cluster gets torn down! + +When working locally on these tests, the same Atlas setup and teardown scripts can be used locally to provision a cluster for development. + +Case 1: Driver can successfully create and list search indexes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +#. Create a collection with the "create" command using a randomly generated name (referred to as ``coll0``). +#. Create a new search index on ``coll0`` with the ``createSearchIndex`` helper. Use the following definition: + + .. code:: typescript + + { + name: 'test-search-index', + definition: { + mappings: { dynamic: false } + } + } + +#. Assert that the command returns the name of the index: ``"test-search-index"``. +#. Run ``coll0.listSearchIndexes()`` repeatedly every 5 seconds until the following condition is satisfied and store the value in a variable ``index``: + + - An index with the ``name`` of ``test-search-index`` is present and the index has a field ``queryable`` with a value of ``true``. + +#. Assert that ``index`` has a property ``latestDefinition`` whose value is ``{ 'mappings': { 'dynamic': false } }`` + +Case 2: Driver can successfully create multiple indexes in batch +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +#. Create a collection with the "create" command using a randomly generated name (referred to as ``coll0``). +#. Create two new search indexes on ``coll0`` with the ``createSearchIndexes`` helper. Use the following + definitions when creating the indexes. These definitions are referred to as ``indexDefinitions``. + + .. code:: typescript + + { + name: 'test-search-index-1', + definition: { + mappings: { dynamic: false } + } + } + + { + name: 'test-search-index-2', + definition: { + mappings: { dynamic: false } + } + } + +#. Assert that the command returns an array containing the new indexes' names: ``["test-search-index-1", "test-search-index-2"]``. +#. Run ``coll0.listSearchIndexes()`` repeatedly every 5 seconds until the following conditions are satisfied. + + - 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``. + - 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``. + +#. Assert that ``index1`` and ``index2`` have the property ``latestDefinition`` whose value is ``{ "mappings" : { "dynamic" : false } }`` + +Case 3: Driver can successfully drop search indexes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +#. Create a collection with the "create" command using a randomly generated name (referred to as ``coll0``). +#. Create a new search index on ``coll0`` with the following definition: + + .. code:: typescript + + { + name: 'test-search-index', + definition: { + mappings: { dynamic: false } + } + } + +#. Assert that the command returns the name of the index: ``"test-search-index"``. +#. Run ``coll0.listSearchIndexes()`` repeatedly every 5 seconds until the following condition is satisfied: + + - An index with the ``name`` of ``test-search-index`` is present and index has a field ``queryable`` with the value of ``true``. + +#. Run a ``dropSearchIndex`` on ``coll0``, using ``test-search-index`` for the name. +#. Run ``coll0.listSearchIndexes()`` repeatedly every 5 seconds until ``listSearchIndexes`` returns an empty array. + +This test fails if it times out waiting for the deletion to succeed. + +Case 4: Driver can update a search index +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +#. Create a collection with the "create" command using a randomly generated name (referred to as ``coll0``). +#. Create a new search index on ``coll0`` with the following definition: + + .. code:: typescript + + { + name: 'test-search-index', + definition: { + mappings: { dynamic: false } + } + } + +#. Assert that the command returns the name of the index: ``"test-search-index"``. +#. Run ``coll0.listSearchIndexes()`` repeatedly every 5 seconds until the following condition is satisfied: + + - An index with the ``name`` of ``test-search-index`` is present and index has a field ``queryable`` with the value of ``true``. + +#. Run a ``updateSearchIndex`` on ``coll0``, using the following definition. + + .. code:: typescript + + { + name: 'test-search-index', + definition: { + mappings: { dynamic: true } + } + } + +#. Assert that the command does not error and the server responds with a success. +#. Run ``coll0.listSearchIndexes()`` repeatedly every 5 seconds until the following conditions are satisfied: + + - An index with the ``name`` of ``test-search-index`` is present. This index is referred to as ``index``. + - The index has a field ``queryable`` with a value of ``true`` and has a field ``status`` with the value of ``READY``. + +#. 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 } }``. + +Case 5: ``dropSearchIndex`` suppresses namespace not found errors +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +#. Create a driver-side collection object for a randomly generated collection name. Do not create this collection on the server. +#. Run a ``dropSearchIndex`` command and assert that no error is thrown. diff --git a/src/test/spec/json/index-management/createSearchIndex.json b/src/test/spec/json/index-management/createSearchIndex.json new file mode 100644 index 000000000..04cffbe9c --- /dev/null +++ b/src/test/spec/json/index-management/createSearchIndex.json @@ -0,0 +1,136 @@ +{ + "description": "createSearchIndex", + "schemaVersion": "1.4", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "database0" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "collection0" + } + } + ], + "runOnRequirements": [ + { + "minServerVersion": "7.0.0", + "topologies": [ + "replicaset", + "load-balanced", + "sharded" + ], + "serverless": "forbid" + } + ], + "tests": [ + { + "description": "no name provided for an index definition", + "operations": [ + { + "name": "createSearchIndex", + "object": "collection0", + "arguments": { + "model": { + "definition": { + "mappings": { + "dynamic": true + } + } + } + }, + "expectError": { + "isError": true, + "errorContains": "Search index commands are only supported with Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "createSearchIndexes": "collection0", + "indexes": [ + { + "definition": { + "mappings": { + "dynamic": true + } + } + } + ], + "$db": "database0" + } + } + } + ] + } + ] + }, + { + "description": "name provided for an index definition", + "operations": [ + { + "name": "createSearchIndex", + "object": "collection0", + "arguments": { + "model": { + "definition": { + "mappings": { + "dynamic": true + } + }, + "name": "test index" + } + }, + "expectError": { + "isError": true, + "errorContains": "Search index commands are only supported with Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "createSearchIndexes": "collection0", + "indexes": [ + { + "definition": { + "mappings": { + "dynamic": true + } + }, + "name": "test index" + } + ], + "$db": "database0" + } + } + } + ] + } + ] + } + ] +} diff --git a/src/test/spec/json/index-management/createSearchIndex.yml b/src/test/spec/json/index-management/createSearchIndex.yml new file mode 100644 index 000000000..6aa56f3bc --- /dev/null +++ b/src/test/spec/json/index-management/createSearchIndex.yml @@ -0,0 +1,62 @@ +description: "createSearchIndex" +schemaVersion: "1.4" +createEntities: + - client: + id: &client0 client0 + useMultipleMongoses: false + observeEvents: + - commandStartedEvent + - database: + id: &database0 database0 + client: *client0 + databaseName: *database0 + - collection: + id: &collection0 collection0 + database: *database0 + collectionName: *collection0 + +runOnRequirements: + - minServerVersion: "7.0.0" + topologies: [ replicaset, load-balanced, sharded ] + serverless: forbid + +tests: + - description: "no name provided for an index definition" + operations: + - name: createSearchIndex + object: *collection0 + arguments: + model: { definition: &definition { mappings: { dynamic: true } } } + expectError: + # This test always errors in a non-Atlas environment. The test functions as a unit test by asserting + # that the driver constructs and sends the correct command. + isError: true + errorContains: Search index commands are only supported with Atlas + expectEvents: + - client: *client0 + events: + - commandStartedEvent: + command: + createSearchIndexes: *collection0 + indexes: [ { definition: *definition } ] + $db: *database0 + + - description: "name provided for an index definition" + operations: + - name: createSearchIndex + object: *collection0 + arguments: + model: { definition: &definition { mappings: { dynamic: true } } , name: 'test index' } + expectError: + # This test always errors in a non-Atlas environment. The test functions as a unit test by asserting + # that the driver constructs and sends the correct command. + isError: true + errorContains: Search index commands are only supported with Atlas + expectEvents: + - client: *client0 + events: + - commandStartedEvent: + command: + createSearchIndexes: *collection0 + indexes: [ { definition: *definition, name: 'test index' } ] + $db: *database0 \ No newline at end of file diff --git a/src/test/spec/json/index-management/createSearchIndexes.json b/src/test/spec/json/index-management/createSearchIndexes.json new file mode 100644 index 000000000..95dbedde7 --- /dev/null +++ b/src/test/spec/json/index-management/createSearchIndexes.json @@ -0,0 +1,172 @@ +{ + "description": "createSearchIndexes", + "schemaVersion": "1.4", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "database0" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "collection0" + } + } + ], + "runOnRequirements": [ + { + "minServerVersion": "7.0.0", + "topologies": [ + "replicaset", + "load-balanced", + "sharded" + ], + "serverless": "forbid" + } + ], + "tests": [ + { + "description": "empty index definition array", + "operations": [ + { + "name": "createSearchIndexes", + "object": "collection0", + "arguments": { + "models": [] + }, + "expectError": { + "isError": true, + "errorContains": "Search index commands are only supported with Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "createSearchIndexes": "collection0", + "indexes": [], + "$db": "database0" + } + } + } + ] + } + ] + }, + { + "description": "no name provided for an index definition", + "operations": [ + { + "name": "createSearchIndexes", + "object": "collection0", + "arguments": { + "models": [ + { + "definition": { + "mappings": { + "dynamic": true + } + } + } + ] + }, + "expectError": { + "isError": true, + "errorContains": "Search index commands are only supported with Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "createSearchIndexes": "collection0", + "indexes": [ + { + "definition": { + "mappings": { + "dynamic": true + } + } + } + ], + "$db": "database0" + } + } + } + ] + } + ] + }, + { + "description": "name provided for an index definition", + "operations": [ + { + "name": "createSearchIndexes", + "object": "collection0", + "arguments": { + "models": [ + { + "definition": { + "mappings": { + "dynamic": true + } + }, + "name": "test index" + } + ] + }, + "expectError": { + "isError": true, + "errorContains": "Search index commands are only supported with Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "createSearchIndexes": "collection0", + "indexes": [ + { + "definition": { + "mappings": { + "dynamic": true + } + }, + "name": "test index" + } + ], + "$db": "database0" + } + } + } + ] + } + ] + } + ] +} diff --git a/src/test/spec/json/index-management/createSearchIndexes.yml b/src/test/spec/json/index-management/createSearchIndexes.yml new file mode 100644 index 000000000..54a6e84cc --- /dev/null +++ b/src/test/spec/json/index-management/createSearchIndexes.yml @@ -0,0 +1,83 @@ +description: "createSearchIndexes" +schemaVersion: "1.4" +createEntities: + - client: + id: &client0 client0 + useMultipleMongoses: false + observeEvents: + - commandStartedEvent + - database: + id: &database0 database0 + client: *client0 + databaseName: *database0 + - collection: + id: &collection0 collection0 + database: *database0 + collectionName: *collection0 + +runOnRequirements: + - minServerVersion: "7.0.0" + topologies: [ replicaset, load-balanced, sharded ] + serverless: forbid + +tests: + - description: "empty index definition array" + operations: + - name: createSearchIndexes + object: *collection0 + arguments: + models: [] + expectError: + # This test always errors in a non-Atlas environment. The test functions as a unit test by asserting + # that the driver constructs and sends the correct command. + isError: true + errorContains: Search index commands are only supported with Atlas + expectEvents: + - client: *client0 + events: + - commandStartedEvent: + command: + createSearchIndexes: *collection0 + indexes: [] + $db: *database0 + + + - description: "no name provided for an index definition" + operations: + - name: createSearchIndexes + object: *collection0 + arguments: + models: [ { definition: &definition { mappings: { dynamic: true } } } ] + expectError: + # This test always errors in a non-Atlas environment. The test functions as a unit test by asserting + # that the driver constructs and sends the correct command. + isError: true + errorContains: Search index commands are only supported with Atlas + expectEvents: + - client: *client0 + events: + - commandStartedEvent: + command: + createSearchIndexes: *collection0 + indexes: [ { definition: *definition } ] + $db: *database0 + + - description: "name provided for an index definition" + operations: + - name: createSearchIndexes + object: *collection0 + arguments: + models: [ { definition: &definition { mappings: { dynamic: true } } , name: 'test index' } ] + expectError: + # This test always errors in a non-Atlas environment. The test functions as a unit test by asserting + # that the driver constructs and sends the correct command. + isError: true + errorContains: Search index commands are only supported with Atlas + expectEvents: + - client: *client0 + events: + - commandStartedEvent: + command: + createSearchIndexes: *collection0 + indexes: [ { definition: *definition, name: 'test index' } ] + $db: *database0 \ No newline at end of file diff --git a/src/test/spec/json/index-management/dropSearchIndex.json b/src/test/spec/json/index-management/dropSearchIndex.json new file mode 100644 index 000000000..0f21a5b68 --- /dev/null +++ b/src/test/spec/json/index-management/dropSearchIndex.json @@ -0,0 +1,74 @@ +{ + "description": "dropSearchIndex", + "schemaVersion": "1.4", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "database0" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "collection0" + } + } + ], + "runOnRequirements": [ + { + "minServerVersion": "7.0.0", + "topologies": [ + "replicaset", + "load-balanced", + "sharded" + ], + "serverless": "forbid" + } + ], + "tests": [ + { + "description": "sends the correct command", + "operations": [ + { + "name": "dropSearchIndex", + "object": "collection0", + "arguments": { + "name": "test index" + }, + "expectError": { + "isError": true, + "errorContains": "Search index commands are only supported with Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "dropSearchIndex": "collection0", + "name": "test index", + "$db": "database0" + } + } + } + ] + } + ] + } + ] +} diff --git a/src/test/spec/json/index-management/dropSearchIndex.yml b/src/test/spec/json/index-management/dropSearchIndex.yml new file mode 100644 index 000000000..e384cf26c --- /dev/null +++ b/src/test/spec/json/index-management/dropSearchIndex.yml @@ -0,0 +1,42 @@ +description: "dropSearchIndex" +schemaVersion: "1.4" +createEntities: + - client: + id: &client0 client0 + useMultipleMongoses: false + observeEvents: + - commandStartedEvent + - database: + id: &database0 database0 + client: *client0 + databaseName: *database0 + - collection: + id: &collection0 collection0 + database: *database0 + collectionName: *collection0 + +runOnRequirements: + - minServerVersion: "7.0.0" + topologies: [ replicaset, load-balanced, sharded ] + serverless: forbid + +tests: + - description: "sends the correct command" + operations: + - name: dropSearchIndex + object: *collection0 + arguments: + name: &indexName 'test index' + expectError: + # This test always errors in a non-Atlas environment. The test functions as a unit test by asserting + # that the driver constructs and sends the correct command. + isError: true + errorContains: Search index commands are only supported with Atlas + expectEvents: + - client: *client0 + events: + - commandStartedEvent: + command: + dropSearchIndex: *collection0 + name: *indexName + $db: *database0 diff --git a/src/test/spec/json/index-management/listSearchIndexes.json b/src/test/spec/json/index-management/listSearchIndexes.json new file mode 100644 index 000000000..24c51ad88 --- /dev/null +++ b/src/test/spec/json/index-management/listSearchIndexes.json @@ -0,0 +1,156 @@ +{ + "description": "listSearchIndexes", + "schemaVersion": "1.4", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "database0" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "collection0" + } + } + ], + "runOnRequirements": [ + { + "minServerVersion": "7.0.0", + "topologies": [ + "replicaset", + "load-balanced", + "sharded" + ], + "serverless": "forbid" + } + ], + "tests": [ + { + "description": "when no name is provided, it does not populate the filter", + "operations": [ + { + "name": "listSearchIndexes", + "object": "collection0", + "expectError": { + "isError": true, + "errorContains": "Search index commands are only supported with Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "aggregate": "collection0", + "pipeline": [ + { + "$listSearchIndexes": {} + } + ] + } + } + } + ] + } + ] + }, + { + "description": "when a name is provided, it is present in the filter", + "operations": [ + { + "name": "listSearchIndexes", + "object": "collection0", + "arguments": { + "name": "test index" + }, + "expectError": { + "isError": true, + "errorContains": "Search index commands are only supported with Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "aggregate": "collection0", + "pipeline": [ + { + "$listSearchIndexes": { + "name": "test index" + } + } + ], + "$db": "database0" + } + } + } + ] + } + ] + }, + { + "description": "aggregation cursor options are supported", + "operations": [ + { + "name": "listSearchIndexes", + "object": "collection0", + "arguments": { + "name": "test index", + "aggregationOptions": { + "batchSize": 10 + } + }, + "expectError": { + "isError": true, + "errorContains": "Search index commands are only supported with Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "aggregate": "collection0", + "cursor": { + "batchSize": 10 + }, + "pipeline": [ + { + "$listSearchIndexes": { + "name": "test index" + } + } + ], + "$db": "database0" + } + } + } + ] + } + ] + } + ] +} diff --git a/src/test/spec/json/index-management/listSearchIndexes.yml b/src/test/spec/json/index-management/listSearchIndexes.yml new file mode 100644 index 000000000..a50becdf1 --- /dev/null +++ b/src/test/spec/json/index-management/listSearchIndexes.yml @@ -0,0 +1,85 @@ +description: "listSearchIndexes" +schemaVersion: "1.4" +createEntities: + - client: + id: &client0 client0 + useMultipleMongoses: false + observeEvents: + - commandStartedEvent + - database: + id: &database0 database0 + client: *client0 + databaseName: *database0 + - collection: + id: &collection0 collection0 + database: *database0 + collectionName: *collection0 + +runOnRequirements: + - minServerVersion: "7.0.0" + topologies: [ replicaset, load-balanced, sharded ] + serverless: forbid + +tests: + - description: "when no name is provided, it does not populate the filter" + operations: + - name: listSearchIndexes + object: *collection0 + expectError: + # This test always errors in a non-Atlas environment. The test functions as a unit test by asserting + # that the driver constructs and sends the correct command. + isError: true + errorContains: Search index commands are only supported with Atlas + expectEvents: + - client: *client0 + events: + - commandStartedEvent: + command: + aggregate: *collection0 + pipeline: + - $listSearchIndexes: {} + + - description: "when a name is provided, it is present in the filter" + operations: + - name: listSearchIndexes + object: *collection0 + arguments: + name: &indexName "test index" + expectError: + # This test always errors in a non-Atlas environment. The test functions as a unit test by asserting + # that the driver constructs and sends the correct command. + isError: true + errorContains: Search index commands are only supported with Atlas + expectEvents: + - client: *client0 + events: + - commandStartedEvent: + command: + aggregate: *collection0 + pipeline: + - $listSearchIndexes: { name: *indexName } + $db: *database0 + + - description: aggregation cursor options are supported + operations: + - name: listSearchIndexes + object: *collection0 + arguments: + name: &indexName "test index" + aggregationOptions: + batchSize: 10 + expectError: + # This test always errors in a non-Atlas environment. The test functions as a unit test by asserting + # that the driver constructs and sends the correct command. + isError: true + errorContains: Search index commands are only supported with Atlas + expectEvents: + - client: *client0 + events: + - commandStartedEvent: + command: + aggregate: *collection0 + cursor: { batchSize: 10 } + pipeline: + - $listSearchIndexes: { name: *indexName } + $db: *database0 \ No newline at end of file diff --git a/src/test/spec/json/index-management/searchIndexIgnoresReadWriteConcern.json b/src/test/spec/json/index-management/searchIndexIgnoresReadWriteConcern.json new file mode 100644 index 000000000..102f5a767 --- /dev/null +++ b/src/test/spec/json/index-management/searchIndexIgnoresReadWriteConcern.json @@ -0,0 +1,252 @@ +{ + "description": "search index operations ignore read and write concern", + "schemaVersion": "1.4", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "uriOptions": { + "readConcernLevel": "local", + "w": 1 + }, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "database0" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "collection0" + } + } + ], + "runOnRequirements": [ + { + "minServerVersion": "7.0.0", + "topologies": [ + "replicaset", + "load-balanced", + "sharded" + ], + "serverless": "forbid" + } + ], + "tests": [ + { + "description": "createSearchIndex ignores read and write concern", + "operations": [ + { + "name": "createSearchIndex", + "object": "collection0", + "arguments": { + "model": { + "definition": { + "mappings": { + "dynamic": true + } + } + } + }, + "expectError": { + "isError": true, + "errorContains": "Search index commands are only supported with Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "createSearchIndexes": "collection0", + "indexes": [ + { + "definition": { + "mappings": { + "dynamic": true + } + } + } + ], + "$db": "database0", + "writeConcern": { + "$$exists": false + }, + "readConcern": { + "$$exists": false + } + } + } + } + ] + } + ] + }, + { + "description": "createSearchIndex ignores read and write concern", + "operations": [ + { + "name": "createSearchIndexes", + "object": "collection0", + "arguments": { + "models": [] + }, + "expectError": { + "isError": true, + "errorContains": "Search index commands are only supported with Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "createSearchIndexes": "collection0", + "indexes": [], + "$db": "database0", + "writeConcern": { + "$$exists": false + }, + "readConcern": { + "$$exists": false + } + } + } + } + ] + } + ] + }, + { + "description": "dropSearchIndex ignores read and write concern", + "operations": [ + { + "name": "dropSearchIndex", + "object": "collection0", + "arguments": { + "name": "test index" + }, + "expectError": { + "isError": true, + "errorContains": "Search index commands are only supported with Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "dropSearchIndex": "collection0", + "name": "test index", + "$db": "database0", + "writeConcern": { + "$$exists": false + }, + "readConcern": { + "$$exists": false + } + } + } + } + ] + } + ] + }, + { + "description": "listSearchIndexes ignores read and write concern", + "operations": [ + { + "name": "listSearchIndexes", + "object": "collection0", + "expectError": { + "isError": true, + "errorContains": "Search index commands are only supported with Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "aggregate": "collection0", + "pipeline": [ + { + "$listSearchIndexes": {} + } + ], + "writeConcern": { + "$$exists": false + }, + "readConcern": { + "$$exists": false + } + } + } + } + ] + } + ] + }, + { + "description": "updateSearchIndex ignores the read and write concern", + "operations": [ + { + "name": "updateSearchIndex", + "object": "collection0", + "arguments": { + "name": "test index", + "definition": {} + }, + "expectError": { + "isError": true, + "errorContains": "Search index commands are only supported with Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "updateSearchIndex": "collection0", + "name": "test index", + "definition": {}, + "$db": "database0", + "writeConcern": { + "$$exists": false + }, + "readConcern": { + "$$exists": false + } + } + } + } + ] + } + ] + } + ] + } \ No newline at end of file diff --git a/src/test/spec/json/index-management/updateSearchIndex.json b/src/test/spec/json/index-management/updateSearchIndex.json new file mode 100644 index 000000000..88a46a306 --- /dev/null +++ b/src/test/spec/json/index-management/updateSearchIndex.json @@ -0,0 +1,76 @@ +{ + "description": "updateSearchIndex", + "schemaVersion": "1.4", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "database0" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "collection0" + } + } + ], + "runOnRequirements": [ + { + "minServerVersion": "7.0.0", + "topologies": [ + "replicaset", + "load-balanced", + "sharded" + ], + "serverless": "forbid" + } + ], + "tests": [ + { + "description": "sends the correct command", + "operations": [ + { + "name": "updateSearchIndex", + "object": "collection0", + "arguments": { + "name": "test index", + "definition": {} + }, + "expectError": { + "isError": true, + "errorContains": "Search index commands are only supported with Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "updateSearchIndex": "collection0", + "name": "test index", + "definition": {}, + "$db": "database0" + } + } + } + ] + } + ] + } + ] +} diff --git a/src/test/spec/json/index-management/updateSearchIndex.yml b/src/test/spec/json/index-management/updateSearchIndex.yml new file mode 100644 index 000000000..bb18ab512 --- /dev/null +++ b/src/test/spec/json/index-management/updateSearchIndex.yml @@ -0,0 +1,45 @@ +description: "updateSearchIndex" +schemaVersion: "1.4" +createEntities: + - client: + id: &client0 client0 + useMultipleMongoses: false + observeEvents: + - commandStartedEvent + - database: + id: &database0 database0 + client: *client0 + databaseName: *database0 + - collection: + id: &collection0 collection0 + database: *database0 + collectionName: *collection0 + +runOnRequirements: + - minServerVersion: "7.0.0" + topologies: [ replicaset, load-balanced, sharded ] + serverless: forbid + +tests: + - description: "sends the correct command" + operations: + - name: updateSearchIndex + object: *collection0 + arguments: + name: &indexName 'test index' + definition: &definition {} + expectError: + # This test always errors in a non-Atlas environment. The test functions as a unit test by asserting + # that the driver constructs and sends the correct command. + isError: true + errorContains: Search index commands are only supported with Atlas + expectEvents: + - client: *client0 + events: + - commandStartedEvent: + command: + updateSearchIndex: *collection0 + name: *indexName + definition: *definition + $db: *database0 + diff --git a/src/test/spec/unified_runner/operation.rs b/src/test/spec/unified_runner/operation.rs index 7269e1546..41d1910c4 100644 --- a/src/test/spec/unified_runner/operation.rs +++ b/src/test/spec/unified_runner/operation.rs @@ -3,6 +3,8 @@ mod csfle; #[cfg(feature = "in-use-encryption-unstable")] use self::csfle::*; +mod search_index; + use std::{ collections::HashMap, convert::TryInto, @@ -383,6 +385,21 @@ impl<'de> Deserialize<'de> for Operation { #[cfg(feature = "in-use-encryption-unstable")] "removeKeyAltName" => deserialize_op::(definition.arguments), "iterateOnce" => deserialize_op::(definition.arguments), + "createSearchIndex" => { + deserialize_op::(definition.arguments) + } + "createSearchIndexes" => { + deserialize_op::(definition.arguments) + } + "dropSearchIndex" => { + deserialize_op::(definition.arguments) + } + "listSearchIndexes" => { + deserialize_op::(definition.arguments) + } + "updateSearchIndex" => { + deserialize_op::(definition.arguments) + } s => Ok(Box::new(UnimplementedOperation { _name: s.to_string(), }) as Box), diff --git a/src/test/spec/unified_runner/operation/search_index.rs b/src/test/spec/unified_runner/operation/search_index.rs new file mode 100644 index 000000000..461d53df5 --- /dev/null +++ b/src/test/spec/unified_runner/operation/search_index.rs @@ -0,0 +1,151 @@ +use bson::{to_bson, Bson, Document}; +use futures_core::future::BoxFuture; +use futures_util::{FutureExt, TryStreamExt}; +use serde::Deserialize; + +use crate::{ + coll::options::AggregateOptions, + error::Result, + search_index::options::{ + CreateSearchIndexOptions, + DropSearchIndexOptions, + ListSearchIndexOptions, + UpdateSearchIndexOptions, + }, + test::spec::unified_runner::{Entity, TestRunner}, + SearchIndexModel, +}; + +use super::TestOperation; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct CreateSearchIndex { + model: SearchIndexModel, + #[serde(flatten)] + options: CreateSearchIndexOptions, +} + +impl TestOperation for CreateSearchIndex { + fn execute_entity_operation<'a>( + &'a self, + id: &'a str, + test_runner: &'a TestRunner, + ) -> BoxFuture<'a, Result>> { + async move { + let collection = test_runner.get_collection(id).await; + let name = collection + .create_search_index(self.model.clone(), self.options.clone()) + .await?; + Ok(Some(Bson::String(name).into())) + } + .boxed() + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct CreateSearchIndexes { + models: Vec, + #[serde(flatten)] + options: CreateSearchIndexOptions, +} + +impl TestOperation for CreateSearchIndexes { + fn execute_entity_operation<'a>( + &'a self, + id: &'a str, + test_runner: &'a TestRunner, + ) -> BoxFuture<'a, Result>> { + async move { + let collection = test_runner.get_collection(id).await; + let names = collection + .create_search_indexes(self.models.clone(), self.options.clone()) + .await?; + Ok(Some(to_bson(&names)?.into())) + } + .boxed() + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct DropSearchIndex { + name: String, + #[serde(flatten)] + options: DropSearchIndexOptions, +} + +impl TestOperation for DropSearchIndex { + fn execute_entity_operation<'a>( + &'a self, + id: &'a str, + test_runner: &'a TestRunner, + ) -> BoxFuture<'a, Result>> { + async move { + let collection = test_runner.get_collection(id).await; + collection + .drop_search_index(&self.name, self.options.clone()) + .await?; + Ok(None) + } + .boxed() + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct ListSearchIndexes { + name: Option, + aggregation_options: Option, + #[serde(flatten)] + options: ListSearchIndexOptions, +} + +impl TestOperation for ListSearchIndexes { + fn execute_entity_operation<'a>( + &'a self, + id: &'a str, + test_runner: &'a TestRunner, + ) -> BoxFuture<'a, Result>> { + async move { + let collection = test_runner.get_collection(id).await; + let cursor = collection + .list_search_indexes( + self.name.as_deref(), + self.aggregation_options.clone(), + self.options.clone(), + ) + .await?; + let values: Vec<_> = cursor.try_collect().await?; + Ok(Some(to_bson(&values)?.into())) + } + .boxed() + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct UpdateSearchIndex { + name: String, + definition: Document, + #[serde(flatten)] + options: UpdateSearchIndexOptions, +} + +impl TestOperation for UpdateSearchIndex { + fn execute_entity_operation<'a>( + &'a self, + id: &'a str, + test_runner: &'a TestRunner, + ) -> BoxFuture<'a, Result>> { + async move { + let collection = test_runner.get_collection(id).await; + collection + .update_search_index(&self.name, self.definition.clone(), self.options.clone()) + .await?; + Ok(None) + } + .boxed() + } +}