From ee279c05dec064c1d76838c5201ef5fbd27bb54f Mon Sep 17 00:00:00 2001 From: Arthur Vickers Date: Thu, 16 Oct 2025 14:33:20 +0100 Subject: [PATCH 1/4] EF-273: Pure refactoring (no behavior change) moving code out of MongoClientWrapper --- .../MongoDatabaseFacadeExtensions.cs | 40 +- .../Storage/IMongoClientWrapper.cs | 157 +--- .../Storage/IMongoDatabaseCreator.cs | 76 ++ .../Storage/MongoClientWrapper.cs | 597 +------------- .../Storage/MongoDatabaseCreator.cs | 561 ++++++++++++- .../Storage/MongoClientWrapperTests.cs | 748 ------------------ .../Storage/MongoDatabaseCreatorTests.cs | 722 +++++++++++++++++ 7 files changed, 1395 insertions(+), 1506 deletions(-) delete mode 100644 tests/MongoDB.EntityFrameworkCore.FunctionalTests/Storage/MongoClientWrapperTests.cs diff --git a/src/MongoDB.EntityFrameworkCore/Extensions/MongoDatabaseFacadeExtensions.cs b/src/MongoDB.EntityFrameworkCore/Extensions/MongoDatabaseFacadeExtensions.cs index 2aa17a63..617a6536 100644 --- a/src/MongoDB.EntityFrameworkCore/Extensions/MongoDatabaseFacadeExtensions.cs +++ b/src/MongoDB.EntityFrameworkCore/Extensions/MongoDatabaseFacadeExtensions.cs @@ -42,7 +42,7 @@ public static void CreateIndex(this DatabaseFacade databaseFacade, IIndex index) { ArgumentNullException.ThrowIfNull(index); - ((IDatabaseFacadeDependenciesAccessor)databaseFacade).Context.GetService().CreateIndex(index); + GetDatabaseCreator(databaseFacade).CreateIndex(index); } /// @@ -57,8 +57,7 @@ public static Task CreateIndexAsync(this DatabaseFacade databaseFacade, IIndex i { ArgumentNullException.ThrowIfNull(index); - return ((IDatabaseFacadeDependenciesAccessor)databaseFacade).Context.GetService() - .CreateIndexAsync(index, cancellationToken); + return GetDatabaseCreator(databaseFacade).CreateIndexAsync(index, cancellationToken); } /// @@ -67,10 +66,7 @@ public static Task CreateIndexAsync(this DatabaseFacade databaseFacade, IIndex i /// /// The from the EF Core . public static void CreateMissingIndexes(this DatabaseFacade databaseFacade) - { - var context = ((IDatabaseFacadeDependenciesAccessor)databaseFacade).Context; - context.GetService().CreateMissingIndexes(context.GetService().Model); - } + => GetDatabaseCreator(databaseFacade).CreateMissingIndexes(); /// /// Creates missing Atlas vector indexes in the MongoDB database for all definitions in the EF Core model for @@ -78,10 +74,7 @@ public static void CreateMissingIndexes(this DatabaseFacade databaseFacade) /// /// The from the EF Core . public static void CreateMissingVectorIndexes(this DatabaseFacade databaseFacade) - { - var context = ((IDatabaseFacadeDependenciesAccessor)databaseFacade).Context; - context.GetService().CreateMissingVectorIndexes(context.GetService().Model); - } + => GetDatabaseCreator(databaseFacade).CreateMissingVectorIndexes(); /// /// Creates indexes in the MongoDB database for all definitions in the EF Core model for which there @@ -91,10 +84,7 @@ public static void CreateMissingVectorIndexes(this DatabaseFacade databaseFacade /// A that can be used to cancel this asynchronous request. /// A to track this async operation. public static Task CreateMissingIndexesAsync(this DatabaseFacade databaseFacade, CancellationToken cancellationToken = default) - { - var context = ((IDatabaseFacadeDependenciesAccessor)databaseFacade).Context; - return context.GetService().CreateMissingIndexesAsync(context.GetService().Model, cancellationToken); - } + => GetDatabaseCreator(databaseFacade).CreateMissingIndexesAsync(cancellationToken); /// /// Creates missing Atlas vector indexes in the MongoDB database for all definitions in the EF Core model for @@ -104,10 +94,7 @@ public static Task CreateMissingIndexesAsync(this DatabaseFacade databaseFacade, /// A that can be used to cancel this asynchronous request. /// A to track this async operation. public static Task CreateMissingVectorIndexesAsync(this DatabaseFacade databaseFacade, CancellationToken cancellationToken = default) - { - var context = ((IDatabaseFacadeDependenciesAccessor)databaseFacade).Context; - return context.GetService().CreateMissingVectorIndexesAsync(context.GetService().Model, cancellationToken); - } + => GetDatabaseCreator(databaseFacade).CreateMissingVectorIndexesAsync(cancellationToken); /// /// Blocks until all vector indexes in the mapped collections are reporting the 'READY' state. @@ -117,10 +104,7 @@ public static Task CreateMissingVectorIndexesAsync(this DatabaseFacade databaseF /// The default is 15 seconds. Zero seconds means no timeout. /// if the timeout expires before all indexes are 'READY'. public static void WaitForVectorIndexes(this DatabaseFacade databaseFacade, TimeSpan? timeout = null) - { - var context = ((IDatabaseFacadeDependenciesAccessor)databaseFacade).Context; - context.GetService().WaitForVectorIndexes(context.GetService().Model, timeout); - } + => GetDatabaseCreator(databaseFacade).WaitForVectorIndexes(timeout); /// /// Blocks until all vector indexes in the mapped collections are reporting the 'READY' state. @@ -132,10 +116,7 @@ public static void WaitForVectorIndexes(this DatabaseFacade databaseFacade, Time /// A to track this async operation. /// if the timeout expires before all indexes are 'READY'. public static Task WaitForVectorIndexesAsync(this DatabaseFacade databaseFacade, TimeSpan? timeout = null, CancellationToken cancellationToken = default) - { - var context = ((IDatabaseFacadeDependenciesAccessor)databaseFacade).Context; - return context.GetService().WaitForVectorIndexesAsync(context.GetService().Model, timeout, cancellationToken); - } + => GetDatabaseCreator(databaseFacade).WaitForVectorIndexesAsync(timeout, cancellationToken); /// /// Ensures that the database for the context exists. If it exists, no action is taken. If it does not @@ -146,7 +127,7 @@ public static Task WaitForVectorIndexesAsync(this DatabaseFacade databaseFacade, /// An object specifying additional actions to be taken. /// if the database is created, if it already existed. public static bool EnsureCreated(this DatabaseFacade databaseFacade, MongoDatabaseCreationOptions options) - => ((IDatabaseFacadeDependenciesAccessor)databaseFacade).Context.GetService().EnsureCreated(options); + => GetDatabaseCreator(databaseFacade).EnsureCreated(options); /// /// Asynchronously ensures that the database for the context exists. If it exists, no action is taken. If it does not @@ -188,4 +169,7 @@ public static Task BeginTransactionAsync( private static IMongoTransactionManager GetMongoTransactionManager(DatabaseFacade databaseFacade) => (IMongoTransactionManager)((IDatabaseFacadeDependenciesAccessor)databaseFacade).Dependencies.TransactionManager; + + private static IMongoDatabaseCreator GetDatabaseCreator(DatabaseFacade databaseFacade) + => ((IDatabaseFacadeDependenciesAccessor)databaseFacade).Context.GetService(); } diff --git a/src/MongoDB.EntityFrameworkCore/Storage/IMongoClientWrapper.cs b/src/MongoDB.EntityFrameworkCore/Storage/IMongoClientWrapper.cs index fd28a10f..baa6a771 100644 --- a/src/MongoDB.EntityFrameworkCore/Storage/IMongoClientWrapper.cs +++ b/src/MongoDB.EntityFrameworkCore/Storage/IMongoClientWrapper.cs @@ -17,9 +17,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore.Metadata; using MongoDB.Driver; -using MongoDB.EntityFrameworkCore.Metadata; using MongoDB.EntityFrameworkCore.Query; namespace MongoDB.EntityFrameworkCore.Storage; @@ -31,6 +29,21 @@ namespace MongoDB.EntityFrameworkCore.Storage; /// public interface IMongoClientWrapper { + /// + /// The underlying . May cause the underlying client to be created. + /// + public IMongoClient Client { get; } + + /// + /// The underlying . May cause the underlying client to be created. + /// + public IMongoDatabase Database { get; } + + /// + /// Gets the name of the underlying . May cause the underlying client to be created. + /// + public string DatabaseName { get; } + /// /// Get an for the given . /// @@ -49,146 +62,6 @@ public interface IMongoClientWrapper /// An containing the items returned by the query. IEnumerable Execute(MongoExecutableQuery executableQuery, out Action log); - /// - /// Create a new database with the name specified in the connection options. - /// - /// If the database already exists only new collections will be created. - /// The that informs how the database should be created. - /// if the database was created from scratch, if it already existed. - bool CreateDatabase(IDesignTimeModel model); - - /// - /// Create a new database with the name specified in the connection options. - /// - /// If the database already exists only new collections will be created. - /// The that informs how the database should be created. - /// An object specifying additional actions to be taken. - /// A delegate called to seed the database before any Atlas indexes are created. - /// if the database was created from scratch, if it already existed. - bool CreateDatabase(IDesignTimeModel model, MongoDatabaseCreationOptions options, Action? seed); - - /// - /// Create a new database with the name specified in the connection options asynchronously. - /// - /// If the database already exists only new collections will be created. - /// The that informs how the database should be created. - /// A that can be used to cancel this asynchronous request. - /// - /// A that, when resolved, will be - /// if the database was created from scratch, if it already existed. - /// - Task CreateDatabaseAsync(IDesignTimeModel model, CancellationToken cancellationToken = default); - - /// - /// Create a new database with the name specified in the connection options asynchronously. - /// - /// If the database already exists only new collections will be created. - /// The that informs how the database should be created. - /// An object specifying additional actions to be taken. - /// A delegate called to seed the database before any Atlas indexes are created. - /// A that can be used to cancel this asynchronous request. - /// - /// A that, when resolved, will be - /// if the database was created from scratch, if it already existed. - /// - Task CreateDatabaseAsync(IDesignTimeModel model, MongoDatabaseCreationOptions options, Func? seedAsync, CancellationToken cancellationToken = default); - - /// - /// Delete the database specified in the connection options. - /// - /// if the database was deleted, if it did not exist. - bool DeleteDatabase(); - - /// - /// Delete the database specified in the connection options asynchronously. - /// - /// A that can be used to cancel this asynchronous request. - /// - /// A that, when resolved, will be - /// if the database was deleted, if it already existed. - /// - Task DeleteDatabaseAsync(CancellationToken cancellationToken = default); - - /// - /// Determine if the database already exists or not. - /// - /// if the database exists, if it does not. - bool DatabaseExists(); - - /// - /// Determine if the database already exists or not asynchronously. - /// - /// A that can be used to cancel this asynchronous request. - /// - /// A that, when resolved, will be - /// if the database exists, if it does not. - /// - Task DatabaseExistsAsync(CancellationToken cancellationToken = default); - - /// - /// Creates an index in MongoDB based on the EF Core definition. No attempt is made to check that the index - /// does not already exist and can therefore be created. The index may be an Atlas index or a normal MongoDB index. - /// - /// The definition. - void CreateIndex(IIndex index); - - /// - /// Creates an index in MongoDB based on the EF Core definition. No attempt is made to check that the index - /// does not already exist and can therefore be created. The index may be an Atlas index or a normal MongoDB index. - /// - /// The definition. - /// A that can be used to cancel this asynchronous request. - /// A to track this async operation. - Task CreateIndexAsync(IIndex index, CancellationToken cancellationToken = default); - - /// - /// Creates any non-Atlas MongoDB indexes defined in the EF Core model that do not already exist. - /// - /// The EF Core . - void CreateMissingIndexes(IModel model); - - /// - /// Creates any non-Atlas MongoDB indexes defined in the EF Core model that do not already exist. - /// - /// The EF Core . - /// A that can be used to cancel this asynchronous request. - /// A to track this async operation. - Task CreateMissingIndexesAsync(IModel model, CancellationToken cancellationToken = default); - - /// - /// Creates any MongoDB Atlas vector indexes defined in the EF Core model that do not already exist. - /// - /// The EF Core . - void CreateMissingVectorIndexes(IModel model); - - /// - /// Creates any MongoDB Atlas vector indexes defined in the EF Core model that do not already exist. - /// - /// The EF Core . - /// A that can be used to cancel this asynchronous request. - /// A to track this async operation. - Task CreateMissingVectorIndexesAsync(IModel model, CancellationToken cancellationToken = default); - - /// - /// Blocks until all vector indexes in the mapped collections are reporting the 'READY' state. - /// - /// The EF Core - /// The minimum amount of time to wait for all indexes to be 'READY' before aborting. - /// The default is 15 seconds. Zero seconds means no timeout. - /// if the timeout expires before all indexes are 'READY'. - void WaitForVectorIndexes(IModel model, TimeSpan? timeout = null); - - /// - /// Blocks until all vector indexes in the mapped collections are reporting the 'READY' state. - /// - /// The EF Core - /// The minimum amount of time to wait for all indexes to be 'READY' before aborting. - /// The default is 15 seconds. Zero seconds means no timeout. - /// A that can be used to cancel this asynchronous request. - /// A to track this async operation. - /// if the timeout expires before all indexes are 'READY'. - Task WaitForVectorIndexesAsync(IModel model, TimeSpan? timeout = null, CancellationToken cancellationToken = default); - /// /// Start a new client session. /// diff --git a/src/MongoDB.EntityFrameworkCore/Storage/IMongoDatabaseCreator.cs b/src/MongoDB.EntityFrameworkCore/Storage/IMongoDatabaseCreator.cs index fd1c2851..24ed4851 100644 --- a/src/MongoDB.EntityFrameworkCore/Storage/IMongoDatabaseCreator.cs +++ b/src/MongoDB.EntityFrameworkCore/Storage/IMongoDatabaseCreator.cs @@ -13,8 +13,10 @@ * limitations under the License. */ +using System; using System.Threading; using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Storage; using MongoDB.EntityFrameworkCore.Metadata; @@ -44,4 +46,78 @@ public interface IMongoDatabaseCreator : IDatabaseCreator /// A task that represents the asynchronous save operation. The task result contains if the database is created, if it already existed. /// Task EnsureCreatedAsync(MongoDatabaseCreationOptions options, CancellationToken cancellationToken = default); + + /// + /// Determine if the database already exists or not. + /// + /// if the database exists, if it does not. + bool DatabaseExists(); + + /// + /// Determine if the database already exists or not asynchronously. + /// + /// A that can be used to cancel this asynchronous request. + /// + /// A that, when resolved, will be + /// if the database exists, if it does not. + /// + Task DatabaseExistsAsync(CancellationToken cancellationToken = default); + + /// + /// Creates an index in MongoDB based on the EF Core definition. No attempt is made to check that the index + /// does not already exist and can therefore be created. The index may be an Atlas index or a normal MongoDB index. + /// + /// The definition. + void CreateIndex(IIndex index); + + /// + /// Creates an index in MongoDB based on the EF Core definition. No attempt is made to check that the index + /// does not already exist and can therefore be created. The index may be an Atlas index or a normal MongoDB index. + /// + /// The definition. + /// A that can be used to cancel this asynchronous request. + /// A to track this async operation. + Task CreateIndexAsync(IIndex index, CancellationToken cancellationToken = default); + + /// + /// Creates any non-Atlas MongoDB indexes defined in the EF Core model that do not already exist. + /// + void CreateMissingIndexes(); + + /// + /// Creates any non-Atlas MongoDB indexes defined in the EF Core model that do not already exist. + /// + /// A that can be used to cancel this asynchronous request. + /// A to track this async operation. + Task CreateMissingIndexesAsync(CancellationToken cancellationToken = default); + + /// + /// Creates any MongoDB Atlas vector indexes defined in the EF Core model that do not already exist. + /// + void CreateMissingVectorIndexes(); + + /// + /// Creates any MongoDB Atlas vector indexes defined in the EF Core model that do not already exist. + /// + /// A that can be used to cancel this asynchronous request. + /// A to track this async operation. + Task CreateMissingVectorIndexesAsync(CancellationToken cancellationToken = default); + + /// + /// Blocks until all vector indexes in the mapped collections are reporting the 'READY' state. + /// + /// The minimum amount of time to wait for all indexes to be 'READY' before aborting. + /// The default is 15 seconds. Zero seconds means no timeout. + /// if the timeout expires before all indexes are 'READY'. + void WaitForVectorIndexes(TimeSpan? timeout = null); + + /// + /// Blocks until all vector indexes in the mapped collections are reporting the 'READY' state. + /// + /// The minimum amount of time to wait for all indexes to be 'READY' before aborting. + /// The default is 15 seconds. Zero seconds means no timeout. + /// A that can be used to cancel this asynchronous request. + /// A to track this async operation. + /// if the timeout expires before all indexes are 'READY'. + Task WaitForVectorIndexesAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default); } diff --git a/src/MongoDB.EntityFrameworkCore/Storage/MongoClientWrapper.cs b/src/MongoDB.EntityFrameworkCore/Storage/MongoClientWrapper.cs index 0bce6248..4a18f18d 100644 --- a/src/MongoDB.EntityFrameworkCore/Storage/MongoClientWrapper.cs +++ b/src/MongoDB.EntityFrameworkCore/Storage/MongoClientWrapper.cs @@ -15,22 +15,15 @@ using System; using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Metadata.Internal; using Microsoft.EntityFrameworkCore.Query; -using MongoDB.Bson; using MongoDB.Driver; using MongoDB.EntityFrameworkCore.Diagnostics; -using MongoDB.EntityFrameworkCore.Extensions; using MongoDB.EntityFrameworkCore.Infrastructure; -using MongoDB.EntityFrameworkCore.Metadata; using MongoDB.EntityFrameworkCore.Query; namespace MongoDB.EntityFrameworkCore.Storage; @@ -49,10 +42,6 @@ public class MongoClientWrapper : IMongoClientWrapper private IMongoClient? _client; private IMongoDatabase? _database; private string? _databaseName; - private bool _useDatabaseNameFilter = true; - - private IMongoClient Client => _client ??= GetOrCreateMongoClient(_options, _serviceProvider); - private IMongoDatabase Database => _database ??= Client.GetDatabase(_databaseName); /// /// Create a new instance of with the supplied parameters. @@ -74,595 +63,49 @@ public MongoClientWrapper( } /// - public IEnumerable Execute(MongoExecutableQuery executableQuery, out Action log) - { - log = () => { }; - - if (executableQuery.Cardinality != ResultCardinality.Enumerable) - return ExecuteScalar(executableQuery); - - var queryable = executableQuery.Provider.CreateQuery(executableQuery.Query); - log = () => _commandLogger.ExecutedMqlQuery(executableQuery); - return queryable; - } - - /// - public IMongoCollection GetCollection(string collectionName) - => Database.GetCollection(collectionName); - - /// - public IClientSessionHandle StartSession() - => Client.StartSession(); + public IMongoClient Client => _client ??= GetOrCreateMongoClient(_options, _serviceProvider); /// - public async Task StartSessionAsync(CancellationToken cancellationToken = default) - => await Client.StartSessionAsync(null, cancellationToken).ConfigureAwait(false); + public IMongoDatabase Database => _database ??= Client.GetDatabase(_databaseName); /// - public bool CreateDatabase(IDesignTimeModel model) - => CreateDatabase(model, new(), null); - - /// - public bool CreateDatabase(IDesignTimeModel model, MongoDatabaseCreationOptions options, Action? seed) + public string DatabaseName { - var existed = DatabaseExists(); - - if (options.CreateMissingCollections) + get { - using var collectionNamesCursor = Database.ListCollectionNames(); - var collectionNames = collectionNamesCursor.ToList(); - - foreach (var entityType in model.Model.GetEntityTypes().Where(e => e.IsDocumentRoot())) + if (_databaseName is null) { - var collectionName = entityType.GetCollectionName(); - if (!collectionNames.Contains(collectionName)) - { - collectionNames.Add(collectionName); - try - { - Database.CreateCollection(collectionName); - } - catch (MongoCommandException ex) when (ex.Message.Contains("already exists")) - { - } - } + _ = Client; } - } - - if (!existed) - { - seed?.Invoke(); - } - if (options.CreateMissingIndexes) - { - CreateMissingIndexes(model.Model); - } - - if (options.CreateMissingVectorIndexes) - { - CreateMissingVectorIndexes(model.Model); + return _databaseName!; } - - if (options.WaitForVectorIndexes) - { - WaitForVectorIndexes(model.Model, options.IndexCreationTimeout); - } - - return !existed; } /// - public Task CreateDatabaseAsync(IDesignTimeModel model, CancellationToken cancellationToken = default) - => CreateDatabaseAsync(model, new(), null, cancellationToken); - - /// - public async Task CreateDatabaseAsync( - IDesignTimeModel model, - MongoDatabaseCreationOptions options, - Func? seedAsync, CancellationToken cancellationToken = default) - { - var existed = await DatabaseExistsAsync(cancellationToken).ConfigureAwait(false); - - if (options.CreateMissingCollections) - { - using var collectionNamesCursor = - await Database.ListCollectionNamesAsync(null, cancellationToken).ConfigureAwait(false); - var collectionNames = await collectionNamesCursor.ToListAsync(cancellationToken); - - foreach (var entityType in model.Model.GetEntityTypes().Where(e => e.IsDocumentRoot())) - { - var collectionName = entityType.GetCollectionName(); - if (!collectionNames.Contains(collectionName)) - { - collectionNames.Add(collectionName); - try - { - await Database.CreateCollectionAsync(collectionName, null, cancellationToken).ConfigureAwait(false); - } - catch (MongoCommandException ex) when (ex.Message.Contains("already exists")) - { - } - } - } - } - - if (!existed && seedAsync != null) - { - await seedAsync(cancellationToken).ConfigureAwait(false); - } - - if (options.CreateMissingIndexes) - { - await CreateMissingIndexesAsync(model.Model, cancellationToken).ConfigureAwait(false); - } - - if (options.CreateMissingVectorIndexes) - { - await CreateMissingVectorIndexesAsync(model.Model, cancellationToken).ConfigureAwait(false); - } - - if (options.WaitForVectorIndexes) - { - await WaitForVectorIndexesAsync(model.Model, options.IndexCreationTimeout, cancellationToken).ConfigureAwait(false); - } - - return !existed; - } - - /// - public void CreateMissingIndexes(IModel model) - { - var collectionNames = new List(); - var existingIndexesMap = new Dictionary?>(); - var indexModelsMap = new Dictionary>?>(); - - foreach (var entityType in model.GetEntityTypes().Where(e => e.IsDocumentRoot())) - { - var collectionName = entityType.GetCollectionName(); - if (!collectionNames.Contains(collectionName, StringComparer.Ordinal)) - { - collectionNames.Add(collectionName); - } - - if (!existingIndexesMap.TryGetValue(collectionName, out var indexes)) - { - using var cursor = Database.GetCollection(collectionName).Indexes.List(); - indexes = cursor.ToList().Select(i => i["name"].AsString).ToList(); - existingIndexesMap[collectionName] = indexes; - } - - BuildIndexes(model, entityType, collectionName, existingIndexesMap, indexModelsMap); - } - - foreach (var collectionName in collectionNames) - { - if (indexModelsMap.TryGetValue(collectionName, out var indexModels) && indexModels!.Count > 0) - { - var indexManager = Database.GetCollection(collectionName).Indexes; - indexManager.CreateMany(indexModels); - } - } - } - - /// - public void CreateMissingVectorIndexes(IModel model) - { - var collectionNames = new List(); - var existingIndexesMap = new Dictionary?>(); - var indexModelsMap = new Dictionary?>(); - - foreach (var entityType in model.GetEntityTypes().Where(e => e.IsDocumentRoot())) - { - // Don't try to access Atlas-specific features unless an Atlas vector index is defined. - if (!HasAtlasIndexes(entityType)) - { - continue; - } - - var collectionName = entityType.GetCollectionName(); - if (!collectionNames.Contains(collectionName, StringComparer.Ordinal)) - { - collectionNames.Add(collectionName); - } - - if (!existingIndexesMap.TryGetValue(collectionName, out var indexes)) - { - using var cursor = Database.GetCollection(collectionName).SearchIndexes.List(); - indexes = cursor.ToList().Select(i => i["name"].AsString).ToList(); - existingIndexesMap[collectionName] = indexes; - } - - BuildVectorIndexes(model, entityType, collectionName, existingIndexesMap, indexModelsMap); - } - - foreach (var collectionName in collectionNames) - { - if (indexModelsMap.TryGetValue(collectionName, out var indexModels) && indexModels!.Count > 0) - { - var searchIndexManager = Database.GetCollection(collectionName).SearchIndexes; - searchIndexManager.CreateMany(indexModels); - } - } - } - - private static bool HasAtlasIndexes(IEntityType entityType) - { - if (entityType.GetIndexes().Any(i => i.GetVectorIndexOptions().HasValue)) - { - return true; - } - - foreach (var ownedEntityType in entityType.Model.GetEntityTypes() - .Where(o => o.FindDeclaredOwnership()?.PrincipalEntityType == entityType)) - { - return HasAtlasIndexes(ownedEntityType); - } - - return false; - } - - /// - public void CreateIndex(IIndex index) - { - var collectionName = index.DeclaringEntityType.GetCollectionName(); - var vectorIndexOptions = index.GetVectorIndexOptions(); - - _ = vectorIndexOptions.HasValue - ? Database.GetCollection(collectionName).SearchIndexes - .CreateOne(index.CreateVectorIndexDocument(vectorIndexOptions.Value)) - : Database.GetCollection(collectionName).Indexes - .CreateOne(index.CreateIndexDocument()); - } - - /// - public async Task CreateIndexAsync(IIndex index, CancellationToken cancellationToken = default) - { - var collectionName = index.DeclaringEntityType.GetCollectionName(); - var vectorIndexOptions = index.GetVectorIndexOptions(); - - _ = vectorIndexOptions.HasValue - ? await Database.GetCollection(collectionName).SearchIndexes - .CreateOneAsync(index.CreateVectorIndexDocument(vectorIndexOptions.Value), cancellationToken) - : await Database.GetCollection(collectionName).Indexes - .CreateOneAsync(index.CreateIndexDocument(), cancellationToken: cancellationToken); - } - - /// - public void WaitForVectorIndexes(IModel model, TimeSpan? timeout = null) - { - // Don't try to access Atlas-specific features unless an Atlas vector index is defined. - if (model.GetEntityTypes().All(e => !HasAtlasIndexes(e))) - { - return; - } - - var failAfter = CalculateTimeoutDateTime(timeout); - - foreach (var collectionName in model.GetCollectionNames()) - { - var delay = 1; - bool isReady; - do - { - isReady = true; - using var cursor = Database.GetCollection(collectionName).SearchIndexes.List(); - - foreach (var indexModel in cursor.ToList()) - { - var status = indexModel["status"].AsString; - - if (status == "FAILED") - { - throw new InvalidOperationException( - $"Failed to build the vector index '{indexModel["name"]}' for path '{indexModel["latestDefinition"]["fields"][0]["path"]}'."); - } - - if (status != "READY") - { - isReady = false; - Thread.Sleep(delay *= 2); - break; - } - } - - if (!isReady && DateTime.UtcNow >= failAfter) - { - throw new InvalidOperationException( - "Index creation timed out. Please create indexes using MongoDB Compass or the mongosh shell."); - } - } while (!isReady); - } - } - - private static DateTimeOffset CalculateTimeoutDateTime(TimeSpan? timeout) - { - timeout ??= TimeSpan.FromSeconds(15); - var failAfter = timeout.Value == TimeSpan.Zero - ? DateTime.MaxValue - : DateTimeOffset.UtcNow.Add(timeout.Value); - return failAfter; - } - - /// - public async Task CreateMissingIndexesAsync(IModel model, CancellationToken cancellationToken = default) - { - var collectionNames = new List(); - var existingIndexesMap = new Dictionary?>(); - var indexModelsMap = new Dictionary>?>(); - - foreach (var entityType in model.GetEntityTypes().Where(e => e.IsDocumentRoot())) - { - var collectionName = entityType.GetCollectionName(); - if (!collectionNames.Contains(collectionName, StringComparer.Ordinal)) - { - collectionNames.Add(collectionName); - } - - if (!existingIndexesMap.TryGetValue(collectionName, out var indexes)) - { - using var cursor = await Database.GetCollection(collectionName).Indexes.ListAsync(cancellationToken) - .ConfigureAwait(false); - indexes = (await cursor.ToListAsync(cancellationToken: cancellationToken).ConfigureAwait(false)) - .Select(i => i["name"].AsString).ToList(); - existingIndexesMap[collectionName] = indexes; - } - - BuildIndexes(model, entityType, collectionName, existingIndexesMap, indexModelsMap); - } - - foreach (var collectionName in collectionNames) - { - if (indexModelsMap.TryGetValue(collectionName, out var indexModels) && indexModels!.Count > 0) - { - var indexManager = Database.GetCollection(collectionName).Indexes; - await indexManager.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false); - } - } - } - - /// - public async Task CreateMissingVectorIndexesAsync(IModel model, CancellationToken cancellationToken = default) + public IEnumerable Execute(MongoExecutableQuery executableQuery, out Action log) { - var collectionNames = new List(); - var existingIndexesMap = new Dictionary?>(); - var indexModelsMap = new Dictionary?>(); - - foreach (var entityType in model.GetEntityTypes().Where(e => e.IsDocumentRoot())) - { - // Don't try to access Atlas-specific features unless an Atlas vector index is defined. - if (!HasAtlasIndexes(entityType)) - { - continue; - } - - var collectionName = entityType.GetCollectionName(); - if (!collectionNames.Contains(collectionName, StringComparer.Ordinal)) - { - collectionNames.Add(collectionName); - } - - if (!existingIndexesMap.TryGetValue(collectionName, out var indexes)) - { - using var cursor = await Database.GetCollection(collectionName).SearchIndexes - .ListAsync(cancellationToken: cancellationToken).ConfigureAwait(false); - indexes = (await cursor.ToListAsync(cancellationToken: cancellationToken).ConfigureAwait(false)) - .Select(i => i["name"].AsString).ToList(); - existingIndexesMap[collectionName] = indexes; - } + log = () => { }; - BuildVectorIndexes(model, entityType, collectionName, existingIndexesMap, indexModelsMap); - } + if (executableQuery.Cardinality != ResultCardinality.Enumerable) + return ExecuteScalar(executableQuery); - foreach (var collectionName in collectionNames) - { - if (indexModelsMap.TryGetValue(collectionName, out var indexModels) && indexModels!.Count > 0) - { - var searchIndexManager = Database.GetCollection(collectionName).SearchIndexes; - await searchIndexManager.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false); - } - } + var queryable = executableQuery.Provider.CreateQuery(executableQuery.Query); + log = () => _commandLogger.ExecutedMqlQuery(executableQuery); + return queryable; } /// - public async Task WaitForVectorIndexesAsync( - IModel model, - TimeSpan? timeout = null, - CancellationToken cancellationToken = default) - { - // Don't try to access Atlas-specific features unless an Atlas vector index is defined. - if (model.GetEntityTypes().All(e => !HasAtlasIndexes(e))) - { - return; - } - - var failAfter = CalculateTimeoutDateTime(timeout); - foreach (var collectionName in model.GetCollectionNames()) - { - var delay = 1; - bool isReady; - do - { - isReady = true; - using var cursor = await Database.GetCollection(collectionName).SearchIndexes - .ListAsync(cancellationToken: cancellationToken).ConfigureAwait(false); - var indexModels = await cursor.ToListAsync(cancellationToken: cancellationToken).ConfigureAwait(false); - - foreach (var indexModel in indexModels) - { - var status = indexModel["status"].AsString; - - if (status == "FAILED") - { - throw new InvalidOperationException( - $"Failed to build the vector index '{indexModel["name"]}' for path '{indexModel["latestDefinition"]["fields"][0]["path"]}'."); - } - - if (status != "READY") - { - isReady = false; - await Task.Delay(delay *= 2, cancellationToken).ConfigureAwait(false); - break; - } - } - - if (!isReady && DateTime.UtcNow >= failAfter) - { - throw new InvalidOperationException( - "Index creation timed out. Please create indexes using MongoDB Compass or the mongosh shell."); - } - } while (!isReady); - } - } - - private void BuildIndexes( - IModel model, - IEntityType entityType, - string collectionName, - Dictionary?> existingIndexesMap, - Dictionary>?> indexModelsMap) - { - var existingIndexes = existingIndexesMap[collectionName]!; - - if (!indexModelsMap.TryGetValue(collectionName, out var indexModels)) - { - indexModels = []; - indexModelsMap[collectionName] = indexModels; - } - - foreach (var index in entityType.GetIndexes().Where(i => !i.GetVectorIndexOptions().HasValue)) - { - var name = index.Name; - Debug.Assert(name != null, "Index name should have been set by IndexNamingConvention."); - - if (!existingIndexes.Contains(name)) - { - existingIndexes.Add(name); - indexModels!.Add(index.CreateIndexDocument()); - } - } - - foreach (var key in entityType.GetKeys().Where(k => !k.IsPrimaryKey())) - { - var name = key.MakeIndexName(); - if (!existingIndexes.Contains(name)) - { - existingIndexes.Add(name); - indexModels!.Add(key.CreateKeyIndexDocument(name)); - } - } - - var ownedEntityTypes = model.GetEntityTypes().Where(o => o.FindDeclaredOwnership()?.PrincipalEntityType == entityType); - - foreach (var ownedEntityType in ownedEntityTypes) - { - BuildIndexes(model, ownedEntityType, collectionName, existingIndexesMap, indexModelsMap); - } - } - - private void BuildVectorIndexes( - IModel model, - IEntityType entityType, - string collectionName, - Dictionary?> existingIndexesMap, - Dictionary?> indexModelsMap) - { - if (!existingIndexesMap.TryGetValue(collectionName, out var existingIndexes)) - { - existingIndexesMap[collectionName] = existingIndexes = new List(); - } - - if (!indexModelsMap.TryGetValue(collectionName, out var indexModels)) - { - indexModels = []; - indexModelsMap[collectionName] = indexModels; - } - - foreach (var index in entityType.GetIndexes().Where(i => i.GetVectorIndexOptions().HasValue)) - { - var name = index.Name; - Debug.Assert(name != null, "Index name should have been set by IndexNamingConvention."); - - var options = index.GetVectorIndexOptions()!.Value; - if (!existingIndexes!.Contains(name)) - { - indexModels!.Add(index.CreateVectorIndexDocument(options)); - existingIndexes.Add(name); - } - } - - var ownedEntityTypes = model.GetEntityTypes().Where(o => o.FindDeclaredOwnership()?.PrincipalEntityType == entityType); - - foreach (var ownedEntityType in ownedEntityTypes) - { - BuildVectorIndexes(model, ownedEntityType, collectionName, existingIndexesMap, indexModelsMap); - } - } + public IMongoCollection GetCollection(string collectionName) + => Database.GetCollection(collectionName); /// - public bool DeleteDatabase() - { - if (!DatabaseExists()) - return false; - - Client.DropDatabase(_databaseName); - return true; - } + public IClientSessionHandle StartSession() + => Client.StartSession(); /// - public async Task DeleteDatabaseAsync(CancellationToken cancellationToken = default) - { - if (!await DatabaseExistsAsync(cancellationToken).ConfigureAwait(false)) - return false; - - await Client.DropDatabaseAsync(_databaseName, cancellationToken).ConfigureAwait(false); - return true; - } - - /// - public bool DatabaseExists() - { - if (_useDatabaseNameFilter) - { - try - { - return Client.ListDatabaseNames(BuildListDbNameFilterOptions()).Any(); - } - catch (MongoCommandException ex) when (ex.ErrorMessage.Contains("filter")) - { - // Shared cluster does not support filtering database names so fallback - _useDatabaseNameFilter = false; - } - } - - return Client.ListDatabaseNames().ToList().Any(d => d == _databaseName); - } - - /// - public async Task DatabaseExistsAsync(CancellationToken cancellationToken = default) - { - if (_useDatabaseNameFilter) - { - try - { - using var cursor = await Client - .ListDatabaseNamesAsync(BuildListDbNameFilterOptions(), cancellationToken).ConfigureAwait(false); - return await cursor.AnyAsync(cancellationToken).ConfigureAwait(false); - } - catch (MongoCommandException ex) when (ex.ErrorMessage.Contains("filter")) - { - // Shared cluster does not support filtering database names so fallback - _useDatabaseNameFilter = false; - } - } - - using var allCursor = await Client.ListDatabaseNamesAsync(cancellationToken).ConfigureAwait(false); - var listOfDatabases = await allCursor.ToListAsync(cancellationToken).ConfigureAwait(false); - return listOfDatabases.Any(d => d == _databaseName); - } - - private ListDatabaseNamesOptions BuildListDbNameFilterOptions() - => new() { Filter = Builders.Filter.Eq("name", _databaseName) }; + public async Task StartSessionAsync(CancellationToken cancellationToken = default) + => await Client.StartSessionAsync(null, cancellationToken).ConfigureAwait(false); private IEnumerable ExecuteScalar(MongoExecutableQuery executableQuery) { diff --git a/src/MongoDB.EntityFrameworkCore/Storage/MongoDatabaseCreator.cs b/src/MongoDB.EntityFrameworkCore/Storage/MongoDatabaseCreator.cs index eceb1af8..2cd1b0bb 100644 --- a/src/MongoDB.EntityFrameworkCore/Storage/MongoDatabaseCreator.cs +++ b/src/MongoDB.EntityFrameworkCore/Storage/MongoDatabaseCreator.cs @@ -13,13 +13,20 @@ * limitations under the License. */ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Internal; using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Update; +using MongoDB.Bson; +using MongoDB.Driver; +using MongoDB.EntityFrameworkCore.Extensions; using MongoDB.EntityFrameworkCore.Metadata; namespace MongoDB.EntityFrameworkCore.Storage; @@ -34,17 +41,30 @@ public class MongoDatabaseCreator( IMongoClientWrapper clientWrapper, IDesignTimeModel designTimeModel, IUpdateAdapterFactory updateAdapterFactory, - IDatabase database, - ICurrentDbContext currentDbContext) + IDatabase database) : IMongoDatabaseCreator { + private bool _useDatabaseNameFilter = true; + /// public bool EnsureDeleted() - => clientWrapper.DeleteDatabase(); + { + if (!DatabaseExists()) + return false; + + clientWrapper.Client.DropDatabase(clientWrapper.DatabaseName); + return true; + } /// - public Task EnsureDeletedAsync(CancellationToken cancellationToken = default) - => clientWrapper.DeleteDatabaseAsync(cancellationToken); + public async Task EnsureDeletedAsync(CancellationToken cancellationToken = default) + { + if (!await DatabaseExistsAsync(cancellationToken).ConfigureAwait(false)) + return false; + + await clientWrapper.Client.DropDatabaseAsync(clientWrapper.DatabaseName, cancellationToken).ConfigureAwait(false); + return true; + } /// public bool EnsureCreated() @@ -56,15 +76,110 @@ public Task EnsureCreatedAsync(CancellationToken cancellationToken = defau /// public bool EnsureCreated(MongoDatabaseCreationOptions options) - => clientWrapper.CreateDatabase(currentDbContext.Context.GetService(), options, SeedFromModel); + { + var existed = DatabaseExists(); + + if (options.CreateMissingCollections) + { + using var collectionNamesCursor = clientWrapper.Database.ListCollectionNames(); + var collectionNames = collectionNamesCursor.ToList(); + + foreach (var entityType in designTimeModel.Model.GetEntityTypes().Where(e => e.IsDocumentRoot())) + { + var collectionName = entityType.GetCollectionName(); + if (!collectionNames.Contains(collectionName)) + { + collectionNames.Add(collectionName); + try + { + clientWrapper.Database.CreateCollection(collectionName); + } + catch (MongoCommandException ex) when (ex.Message.Contains("already exists")) + { + } + } + } + } + + if (!existed) + { + SeedFromModel(); + } + + if (options.CreateMissingIndexes) + { + CreateMissingIndexes(); + } + + if (options.CreateMissingVectorIndexes) + { + CreateMissingVectorIndexes(); + } + + if (options.WaitForVectorIndexes) + { + WaitForVectorIndexes(options.IndexCreationTimeout); + } + + return !existed; + } /// - public Task EnsureCreatedAsync(MongoDatabaseCreationOptions options, CancellationToken cancellationToken = default) - => clientWrapper.CreateDatabaseAsync(currentDbContext.Context.GetService(), options, SeedFromModelAsync, cancellationToken); + public async Task EnsureCreatedAsync(MongoDatabaseCreationOptions options, CancellationToken cancellationToken = default) + { + var existed = await DatabaseExistsAsync(cancellationToken).ConfigureAwait(false); + + if (options.CreateMissingCollections) + { + using var collectionNamesCursor = + await clientWrapper.Database.ListCollectionNamesAsync(null, cancellationToken).ConfigureAwait(false); + var collectionNames = await collectionNamesCursor.ToListAsync(cancellationToken); + + foreach (var entityType in designTimeModel.Model.GetEntityTypes().Where(e => e.IsDocumentRoot())) + { + var collectionName = entityType.GetCollectionName(); + if (!collectionNames.Contains(collectionName)) + { + collectionNames.Add(collectionName); + try + { + await clientWrapper.Database.CreateCollectionAsync(collectionName, null, cancellationToken).ConfigureAwait(false); + } + catch (MongoCommandException ex) when (ex.Message.Contains("already exists")) + { + } + } + } + } + if (!existed) + { + await SeedFromModelAsync(cancellationToken).ConfigureAwait(false); + } + + if (options.CreateMissingIndexes) + { + await CreateMissingIndexesAsync(cancellationToken).ConfigureAwait(false); + } + + if (options.CreateMissingVectorIndexes) + { + await CreateMissingVectorIndexesAsync(cancellationToken).ConfigureAwait(false); + } + + if (options.WaitForVectorIndexes) + { + await WaitForVectorIndexesAsync(options.IndexCreationTimeout, cancellationToken).ConfigureAwait(false); + } + + return !existed; + } + + // Used by tests. The EF8 tests use sync infra, and hence this method is used when building against EF8. internal void SeedFromModel() => database.SaveChanges(AddModelData().GetEntriesToSave()); + // Used by tests. The EF9 tests were updated to async infra, and hence this method is used when building against EF9. internal async Task SeedFromModelAsync(CancellationToken cancellationToken = default) => await database.SaveChangesAsync(AddModelData().GetEntriesToSave(), cancellationToken).ConfigureAwait(false); @@ -90,7 +205,7 @@ public bool CanConnect() try { // Do anything that causes an actual database connection with no side effects - clientWrapper.DatabaseExists(); + DatabaseExists(); return true; } catch @@ -105,7 +220,7 @@ public async Task CanConnectAsync(CancellationToken cancellationToken = de try { // Do anything that causes an actual database connection with no side effects - await clientWrapper.DatabaseExistsAsync(cancellationToken).ConfigureAwait(false); + await DatabaseExistsAsync(cancellationToken).ConfigureAwait(false); return true; } catch @@ -113,4 +228,428 @@ public async Task CanConnectAsync(CancellationToken cancellationToken = de return false; } } + + /// + public void CreateMissingIndexes() + { + var collectionNames = new List(); + var existingIndexesMap = new Dictionary?>(); + var indexModelsMap = new Dictionary>?>(); + + foreach (var entityType in designTimeModel.Model.GetEntityTypes().Where(e => e.IsDocumentRoot())) + { + var collectionName = entityType.GetCollectionName(); + if (!collectionNames.Contains(collectionName, StringComparer.Ordinal)) + { + collectionNames.Add(collectionName); + } + + if (!existingIndexesMap.TryGetValue(collectionName, out var indexes)) + { + using var cursor = clientWrapper.Database.GetCollection(collectionName).Indexes.List(); + indexes = cursor.ToList().Select(i => i["name"].AsString).ToList(); + existingIndexesMap[collectionName] = indexes; + } + + BuildIndexes(entityType, collectionName, existingIndexesMap, indexModelsMap); + } + + foreach (var collectionName in collectionNames) + { + if (indexModelsMap.TryGetValue(collectionName, out var indexModels) && indexModels!.Count > 0) + { + var indexManager = clientWrapper.Database.GetCollection(collectionName).Indexes; + indexManager.CreateMany(indexModels); + } + } + } + + /// + public void CreateMissingVectorIndexes() + { + var collectionNames = new List(); + var existingIndexesMap = new Dictionary?>(); + var indexModelsMap = new Dictionary?>(); + + foreach (var entityType in designTimeModel.Model.GetEntityTypes().Where(e => e.IsDocumentRoot())) + { + // Don't try to access Atlas-specific features unless an Atlas vector index is defined. + if (!HasAtlasIndexes(entityType)) + { + continue; + } + + var collectionName = entityType.GetCollectionName(); + if (!collectionNames.Contains(collectionName, StringComparer.Ordinal)) + { + collectionNames.Add(collectionName); + } + + if (!existingIndexesMap.TryGetValue(collectionName, out var indexes)) + { + using var cursor = clientWrapper.Database.GetCollection(collectionName).SearchIndexes.List(); + indexes = cursor.ToList().Select(i => i["name"].AsString).ToList(); + existingIndexesMap[collectionName] = indexes; + } + + BuildVectorIndexes(entityType, collectionName, existingIndexesMap, indexModelsMap); + } + + foreach (var collectionName in collectionNames) + { + if (indexModelsMap.TryGetValue(collectionName, out var indexModels) && indexModels!.Count > 0) + { + var searchIndexManager = clientWrapper.Database.GetCollection(collectionName).SearchIndexes; + searchIndexManager.CreateMany(indexModels); + } + } + } + + private static bool HasAtlasIndexes(IEntityType entityType) + { + if (entityType.GetIndexes().Any(i => i.GetVectorIndexOptions().HasValue)) + { + return true; + } + + foreach (var ownedEntityType in entityType.Model.GetEntityTypes().Where(o => o.FindDeclaredOwnership()?.PrincipalEntityType == entityType)) + { + return HasAtlasIndexes(ownedEntityType); + } + + return false; + } + + /// + public void CreateIndex(IIndex index) + { + var collectionName = index.DeclaringEntityType.GetCollectionName(); + var vectorIndexOptions = index.GetVectorIndexOptions(); + + _ = vectorIndexOptions.HasValue + ? clientWrapper.Database.GetCollection(collectionName).SearchIndexes + .CreateOne(index.CreateVectorIndexDocument(vectorIndexOptions.Value)) + : clientWrapper.Database.GetCollection(collectionName).Indexes + .CreateOne(index.CreateIndexDocument()); + } + + /// + public async Task CreateIndexAsync(IIndex index, CancellationToken cancellationToken = default) + { + var collectionName = index.DeclaringEntityType.GetCollectionName(); + var vectorIndexOptions = index.GetVectorIndexOptions(); + + _ = vectorIndexOptions.HasValue + ? await clientWrapper.Database.GetCollection(collectionName).SearchIndexes + .CreateOneAsync(index.CreateVectorIndexDocument(vectorIndexOptions.Value), cancellationToken) + : await clientWrapper.Database.GetCollection(collectionName).Indexes + .CreateOneAsync(index.CreateIndexDocument(), cancellationToken: cancellationToken); + } + + /// + public void WaitForVectorIndexes(TimeSpan? timeout = null) + { + // Don't try to access Atlas-specific features unless an Atlas vector index is defined. + if (designTimeModel.Model.GetEntityTypes().All(e => !HasAtlasIndexes(e))) + { + return; + } + + var failAfter = CalculateTimeoutDateTime(timeout); + + foreach (var collectionName in designTimeModel.Model.GetCollectionNames()) + { + var delay = 1; + bool isReady; + do + { + isReady = true; + using var cursor = clientWrapper.Database.GetCollection(collectionName).SearchIndexes.List(); + + foreach (var indexModel in cursor.ToList()) + { + var status = indexModel["status"].AsString; + + if (status == "FAILED") + { + throw new InvalidOperationException( + $"Failed to build the vector index '{indexModel["name"]}' for path '{indexModel["latestDefinition"]["fields"][0]["path"]}'."); + } + + if (status != "READY") + { + isReady = false; + Thread.Sleep(delay *= 2); + break; + } + } + + if (!isReady && DateTime.UtcNow >= failAfter) + { + throw new InvalidOperationException( + "Index creation timed out. Please create indexes using MongoDB Compass or the mongosh shell."); + } + } while (!isReady); + } + } + + private static DateTimeOffset CalculateTimeoutDateTime(TimeSpan? timeout) + { + timeout ??= TimeSpan.FromSeconds(15); + var failAfter = timeout.Value == TimeSpan.Zero + ? DateTime.MaxValue + : DateTimeOffset.UtcNow.Add(timeout.Value); + return failAfter; + } + + /// + public async Task CreateMissingIndexesAsync(CancellationToken cancellationToken = default) + { + var collectionNames = new List(); + var existingIndexesMap = new Dictionary?>(); + var indexModelsMap = new Dictionary>?>(); + + foreach (var entityType in designTimeModel.Model.GetEntityTypes().Where(e => e.IsDocumentRoot())) + { + var collectionName = entityType.GetCollectionName(); + if (!collectionNames.Contains(collectionName, StringComparer.Ordinal)) + { + collectionNames.Add(collectionName); + } + + if (!existingIndexesMap.TryGetValue(collectionName, out var indexes)) + { + using var cursor = await clientWrapper.Database.GetCollection(collectionName).Indexes.ListAsync(cancellationToken).ConfigureAwait(false); + indexes = (await cursor.ToListAsync(cancellationToken: cancellationToken).ConfigureAwait(false)).Select(i => i["name"].AsString).ToList(); + existingIndexesMap[collectionName] = indexes; + } + + BuildIndexes(entityType, collectionName, existingIndexesMap, indexModelsMap); + } + + foreach (var collectionName in collectionNames) + { + if (indexModelsMap.TryGetValue(collectionName, out var indexModels) && indexModels!.Count > 0) + { + var indexManager = clientWrapper.Database.GetCollection(collectionName).Indexes; + await indexManager.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false); + } + } + } + + /// + public async Task CreateMissingVectorIndexesAsync(CancellationToken cancellationToken = default) + { + var collectionNames = new List(); + var existingIndexesMap = new Dictionary?>(); + var indexModelsMap = new Dictionary?>(); + + foreach (var entityType in designTimeModel.Model.GetEntityTypes().Where(e => e.IsDocumentRoot())) + { + // Don't try to access Atlas-specific features unless an Atlas vector index is defined. + if (!HasAtlasIndexes(entityType)) + { + continue; + } + + var collectionName = entityType.GetCollectionName(); + if (!collectionNames.Contains(collectionName, StringComparer.Ordinal)) + { + collectionNames.Add(collectionName); + } + + if (!existingIndexesMap.TryGetValue(collectionName, out var indexes)) + { + using var cursor = await clientWrapper.Database.GetCollection(collectionName).SearchIndexes.ListAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + indexes = (await cursor.ToListAsync(cancellationToken: cancellationToken).ConfigureAwait(false)).Select(i => i["name"].AsString).ToList(); + existingIndexesMap[collectionName] = indexes; + } + + BuildVectorIndexes(entityType, collectionName, existingIndexesMap, indexModelsMap); + } + + foreach (var collectionName in collectionNames) + { + if (indexModelsMap.TryGetValue(collectionName, out var indexModels) && indexModels!.Count > 0) + { + var searchIndexManager = clientWrapper.Database.GetCollection(collectionName).SearchIndexes; + await searchIndexManager.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false); + } + } + } + + /// + public async Task WaitForVectorIndexesAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + // Don't try to access Atlas-specific features unless an Atlas vector index is defined. + if (designTimeModel.Model.GetEntityTypes().All(e => !HasAtlasIndexes(e))) + { + return; + } + + var failAfter = CalculateTimeoutDateTime(timeout); + foreach (var collectionName in designTimeModel.Model.GetCollectionNames()) + { + var delay = 1; + bool isReady; + do + { + isReady = true; + using var cursor = await clientWrapper.Database.GetCollection(collectionName).SearchIndexes.ListAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + var indexModels = await cursor.ToListAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + + foreach (var indexModel in indexModels) + { + var status = indexModel["status"].AsString; + + if (status == "FAILED") + { + throw new InvalidOperationException( + $"Failed to build the vector index '{indexModel["name"]}' for path '{indexModel["latestDefinition"]["fields"][0]["path"]}'."); + } + + if (status != "READY") + { + isReady = false; + await Task.Delay(delay *= 2, cancellationToken).ConfigureAwait(false); + break; + } + } + + if (!isReady && DateTime.UtcNow >= failAfter) + { + throw new InvalidOperationException( + "Index creation timed out. Please create indexes using MongoDB Compass or the mongosh shell."); + } + } while (!isReady); + } + } + + private void BuildIndexes( + IEntityType entityType, + string collectionName, + Dictionary?> existingIndexesMap, + Dictionary>?> indexModelsMap) + { + var existingIndexes = existingIndexesMap[collectionName]!; + + if (!indexModelsMap.TryGetValue(collectionName, out var indexModels)) + { + indexModels = []; + indexModelsMap[collectionName] = indexModels; + } + + foreach (var index in entityType.GetIndexes().Where(i => !i.GetVectorIndexOptions().HasValue)) + { + var name = index.Name; + Debug.Assert(name != null, "Index name should have been set by IndexNamingConvention."); + + if (!existingIndexes.Contains(name)) + { + existingIndexes.Add(name); + indexModels!.Add(index.CreateIndexDocument()); + } + } + + foreach (var key in entityType.GetKeys().Where(k => !k.IsPrimaryKey())) + { + var name = key.MakeIndexName(); + if (!existingIndexes.Contains(name)) + { + existingIndexes.Add(name); + indexModels!.Add(key.CreateKeyIndexDocument(name)); + } + } + + var ownedEntityTypes = designTimeModel.Model.GetEntityTypes().Where(o => o.FindDeclaredOwnership()?.PrincipalEntityType == entityType); + + foreach (var ownedEntityType in ownedEntityTypes) + { + BuildIndexes(ownedEntityType, collectionName, existingIndexesMap, indexModelsMap); + } + } + + private void BuildVectorIndexes( + IEntityType entityType, + string collectionName, + Dictionary?> existingIndexesMap, + Dictionary?> indexModelsMap) + { + if (!existingIndexesMap.TryGetValue(collectionName, out var existingIndexes)) + { + existingIndexesMap[collectionName] = existingIndexes = new List(); + } + + if (!indexModelsMap.TryGetValue(collectionName, out var indexModels)) + { + indexModels = []; + indexModelsMap[collectionName] = indexModels; + } + + foreach (var index in entityType.GetIndexes().Where(i => i.GetVectorIndexOptions().HasValue)) + { + var name = index.Name; + Debug.Assert(name != null, "Index name should have been set by IndexNamingConvention."); + + var options = index.GetVectorIndexOptions()!.Value; + if (!existingIndexes!.Contains(name)) + { + indexModels!.Add(index.CreateVectorIndexDocument(options)); + existingIndexes.Add(name); + } + } + + var ownedEntityTypes = designTimeModel.Model.GetEntityTypes().Where(o => o.FindDeclaredOwnership()?.PrincipalEntityType == entityType); + + foreach (var ownedEntityType in ownedEntityTypes) + { + BuildVectorIndexes(ownedEntityType, collectionName, existingIndexesMap, indexModelsMap); + } + } + + /// + public bool DatabaseExists() + { + if (_useDatabaseNameFilter) + { + try + { + return clientWrapper.Client.ListDatabaseNames(BuildListDbNameFilterOptions()).Any(); + } + catch (MongoCommandException ex) when (ex.ErrorMessage.Contains("filter")) + { + // Shared cluster does not support filtering database names so fallback + _useDatabaseNameFilter = false; + } + } + + return clientWrapper.Client.ListDatabaseNames().ToList().Any(d => d == clientWrapper.DatabaseName); + } + + /// + public async Task DatabaseExistsAsync(CancellationToken cancellationToken = default) + { + if (_useDatabaseNameFilter) + { + try + { + using var cursor = await clientWrapper.Client + .ListDatabaseNamesAsync(BuildListDbNameFilterOptions(), cancellationToken).ConfigureAwait(false); + return await cursor.AnyAsync(cancellationToken).ConfigureAwait(false); + } + catch (MongoCommandException ex) when (ex.ErrorMessage.Contains("filter")) + { + // Shared cluster does not support filtering database names so fallback + _useDatabaseNameFilter = false; + } + } + + using var allCursor = await clientWrapper.Client.ListDatabaseNamesAsync(cancellationToken).ConfigureAwait(false); + var listOfDatabases = await allCursor.ToListAsync(cancellationToken).ConfigureAwait(false); + return listOfDatabases.Any(d => d == clientWrapper.DatabaseName); + } + + private ListDatabaseNamesOptions BuildListDbNameFilterOptions() + => new() { Filter = Builders.Filter.Eq("name", clientWrapper.DatabaseName) }; + } diff --git a/tests/MongoDB.EntityFrameworkCore.FunctionalTests/Storage/MongoClientWrapperTests.cs b/tests/MongoDB.EntityFrameworkCore.FunctionalTests/Storage/MongoClientWrapperTests.cs deleted file mode 100644 index 10cf9637..00000000 --- a/tests/MongoDB.EntityFrameworkCore.FunctionalTests/Storage/MongoClientWrapperTests.cs +++ /dev/null @@ -1,748 +0,0 @@ -/* Copyright 2023-present MongoDB Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Diagnostics; -using MongoDB.Bson; -using MongoDB.Driver; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using MongoDB.EntityFrameworkCore.Extensions; -using MongoDB.EntityFrameworkCore.Metadata; -using MongoDB.EntityFrameworkCore.Storage; - -namespace MongoDB.EntityFrameworkCore.FunctionalTests.Storage; - -[XUnitCollection("StorageTests")] -public class MongoClientWrapperTests -{ - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task CreateDatabase_creates_database_and_collections(bool async) - { - var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); - - var context = MyContext.CreateCollectionOptions(database.MongoDatabase); - var client = context.GetService(); - - { - var didCreate = async - ? await client.CreateDatabaseAsync(context.GetService()) - : client.CreateDatabase(context.GetService()); - Assert.True(didCreate); - var collectionNames = database.MongoDatabase.ListCollectionNames().ToList(); - Assert.Equal(3, collectionNames.Count); - Assert.Contains("Customers", collectionNames); - Assert.Contains("Orders", collectionNames); - Assert.Contains("Addresses", collectionNames); - } - - { - var didCreate = async - ? await client.CreateDatabaseAsync(context.GetService()) - : client.CreateDatabase(context.GetService()); - Assert.False(didCreate); - var collectionNames = database.MongoDatabase.ListCollectionNames().ToList(); - Assert.Equal(3, collectionNames.Count); - } - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task CreateDatabase_can_be_configured_to_not_create_missing_collections(bool async) - { - var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); - - var context = MyContext.CreateCollectionOptions(database.MongoDatabase); - var client = context.GetService(); - - var options = new MongoDatabaseCreationOptions(CreateMissingCollections: false); - - var didCreate = async - ? await client.CreateDatabaseAsync(context.GetService(), options, seedAsync: null) - : client.CreateDatabase(context.GetService(), options, seed: null); - - Assert.True(didCreate); - Assert.Empty(database.MongoDatabase.ListCollectionNames().ToList()); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task CreateDatabase_creates_indexes(bool async) - { - var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); - - var context = MyContext.CreateCollectionOptions(database.MongoDatabase, mb => - { - mb.Entity().HasIndex(c => c.Name); - mb.Entity().HasIndex(o => o.OrderRef).IsUnique(); - mb.Entity
().HasIndex(o => o.PostCode, "custom_index_name"); - }); - var client = context.GetService(); - - var didCreate = async - ? await client.CreateDatabaseAsync(context.GetService()) - : client.CreateDatabase(context.GetService()); - - Assert.True(didCreate); - Assert.Equal(2, GetIndexes(database.MongoDatabase, "Customers").Count); - Assert.Equal(2, GetIndexes(database.MongoDatabase, "Orders").Count); - Assert.Equal(2, GetIndexes(database.MongoDatabase, "Addresses").Count); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task CreateDatabase_index_creation_can_be_deferred(bool async) - { - var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); - - var context = MyContext.CreateCollectionOptions(database.MongoDatabase, mb => - { - mb.Entity().HasIndex(c => c.Name); - mb.Entity().HasIndex(o => o.OrderRef).IsUnique(); - mb.Entity
().HasIndex(o => o.PostCode, "custom_index_name"); - }); - var client = context.GetService(); - - var designTimeModel = context.GetService(); - var options = new MongoDatabaseCreationOptions(CreateMissingIndexes: false); - - var didCreate = async - ? await client.CreateDatabaseAsync(designTimeModel, options, seedAsync: null) - : client.CreateDatabase(designTimeModel, options, seed: null); - - Assert.True(didCreate); - Assert.Single(GetIndexes(database.MongoDatabase, "Customers")); - Assert.Single(GetIndexes(database.MongoDatabase, "Orders")); - Assert.Single(GetIndexes(database.MongoDatabase, "Addresses")); - - if (async) - { - await client.CreateMissingIndexesAsync(designTimeModel.Model); - } - else - { - client.CreateMissingIndexes(designTimeModel.Model); - } - - Assert.Equal(2, GetIndexes(database.MongoDatabase, "Customers").Count); - Assert.Equal(2, GetIndexes(database.MongoDatabase, "Orders").Count); - Assert.Equal(2, GetIndexes(database.MongoDatabase, "Addresses").Count); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task CreateDatabase_creates_nested_index_on_owns_one(bool async) - { - var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); - - var collection = database.CreateCollection(values: async); - var context = SingleEntityDbContext.Create(collection, mb => - { - mb.Entity(p => - { - p.HasIndex(o => o.Name); - p.OwnsOne(q => q.PrimaryCertificate, q => { q.HasIndex(r => r.Name); }); - }); - }); - var client = context.GetService(); - - _ = async - ? await client.CreateDatabaseAsync(context.GetService()) - : client.CreateDatabase(context.GetService()); - - var indexList = async - ? (await collection.Indexes.ListAsync()).ToList() - : collection.Indexes.List().ToList(); - - Assert.Equal(3, indexList.Count); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task CreateDatabase_creates_nested_index_on_owns_one_can_be_deferred(bool async) - { - var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); - - var collection = database.CreateCollection(values: async); - var context = SingleEntityDbContext.Create(collection, mb => - { - mb.Entity(p => - { - p.HasIndex(o => o.Name); - p.OwnsOne(q => q.PrimaryCertificate, q => { q.HasIndex(r => r.Name); }); - }); - }); - var client = context.GetService(); - - var designTimeModel = context.GetService(); - var options = new MongoDatabaseCreationOptions(CreateMissingIndexes: false); - - _ = async - ? await client.CreateDatabaseAsync(designTimeModel, options, seedAsync: null) - : client.CreateDatabase(designTimeModel, options, seed: null); - - var indexList = async - ? (await collection.Indexes.ListAsync()).ToList() - : collection.Indexes.List().ToList(); - - Assert.Single(indexList); - - if (async) - { - await client.CreateMissingIndexesAsync(designTimeModel.Model); - } - else - { - client.CreateMissingIndexes(designTimeModel.Model); - } - - indexList = async - ? (await collection.Indexes.ListAsync()).ToList() - : collection.Indexes.List().ToList(); - - Assert.Equal(3, indexList.Count); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task CreateDatabase_creates_nested_index_on_owns_many(bool async) - { - var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); - - var collection = database.CreateCollection(values: async); - var context = SingleEntityDbContext.Create(collection, mb => - { - mb.Entity(p => - { - p.HasIndex(o => o.Name); - p.OwnsMany(q => q.SecondaryCertificates, q => { q.HasIndex(r => r.Name); }); - }); - }); - var client = context.GetService(); - - _ = async - ? await client.CreateDatabaseAsync(context.GetService()) - : client.CreateDatabase(context.GetService()); - - var indexList = async - ? (await collection.Indexes.ListAsync()).ToList() - : collection.Indexes.List().ToList(); - - Assert.Equal(3, indexList.Count); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task CreateDatabase_creates_nested_index_on_owns_many_can_be_deferred(bool async) - { - var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); - - var collection = database.CreateCollection(values: async); - var context = SingleEntityDbContext.Create(collection, mb => - { - mb.Entity(p => - { - p.HasIndex(o => o.Name); - p.OwnsMany(q => q.SecondaryCertificates, q => { q.HasIndex(r => r.Name); }); - }); - }); - var client = context.GetService(); - - var designTimeModel = context.GetService(); - var options = new MongoDatabaseCreationOptions(CreateMissingIndexes: false); - - _ = async - ? await client.CreateDatabaseAsync(designTimeModel, options, seedAsync: null) - : client.CreateDatabase(designTimeModel, options, seed: null); - - var indexList = async - ? (await collection.Indexes.ListAsync()).ToList() - : collection.Indexes.List().ToList(); - - Assert.Single(indexList); - - if (async) - { - await client.CreateMissingIndexesAsync(designTimeModel.Model); - } - else - { - client.CreateMissingIndexes(designTimeModel.Model); - } - - indexList = async - ? (await collection.Indexes.ListAsync()).ToList() - : collection.Indexes.List().ToList(); - - Assert.Equal(3, indexList.Count); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task CreateDatabase_creates_nested_index_on_owns_many_owns_one(bool async) - { - var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); - - var collection = database.CreateCollection(values: async); - var context = SingleEntityDbContext.Create(collection, mb => - { - mb.Entity(p => - { - p.OwnsMany(q => q.SecondaryCertificates, q => - { - q.OwnsOne(c => c.Issuer, i => - { - i.HasIndex(j => j.OrganizationName); - i.HasIndex(j => j.Country); - }); - }); - }); - }); - var client = context.GetService(); - - _ = async - ? await client.CreateDatabaseAsync(context.GetService()) - : client.CreateDatabase(context.GetService()); - - var indexList = async - ? (await collection.Indexes.ListAsync()).ToList() - : collection.Indexes.List().ToList(); - - Assert.Equal(3, indexList.Count); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task CreateDatabase_creates_alternate_keys(bool async) - { - var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); - - var context = MyContext.CreateCollectionOptions(database.MongoDatabase, mb => - { - mb.Entity(e => - { - e.HasAlternateKey(c => c.SSN); - e.HasAlternateKey(c => c.TIN); - e.Property(c => c.SSN).HasElementName("ssn"); - }); - mb.Entity().HasAlternateKey(o => new { o.OrderRef, o.CustomerId }); - mb.Entity
().HasAlternateKey(o => o.UniqueRef); - }); - var client = context.GetService(); - - var didCreate = async - ? await client.CreateDatabaseAsync(context.GetService()) - : client.CreateDatabase(context.GetService()); - - Assert.True(didCreate); - - var customerIndexes = GetIndexes(database.MongoDatabase, "Customers"); - Assert.Equal(3, customerIndexes.Count); - var customerSsnKey = Assert.Single(customerIndexes, i => i["name"] == "ssn_1"); - Assert.Equal(BsonBoolean.True, customerSsnKey["unique"]); - Assert.Equal(new BsonDocument("ssn", 1), customerSsnKey["key"]); - var customerTinKey = Assert.Single(customerIndexes, i => i["name"] == "TIN_1"); - Assert.Equal(BsonBoolean.True, customerTinKey["unique"]); - Assert.Equal(new BsonDocument("TIN", 1), customerTinKey["key"]); - - var orderIndexes = GetIndexes(database.MongoDatabase, "Orders"); - Assert.Equal(2, orderIndexes.Count); - var orderAlternateKeyIndex = Assert.Single(orderIndexes, i => i["name"] == "OrderRef_1_CustomerId_1"); - Assert.Equal(BsonBoolean.True, orderAlternateKeyIndex["unique"]); - Assert.Equal(new BsonDocument { ["OrderRef"] = 1, ["CustomerId"] = 1 }, orderAlternateKeyIndex["key"]); - - var addressIndexes = GetIndexes(database.MongoDatabase, "Addresses"); - Assert.Equal(2, addressIndexes.Count); - var addressAlternateKeyIndex = Assert.Single(addressIndexes, i => i["name"] == "UniqueRef_1"); - Assert.Equal(BsonBoolean.True, addressAlternateKeyIndex["unique"]); - Assert.Equal(new BsonDocument("UniqueRef", 1), addressAlternateKeyIndex["key"]); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task CreateDatabase_does_not_duplicate_indexes(bool async) - { - var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); - - - { - var context = MyContext.CreateCollectionOptions(database.MongoDatabase, - mb => - { - mb.Entity
().HasIndex(o => o.PostCode, "custom_index_name"); - mb.Entity().HasAlternateKey(o => o.OrderRef); - } - ); - var client = context.GetService(); - - var didCreate = async - ? await client.CreateDatabaseAsync(context.GetService()) - : client.CreateDatabase(context.GetService()); - - Assert.True(didCreate); - Assert.Single(GetIndexes(database.MongoDatabase, "Customers")); - Assert.Equal(2, GetIndexes(database.MongoDatabase, "Addresses").Count); - Assert.Equal(2, GetIndexes(database.MongoDatabase, "Orders").Count); - } - - { - var context = MyContext.CreateCollectionOptions(database.MongoDatabase, mb => - { - mb.Entity().HasIndex(c => c.Name); - mb.Entity
().HasIndex(o => o.PostCode, "custom_index_name"); - mb.Entity().HasAlternateKey(o => o.OrderRef); - }); - var client = context.GetService(); - - var didCreate = async - ? await client.CreateDatabaseAsync(context.GetService()) - : client.CreateDatabase(context.GetService()); - - Assert.False(didCreate); - Assert.Equal(2, GetIndexes(database.MongoDatabase, "Customers").Count); - Assert.Equal(2, GetIndexes(database.MongoDatabase, "Addresses").Count); - Assert.Equal(2, GetIndexes(database.MongoDatabase, "Orders").Count); - } - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task CreateDatabase_creates_index_from_string_named_properties(bool async) - { - var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); - - var context = MyContext.CreateCollectionOptions(database.MongoDatabase, - mb => mb.Entity
().HasIndex("PostCode")); - var client = context.GetService(); - - _ = async - ? await client.CreateDatabaseAsync(context.GetService()) - : client.CreateDatabase(context.GetService()); - - var indexes = GetIndexes(database.MongoDatabase, "Addresses"); - Assert.Equal(2, indexes.Count); - Assert.Single(indexes, i => i["key"].AsBsonDocument.Names.Single() == "PostCode"); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task CreateDatabase_creates_index_from_multiple_string_named_properties(bool async) - { - var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); - - var context = MyContext.CreateCollectionOptions(database.MongoDatabase, - mb => mb.Entity
().HasIndex("Country", "PostCode")); - var client = context.GetService(); - - _ = async - ? await client.CreateDatabaseAsync(context.GetService()) - : client.CreateDatabase(context.GetService()); - - var indexes = GetIndexes(database.MongoDatabase, "Addresses"); - Assert.Equal(2, indexes.Count); - - var foundIndex = Assert.Single(indexes, i => i["key"].AsBsonDocument.Names.Count() == 2); - Assert.Contains(foundIndex["key"].AsBsonDocument, key => key.Name == "Country"); - Assert.Contains(foundIndex["key"].AsBsonDocument, key => key.Name == "PostCode"); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task CreateDatabase_creates_index_with_descending_property(bool async) - { - var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); - - var context = MyContext.CreateCollectionOptions(database.MongoDatabase, - mb => mb.Entity
().HasIndex(a => a.PostCode).IsDescending(true)); - var client = context.GetService(); - - _ = async - ? await client.CreateDatabaseAsync(context.GetService()) - : client.CreateDatabase(context.GetService()); - - var indexes = GetIndexes(database.MongoDatabase, "Addresses"); - Assert.Equal(2, indexes.Count); - - var foundIndex = Assert.Single(indexes, i => i["key"].AsBsonDocument.Names.Contains("PostCode")); - Assert.Equal(-1, foundIndex["key"].AsBsonDocument["PostCode"].AsInt32); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task CreateDatabase_creates_index_with_two_descending_properties(bool async) - { - var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); - - var context = MyContext.CreateCollectionOptions(database.MongoDatabase, - mb => mb.Entity
().HasIndex(a => new { a.PostCode, a.Country }).IsDescending()); - var client = context.GetService(); - - _ = async - ? await client.CreateDatabaseAsync(context.GetService()) - : client.CreateDatabase(context.GetService()); - - var indexes = GetIndexes(database.MongoDatabase, "Addresses"); - Assert.Equal(2, indexes.Count); - - var foundIndex = Assert.Single(indexes, i => i["key"].AsBsonDocument.Names.Contains("PostCode")); - Assert.Equal(-1, foundIndex["key"].AsBsonDocument["PostCode"].AsInt32); - Assert.Equal(-1, foundIndex["key"].AsBsonDocument["Country"].AsInt32); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task CreateDatabase_creates_index_with_two_properties_mixed_sort_order(bool async) - { - var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); - - var context = MyContext.CreateCollectionOptions(database.MongoDatabase, - mb => mb.Entity
().HasIndex(a => new { a.PostCode, a.Country }).IsDescending(false, true)); - var client = context.GetService(); - - _ = async - ? await client.CreateDatabaseAsync(context.GetService()) - : client.CreateDatabase(context.GetService()); - - var indexes = GetIndexes(database.MongoDatabase, "Addresses"); - Assert.Equal(2, indexes.Count); - - var foundIndex = Assert.Single(indexes, i => i["key"].AsBsonDocument.Names.Contains("PostCode")); - Assert.Equal(1, foundIndex["key"].AsBsonDocument["PostCode"].AsInt32); - Assert.Equal(-1, foundIndex["key"].AsBsonDocument["Country"].AsInt32); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task CreateDatabase_creates_index_with_two_properties_unique_descending(bool async) - { - var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); - - var context = MyContext.CreateCollectionOptions(database.MongoDatabase, - mb => mb.Entity
().HasIndex(a => new { a.PostCode, a.Country }).IsUnique().IsDescending()); - var client = context.GetService(); - - _ = async - ? await client.CreateDatabaseAsync(context.GetService()) - : client.CreateDatabase(context.GetService()); - - var indexes = GetIndexes(database.MongoDatabase, "Addresses"); - Assert.Equal(2, indexes.Count); - - var foundIndex = Assert.Single(indexes, i => i["key"].AsBsonDocument.Names.Contains("PostCode")); - Assert.Equal(-1, foundIndex["key"].AsBsonDocument["PostCode"].AsInt32); - Assert.Equal(-1, foundIndex["key"].AsBsonDocument["Country"].AsInt32); - Assert.Single(indexes, i => i.Names.Contains("unique") && i["unique"].AsBoolean); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task CreateDatabase_creates_index_with_filter(bool async) - { - var filter = Builders.Filter.Eq(a => a["Country"], "UK"); - var options = new CreateIndexOptions { PartialFilterExpression = filter }; - var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); - - var context = MyContext.CreateCollectionOptions(database.MongoDatabase, - mb => mb.Entity
().HasIndex(a => a.PostCode).HasCreateIndexOptions(options)); - var client = context.GetService(); - - _ = async - ? await client.CreateDatabaseAsync(context.GetService()) - : client.CreateDatabase(context.GetService()); - - var indexes = database.MongoDatabase.GetCollection("Addresses").Indexes.List().ToList(); - Assert.Single(indexes, - i => i.Names.Contains("partialFilterExpression") - && i["partialFilterExpression"].ToString() == "{ \"Country\" : \"UK\" }"); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task CreateDatabase_creates_index_with_create_index_options(bool async) - { - var options = new CreateIndexOptions { Sparse = true, Unique = true }; - var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); - - var context = MyContext.CreateCollectionOptions(database.MongoDatabase, - mb => mb.Entity
().HasIndex(a => a.PostCode).HasCreateIndexOptions(options)); - var client = context.GetService(); - - _ = async - ? await client.CreateDatabaseAsync(context.GetService()) - : client.CreateDatabase(context.GetService()); - - var indexes = database.MongoDatabase.GetCollection("Addresses").Indexes.List().ToList(); - Assert.Single(indexes, - i => i.Names.Contains("sparse") && i["sparse"].AsBoolean && i.Names.Contains("unique") && i["unique"].AsBoolean); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task CreateDatabase_does_not_affect_existing_collections(bool async) - { - const int expectedMaxDocs = 1024; - const int expectedMaxSize = 4096; - - var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); - - { - var collection = database.MongoDatabase.GetCollection("Customers"); - collection.InsertOne(new Customer { Name = "John Doe" }); - database.MongoDatabase.CreateCollection("Orders", - new CreateCollectionOptions { MaxDocuments = expectedMaxDocs, MaxSize = expectedMaxSize, Capped = true }); - database.MongoDatabase.CreateCollection("Orders2"); - } - - { - var context = MyContext.CreateCollectionOptions(database.MongoDatabase); - var client = context.GetService(); - - var didCreate = async - ? await client.CreateDatabaseAsync(context.GetService()) - : client.CreateDatabase(context.GetService()); - - Assert.False(didCreate); - var collections = database.MongoDatabase.ListCollections().ToList(); - var allNames = collections.Select(c => c["name"].AsString).ToArray(); - Assert.Equal(4, allNames.Length); - Assert.Contains("Customers", allNames); - Assert.Contains("Orders", allNames); - Assert.Contains("Addresses", allNames); - - var customerCollectionOptions = collections.Single(c => c["name"].AsString == "Orders")["options"]; - Assert.True(customerCollectionOptions["capped"].AsBoolean); - Assert.Equal(expectedMaxDocs, customerCollectionOptions["max"].AsInt32); - Assert.Equal(expectedMaxSize, customerCollectionOptions["size"].AsInt32); - - var collection = database.MongoDatabase.GetCollection("Customers"); - Assert.Equal(1, collection.CountDocuments(FilterDefinition.Empty)); - } - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task DeleteDatabase_deletes_database(bool async) - { - var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); - - var context = MyContext.CreateCollectionOptions(database.MongoDatabase); - var client = context.GetService(); - - Assert.False(client.DatabaseExists()); - - client.CreateDatabase(context.GetService()); - Assert.True(client.DatabaseExists()); - - _ = async ? await client.DeleteDatabaseAsync() : client.DeleteDatabase(); - Assert.False(client.DatabaseExists()); - } - - class MyContext( - DbContextOptions options, - Action? modelBuilderAction = null) - : DbContext(options) - { - public DbSet Customers { get; set; } - public DbSet Orders { get; set; } - public DbSet
Addresses { get; set; } - - private static DbContextOptions CreateOptions(IMongoDatabase database) - => new DbContextOptionsBuilder() - .UseMongoDB(database.Client, database.DatabaseNamespace.DatabaseName) - .ConfigureWarnings(x => x.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning)) - .ReplaceService() - .Options; - - public static MyContext CreateCollectionOptions( - IMongoDatabase database, - Action? modelBuilderAction = null) - => new(CreateOptions(database), modelBuilderAction); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - modelBuilderAction?.Invoke(modelBuilder); - } - } - - class Customer - { - public ObjectId Id { get; set; } - public string Name { get; set; } - public string SSN { get; set; } - public string TIN { get; set; } - } - - class Order - { - public ObjectId Id { get; set; } - public string CustomerId { get; set; } - public string OrderRef { get; set; } - } - - class Address - { - public ObjectId Id { get; set; } - public string PostCode { get; set; } - public string Country { get; set; } - public string Region { get; set; } - public string UniqueRef { get; set; } - } - - class Product - { - public ObjectId Id { get; set; } - public string Name { get; set; } - public Certificate PrimaryCertificate { get; set; } - public List SecondaryCertificates { get; set; } - } - - class Certificate - { - public string Name { get; set; } - public Issuer? Issuer { get; set; } - } - - class Issuer - { - public string OrganizationName { get; set; } - public string Country { get; set; } - } - - private static List GetIndexes(IMongoDatabase database, string collectionName) - => database.GetCollection(collectionName).Indexes.List().ToList(); -} diff --git a/tests/MongoDB.EntityFrameworkCore.FunctionalTests/Storage/MongoDatabaseCreatorTests.cs b/tests/MongoDB.EntityFrameworkCore.FunctionalTests/Storage/MongoDatabaseCreatorTests.cs index 227c5e8f..744e5e7e 100644 --- a/tests/MongoDB.EntityFrameworkCore.FunctionalTests/Storage/MongoDatabaseCreatorTests.cs +++ b/tests/MongoDB.EntityFrameworkCore.FunctionalTests/Storage/MongoDatabaseCreatorTests.cs @@ -13,8 +13,15 @@ * limitations under the License. */ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Infrastructure; +using MongoDB.Bson; using MongoDB.Driver; +using MongoDB.EntityFrameworkCore.Extensions; using MongoDB.EntityFrameworkCore.FunctionalTests.Entities.Guides; +using MongoDB.EntityFrameworkCore.Metadata; +using MongoDB.EntityFrameworkCore.Storage; namespace MongoDB.EntityFrameworkCore.FunctionalTests.Storage; @@ -139,4 +146,719 @@ private readonly MongoClient _fastFailClient Server = MongoServerAddress.Parse("localhost:27999"), ServerSelectionTimeout = TimeSpan.FromSeconds(0), ConnectTimeout = TimeSpan.FromSeconds(1) }); + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CreateDatabase_creates_database_and_collections(bool async) + { + var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); + + var context = MyContext.CreateCollectionOptions(database.MongoDatabase); + var databaseCreator = context.GetService(); + + { + var didCreate = async + ? await databaseCreator.EnsureCreatedAsync() + : databaseCreator.EnsureCreated(); + Assert.True(didCreate); + var collectionNames = database.MongoDatabase.ListCollectionNames().ToList(); + Assert.Equal(3, collectionNames.Count); + Assert.Contains("Customers", collectionNames); + Assert.Contains("Orders", collectionNames); + Assert.Contains("Addresses", collectionNames); + } + + { + var didCreate = async + ? await databaseCreator.EnsureCreatedAsync() + : databaseCreator.EnsureCreated(); + Assert.False(didCreate); + var collectionNames = database.MongoDatabase.ListCollectionNames().ToList(); + Assert.Equal(3, collectionNames.Count); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CreateDatabase_can_be_configured_to_not_create_missing_collections(bool async) + { + var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); + + var context = MyContext.CreateCollectionOptions(database.MongoDatabase); + var databaseCreator = context.GetService(); + + var options = new MongoDatabaseCreationOptions(CreateMissingCollections: false); + + var didCreate = async + ? await databaseCreator.EnsureCreatedAsync(options) + : databaseCreator.EnsureCreated(options); + + Assert.True(didCreate); + Assert.Empty(database.MongoDatabase.ListCollectionNames().ToList()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CreateDatabase_creates_indexes(bool async) + { + var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); + + var context = MyContext.CreateCollectionOptions(database.MongoDatabase, mb => + { + mb.Entity().HasIndex(c => c.Name); + mb.Entity().HasIndex(o => o.OrderRef).IsUnique(); + mb.Entity
().HasIndex(o => o.PostCode, "custom_index_name"); + }); + var databaseCreator = context.GetService(); + + var didCreate = async + ? await databaseCreator.EnsureCreatedAsync() + : databaseCreator.EnsureCreated(); + + Assert.True(didCreate); + Assert.Equal(2, GetIndexes(database.MongoDatabase, "Customers").Count); + Assert.Equal(2, GetIndexes(database.MongoDatabase, "Orders").Count); + Assert.Equal(2, GetIndexes(database.MongoDatabase, "Addresses").Count); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CreateDatabase_index_creation_can_be_deferred(bool async) + { + var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); + + var context = MyContext.CreateCollectionOptions(database.MongoDatabase, mb => + { + mb.Entity().HasIndex(c => c.Name); + mb.Entity().HasIndex(o => o.OrderRef).IsUnique(); + mb.Entity
().HasIndex(o => o.PostCode, "custom_index_name"); + }); + var databaseCreator = context.GetService(); + + var options = new MongoDatabaseCreationOptions(CreateMissingIndexes: false); + + var didCreate = async + ? await databaseCreator.EnsureCreatedAsync(options) + : databaseCreator.EnsureCreated(options); + + Assert.True(didCreate); + Assert.Single(GetIndexes(database.MongoDatabase, "Customers")); + Assert.Single(GetIndexes(database.MongoDatabase, "Orders")); + Assert.Single(GetIndexes(database.MongoDatabase, "Addresses")); + + if (async) + { + await databaseCreator.CreateMissingIndexesAsync(); + } + else + { + databaseCreator.CreateMissingIndexes(); + } + + Assert.Equal(2, GetIndexes(database.MongoDatabase, "Customers").Count); + Assert.Equal(2, GetIndexes(database.MongoDatabase, "Orders").Count); + Assert.Equal(2, GetIndexes(database.MongoDatabase, "Addresses").Count); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CreateDatabase_creates_nested_index_on_owns_one(bool async) + { + var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); + + var collection = database.CreateCollection(values: async); + var context = SingleEntityDbContext.Create(collection, mb => + { + mb.Entity(p => + { + p.HasIndex(o => o.Name); + p.OwnsOne(q => q.PrimaryCertificate, q => { q.HasIndex(r => r.Name); }); + }); + }); + var databaseCreator = context.GetService(); + + _ = async + ? await databaseCreator.EnsureCreatedAsync() + : databaseCreator.EnsureCreated(); + + var indexList = async + ? (await collection.Indexes.ListAsync()).ToList() + : collection.Indexes.List().ToList(); + + Assert.Equal(3, indexList.Count); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CreateDatabase_creates_nested_index_on_owns_one_can_be_deferred(bool async) + { + var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); + + var collection = database.CreateCollection(values: async); + var context = SingleEntityDbContext.Create(collection, mb => + { + mb.Entity(p => + { + p.HasIndex(o => o.Name); + p.OwnsOne(q => q.PrimaryCertificate, q => { q.HasIndex(r => r.Name); }); + }); + }); + var databaseCreator = context.GetService(); + + var options = new MongoDatabaseCreationOptions(CreateMissingIndexes: false); + + _ = async + ? await databaseCreator.EnsureCreatedAsync(options) + : databaseCreator.EnsureCreated(options); + + var indexList = async + ? (await collection.Indexes.ListAsync()).ToList() + : collection.Indexes.List().ToList(); + + Assert.Single(indexList); + + if (async) + { + await databaseCreator.CreateMissingIndexesAsync(); + } + else + { + databaseCreator.CreateMissingIndexes(); + } + + indexList = async + ? (await collection.Indexes.ListAsync()).ToList() + : collection.Indexes.List().ToList(); + + Assert.Equal(3, indexList.Count); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CreateDatabase_creates_nested_index_on_owns_many(bool async) + { + var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); + + var collection = database.CreateCollection(values: async); + var context = SingleEntityDbContext.Create(collection, mb => + { + mb.Entity(p => + { + p.HasIndex(o => o.Name); + p.OwnsMany(q => q.SecondaryCertificates, q => { q.HasIndex(r => r.Name); }); + }); + }); + var databaseCreator = context.GetService(); + + _ = async + ? await databaseCreator.EnsureCreatedAsync() + : databaseCreator.EnsureCreated(); + + var indexList = async + ? (await collection.Indexes.ListAsync()).ToList() + : collection.Indexes.List().ToList(); + + Assert.Equal(3, indexList.Count); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CreateDatabase_creates_nested_index_on_owns_many_can_be_deferred(bool async) + { + var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); + + var collection = database.CreateCollection(values: async); + var context = SingleEntityDbContext.Create(collection, mb => + { + mb.Entity(p => + { + p.HasIndex(o => o.Name); + p.OwnsMany(q => q.SecondaryCertificates, q => { q.HasIndex(r => r.Name); }); + }); + }); + var databaseCreator = context.GetService(); + + var options = new MongoDatabaseCreationOptions(CreateMissingIndexes: false); + + _ = async + ? await databaseCreator.EnsureCreatedAsync(options) + : databaseCreator.EnsureCreated(options); + + var indexList = async + ? (await collection.Indexes.ListAsync()).ToList() + : collection.Indexes.List().ToList(); + + Assert.Single(indexList); + + if (async) + { + await databaseCreator.CreateMissingIndexesAsync(); + } + else + { + databaseCreator.CreateMissingIndexes(); + } + + indexList = async + ? (await collection.Indexes.ListAsync()).ToList() + : collection.Indexes.List().ToList(); + + Assert.Equal(3, indexList.Count); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CreateDatabase_creates_nested_index_on_owns_many_owns_one(bool async) + { + var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); + + var collection = database.CreateCollection(values: async); + var context = SingleEntityDbContext.Create(collection, mb => + { + mb.Entity(p => + { + p.OwnsMany(q => q.SecondaryCertificates, q => + { + q.OwnsOne(c => c.Issuer, i => + { + i.HasIndex(j => j.OrganizationName); + i.HasIndex(j => j.Country); + }); + }); + }); + }); + var databaseCreator = context.GetService(); + + _ = async + ? await databaseCreator.EnsureCreatedAsync() + : databaseCreator.EnsureCreated(); + + var indexList = async + ? (await collection.Indexes.ListAsync()).ToList() + : collection.Indexes.List().ToList(); + + Assert.Equal(3, indexList.Count); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CreateDatabase_creates_alternate_keys(bool async) + { + var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); + + var context = MyContext.CreateCollectionOptions(database.MongoDatabase, mb => + { + mb.Entity(e => + { + e.HasAlternateKey(c => c.SSN); + e.HasAlternateKey(c => c.TIN); + e.Property(c => c.SSN).HasElementName("ssn"); + }); + mb.Entity().HasAlternateKey(o => new { o.OrderRef, o.CustomerId }); + mb.Entity
().HasAlternateKey(o => o.UniqueRef); + }); + var databaseCreator = context.GetService(); + + var didCreate = async + ? await databaseCreator.EnsureCreatedAsync() + : databaseCreator.EnsureCreated(); + + Assert.True(didCreate); + + var customerIndexes = GetIndexes(database.MongoDatabase, "Customers"); + Assert.Equal(3, customerIndexes.Count); + var customerSsnKey = Assert.Single(customerIndexes, i => i["name"] == "ssn_1"); + Assert.Equal(BsonBoolean.True, customerSsnKey["unique"]); + Assert.Equal(new BsonDocument("ssn", 1), customerSsnKey["key"]); + var customerTinKey = Assert.Single(customerIndexes, i => i["name"] == "TIN_1"); + Assert.Equal(BsonBoolean.True, customerTinKey["unique"]); + Assert.Equal(new BsonDocument("TIN", 1), customerTinKey["key"]); + + var orderIndexes = GetIndexes(database.MongoDatabase, "Orders"); + Assert.Equal(2, orderIndexes.Count); + var orderAlternateKeyIndex = Assert.Single(orderIndexes, i => i["name"] == "OrderRef_1_CustomerId_1"); + Assert.Equal(BsonBoolean.True, orderAlternateKeyIndex["unique"]); + Assert.Equal(new BsonDocument { ["OrderRef"] = 1, ["CustomerId"] = 1 }, orderAlternateKeyIndex["key"]); + + var addressIndexes = GetIndexes(database.MongoDatabase, "Addresses"); + Assert.Equal(2, addressIndexes.Count); + var addressAlternateKeyIndex = Assert.Single(addressIndexes, i => i["name"] == "UniqueRef_1"); + Assert.Equal(BsonBoolean.True, addressAlternateKeyIndex["unique"]); + Assert.Equal(new BsonDocument("UniqueRef", 1), addressAlternateKeyIndex["key"]); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CreateDatabase_does_not_duplicate_indexes(bool async) + { + var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); + + + { + var context = MyContext.CreateCollectionOptions(database.MongoDatabase, + mb => + { + mb.Entity
().HasIndex(o => o.PostCode, "custom_index_name"); + mb.Entity().HasAlternateKey(o => o.OrderRef); + } + ); + var databaseCreator = context.GetService(); + + var didCreate = async + ? await databaseCreator.EnsureCreatedAsync() + : databaseCreator.EnsureCreated(); + + Assert.True(didCreate); + Assert.Single(GetIndexes(database.MongoDatabase, "Customers")); + Assert.Equal(2, GetIndexes(database.MongoDatabase, "Addresses").Count); + Assert.Equal(2, GetIndexes(database.MongoDatabase, "Orders").Count); + } + + { + var context = MyContext.CreateCollectionOptions(database.MongoDatabase, mb => + { + mb.Entity().HasIndex(c => c.Name); + mb.Entity
().HasIndex(o => o.PostCode, "custom_index_name"); + mb.Entity().HasAlternateKey(o => o.OrderRef); + }); + var databaseCreator = context.GetService(); + + var didCreate = async + ? await databaseCreator.EnsureCreatedAsync() + : databaseCreator.EnsureCreated(); + + Assert.False(didCreate); + Assert.Equal(2, GetIndexes(database.MongoDatabase, "Customers").Count); + Assert.Equal(2, GetIndexes(database.MongoDatabase, "Addresses").Count); + Assert.Equal(2, GetIndexes(database.MongoDatabase, "Orders").Count); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CreateDatabase_creates_index_from_string_named_properties(bool async) + { + var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); + + var context = MyContext.CreateCollectionOptions(database.MongoDatabase, + mb => mb.Entity
().HasIndex("PostCode")); + var databaseCreator = context.GetService(); + + _ = async + ? await databaseCreator.EnsureCreatedAsync() + : databaseCreator.EnsureCreated(); + + var indexes = GetIndexes(database.MongoDatabase, "Addresses"); + Assert.Equal(2, indexes.Count); + Assert.Single(indexes, i => i["key"].AsBsonDocument.Names.Single() == "PostCode"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CreateDatabase_creates_index_from_multiple_string_named_properties(bool async) + { + var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); + + var context = MyContext.CreateCollectionOptions(database.MongoDatabase, + mb => mb.Entity
().HasIndex("Country", "PostCode")); + var databaseCreator = context.GetService(); + + _ = async + ? await databaseCreator.EnsureCreatedAsync() + : databaseCreator.EnsureCreated(); + + var indexes = GetIndexes(database.MongoDatabase, "Addresses"); + Assert.Equal(2, indexes.Count); + + var foundIndex = Assert.Single(indexes, i => i["key"].AsBsonDocument.Names.Count() == 2); + Assert.Contains(foundIndex["key"].AsBsonDocument, key => key.Name == "Country"); + Assert.Contains(foundIndex["key"].AsBsonDocument, key => key.Name == "PostCode"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CreateDatabase_creates_index_with_descending_property(bool async) + { + var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); + + var context = MyContext.CreateCollectionOptions(database.MongoDatabase, + mb => mb.Entity
().HasIndex(a => a.PostCode).IsDescending(true)); + var databaseCreator = context.GetService(); + + _ = async + ? await databaseCreator.EnsureCreatedAsync() + : databaseCreator.EnsureCreated(); + + var indexes = GetIndexes(database.MongoDatabase, "Addresses"); + Assert.Equal(2, indexes.Count); + + var foundIndex = Assert.Single(indexes, i => i["key"].AsBsonDocument.Names.Contains("PostCode")); + Assert.Equal(-1, foundIndex["key"].AsBsonDocument["PostCode"].AsInt32); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CreateDatabase_creates_index_with_two_descending_properties(bool async) + { + var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); + + var context = MyContext.CreateCollectionOptions(database.MongoDatabase, + mb => mb.Entity
().HasIndex(a => new { a.PostCode, a.Country }).IsDescending()); + var databaseCreator = context.GetService(); + + _ = async + ? await databaseCreator.EnsureCreatedAsync() + : databaseCreator.EnsureCreated(); + + var indexes = GetIndexes(database.MongoDatabase, "Addresses"); + Assert.Equal(2, indexes.Count); + + var foundIndex = Assert.Single(indexes, i => i["key"].AsBsonDocument.Names.Contains("PostCode")); + Assert.Equal(-1, foundIndex["key"].AsBsonDocument["PostCode"].AsInt32); + Assert.Equal(-1, foundIndex["key"].AsBsonDocument["Country"].AsInt32); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CreateDatabase_creates_index_with_two_properties_mixed_sort_order(bool async) + { + var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); + + var context = MyContext.CreateCollectionOptions(database.MongoDatabase, + mb => mb.Entity
().HasIndex(a => new { a.PostCode, a.Country }).IsDescending(false, true)); + var databaseCreator = context.GetService(); + + _ = async + ? await databaseCreator.EnsureCreatedAsync() + : databaseCreator.EnsureCreated(); + + var indexes = GetIndexes(database.MongoDatabase, "Addresses"); + Assert.Equal(2, indexes.Count); + + var foundIndex = Assert.Single(indexes, i => i["key"].AsBsonDocument.Names.Contains("PostCode")); + Assert.Equal(1, foundIndex["key"].AsBsonDocument["PostCode"].AsInt32); + Assert.Equal(-1, foundIndex["key"].AsBsonDocument["Country"].AsInt32); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CreateDatabase_creates_index_with_two_properties_unique_descending(bool async) + { + var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); + + var context = MyContext.CreateCollectionOptions(database.MongoDatabase, + mb => mb.Entity
().HasIndex(a => new { a.PostCode, a.Country }).IsUnique().IsDescending()); + var databaseCreator = context.GetService(); + + _ = async + ? await databaseCreator.EnsureCreatedAsync() + : databaseCreator.EnsureCreated(); + + var indexes = GetIndexes(database.MongoDatabase, "Addresses"); + Assert.Equal(2, indexes.Count); + + var foundIndex = Assert.Single(indexes, i => i["key"].AsBsonDocument.Names.Contains("PostCode")); + Assert.Equal(-1, foundIndex["key"].AsBsonDocument["PostCode"].AsInt32); + Assert.Equal(-1, foundIndex["key"].AsBsonDocument["Country"].AsInt32); + Assert.Single(indexes, i => i.Names.Contains("unique") && i["unique"].AsBoolean); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CreateDatabase_creates_index_with_filter(bool async) + { + var filter = Builders.Filter.Eq(a => a["Country"], "UK"); + var options = new CreateIndexOptions { PartialFilterExpression = filter }; + var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); + + var context = MyContext.CreateCollectionOptions(database.MongoDatabase, + mb => mb.Entity
().HasIndex(a => a.PostCode).HasCreateIndexOptions(options)); + var databaseCreator = context.GetService(); + + _ = async + ? await databaseCreator.EnsureCreatedAsync() + : databaseCreator.EnsureCreated(); + + var indexes = database.MongoDatabase.GetCollection("Addresses").Indexes.List().ToList(); + Assert.Single(indexes, + i => i.Names.Contains("partialFilterExpression") + && i["partialFilterExpression"].ToString() == "{ \"Country\" : \"UK\" }"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CreateDatabase_creates_index_with_create_index_options(bool async) + { + var options = new CreateIndexOptions { Sparse = true, Unique = true }; + var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); + + var context = MyContext.CreateCollectionOptions(database.MongoDatabase, + mb => mb.Entity
().HasIndex(a => a.PostCode).HasCreateIndexOptions(options)); + var databaseCreator = context.GetService(); + + _ = async + ? await databaseCreator.EnsureCreatedAsync() + : databaseCreator.EnsureCreated(); + + var indexes = database.MongoDatabase.GetCollection("Addresses").Indexes.List().ToList(); + Assert.Single(indexes, + i => i.Names.Contains("sparse") && i["sparse"].AsBoolean && i.Names.Contains("unique") && i["unique"].AsBoolean); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CreateDatabase_does_not_affect_existing_collections(bool async) + { + const int expectedMaxDocs = 1024; + const int expectedMaxSize = 4096; + + var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); + + { + var collection = database.MongoDatabase.GetCollection("Customers"); + collection.InsertOne(new Customer { Name = "John Doe" }); + database.MongoDatabase.CreateCollection("Orders", + new CreateCollectionOptions { MaxDocuments = expectedMaxDocs, MaxSize = expectedMaxSize, Capped = true }); + database.MongoDatabase.CreateCollection("Orders2"); + } + + { + var context = MyContext.CreateCollectionOptions(database.MongoDatabase); + var databaseCreator = context.GetService(); + + var didCreate = async + ? await databaseCreator.EnsureCreatedAsync() + : databaseCreator.EnsureCreated(); + + Assert.False(didCreate); + var collections = database.MongoDatabase.ListCollections().ToList(); + var allNames = collections.Select(c => c["name"].AsString).ToArray(); + Assert.Equal(4, allNames.Length); + Assert.Contains("Customers", allNames); + Assert.Contains("Orders", allNames); + Assert.Contains("Addresses", allNames); + + var customerCollectionOptions = collections.Single(c => c["name"].AsString == "Orders")["options"]; + Assert.True(customerCollectionOptions["capped"].AsBoolean); + Assert.Equal(expectedMaxDocs, customerCollectionOptions["max"].AsInt32); + Assert.Equal(expectedMaxSize, customerCollectionOptions["size"].AsInt32); + + var collection = database.MongoDatabase.GetCollection("Customers"); + Assert.Equal(1, collection.CountDocuments(FilterDefinition.Empty)); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task DeleteDatabase_deletes_database(bool async) + { + var database = await TemporaryDatabaseFixture.CreateInitializedAsync(); + + var context = MyContext.CreateCollectionOptions(database.MongoDatabase); + var databaseCreator = context.GetService(); + + Assert.False(databaseCreator.DatabaseExists()); + + databaseCreator.EnsureCreated(); + Assert.True(databaseCreator.DatabaseExists()); + + _ = async ? await databaseCreator.EnsureDeletedAsync() : databaseCreator.EnsureDeleted(); + Assert.False(databaseCreator.DatabaseExists()); + } + + class MyContext( + DbContextOptions options, + Action? modelBuilderAction = null) + : DbContext(options) + { + public DbSet Customers { get; set; } + public DbSet Orders { get; set; } + public DbSet
Addresses { get; set; } + + private static DbContextOptions CreateOptions(IMongoDatabase database) + => new DbContextOptionsBuilder() + .UseMongoDB(database.Client, database.DatabaseNamespace.DatabaseName) + .ConfigureWarnings(x => x.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning)) + .ReplaceService() + .Options; + + public static MyContext CreateCollectionOptions( + IMongoDatabase database, + Action? modelBuilderAction = null) + => new(CreateOptions(database), modelBuilderAction); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilderAction?.Invoke(modelBuilder); + } + } + + class Customer + { + public ObjectId Id { get; set; } + public string Name { get; set; } = null!; + public string SSN { get; set; } = null!; + public string TIN { get; set; } = null!; + } + + class Order + { + public ObjectId Id { get; set; } + public string CustomerId { get; set; } = null!; + public string OrderRef { get; set; } = null!; + } + + class Address + { + public ObjectId Id { get; set; } + public string PostCode { get; set; } = null!; + public string Country { get; set; } = null!; + public string Region { get; set; } = null!; + public string UniqueRef { get; set; } = null!; + } + + class Product + { + public ObjectId Id { get; set; } + public string? Name { get; set; } + public Certificate? PrimaryCertificate { get; set; } + public List? SecondaryCertificates { get; set; } + } + + class Certificate + { + public string? Name { get; set; } + public Issuer? Issuer { get; set; } + } + + class Issuer + { + public string? OrganizationName { get; set; } + public string? Country { get; set; } + } + + private static List GetIndexes(IMongoDatabase database, string collectionName) + => database.GetCollection(collectionName).Indexes.List().ToList(); } From d440ef4d15fb1b19a27a46b6efca62d31d96f13b Mon Sep 17 00:00:00 2001 From: Arthur Vickers Date: Thu, 23 Oct 2025 10:28:14 +0100 Subject: [PATCH 2/4] Update src/MongoDB.EntityFrameworkCore/Storage/MongoDatabaseCreator.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/MongoDB.EntityFrameworkCore/Storage/MongoDatabaseCreator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MongoDB.EntityFrameworkCore/Storage/MongoDatabaseCreator.cs b/src/MongoDB.EntityFrameworkCore/Storage/MongoDatabaseCreator.cs index 2cd1b0bb..f3713d93 100644 --- a/src/MongoDB.EntityFrameworkCore/Storage/MongoDatabaseCreator.cs +++ b/src/MongoDB.EntityFrameworkCore/Storage/MongoDatabaseCreator.cs @@ -248,7 +248,7 @@ public void CreateMissingIndexes() { using var cursor = clientWrapper.Database.GetCollection(collectionName).Indexes.List(); indexes = cursor.ToList().Select(i => i["name"].AsString).ToList(); - existingIndexesMap[collectionName] = indexes; + existingIndexesMap[collectionName] = indexes; } BuildIndexes(entityType, collectionName, existingIndexesMap, indexModelsMap); From 37920ef7b00b988e1991f916f547c02aa1e77072 Mon Sep 17 00:00:00 2001 From: Arthur Vickers Date: Thu, 23 Oct 2025 10:28:23 +0100 Subject: [PATCH 3/4] Update src/MongoDB.EntityFrameworkCore/Storage/MongoDatabaseCreator.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/MongoDB.EntityFrameworkCore/Storage/MongoDatabaseCreator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MongoDB.EntityFrameworkCore/Storage/MongoDatabaseCreator.cs b/src/MongoDB.EntityFrameworkCore/Storage/MongoDatabaseCreator.cs index f3713d93..338b7963 100644 --- a/src/MongoDB.EntityFrameworkCore/Storage/MongoDatabaseCreator.cs +++ b/src/MongoDB.EntityFrameworkCore/Storage/MongoDatabaseCreator.cs @@ -421,7 +421,7 @@ public async Task CreateMissingIndexesAsync(CancellationToken cancellationToken { using var cursor = await clientWrapper.Database.GetCollection(collectionName).Indexes.ListAsync(cancellationToken).ConfigureAwait(false); indexes = (await cursor.ToListAsync(cancellationToken: cancellationToken).ConfigureAwait(false)).Select(i => i["name"].AsString).ToList(); - existingIndexesMap[collectionName] = indexes; + existingIndexesMap[collectionName] = indexes; } BuildIndexes(entityType, collectionName, existingIndexesMap, indexModelsMap); From 28debd18e6b7465db9bfe0c34a5751d9cee4e21a Mon Sep 17 00:00:00 2001 From: Arthur Vickers Date: Fri, 24 Oct 2025 11:46:01 +0100 Subject: [PATCH 4/4] Review updates. --- .../Metadata/MongoDatabaseCreationOptions.cs | 4 ++-- .../Storage/IMongoClientWrapper.cs | 12 ++++++------ .../Storage/IMongoDatabaseCreator.cs | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/MongoDB.EntityFrameworkCore/Metadata/MongoDatabaseCreationOptions.cs b/src/MongoDB.EntityFrameworkCore/Metadata/MongoDatabaseCreationOptions.cs index c1b338af..0de2d55c 100644 --- a/src/MongoDB.EntityFrameworkCore/Metadata/MongoDatabaseCreationOptions.cs +++ b/src/MongoDB.EntityFrameworkCore/Metadata/MongoDatabaseCreationOptions.cs @@ -19,8 +19,8 @@ namespace MongoDB.EntityFrameworkCore.Metadata; /// /// Creates a to determine which additional actions are taken when -/// or -/// +/// or +/// /// /// Creates any MongoDB database collections that do not already exist. The default is true. /// Creates any non-Atlas MongoDB indexes that do not already exist. The default is true. diff --git a/src/MongoDB.EntityFrameworkCore/Storage/IMongoClientWrapper.cs b/src/MongoDB.EntityFrameworkCore/Storage/IMongoClientWrapper.cs index baa6a771..66d39a6a 100644 --- a/src/MongoDB.EntityFrameworkCore/Storage/IMongoClientWrapper.cs +++ b/src/MongoDB.EntityFrameworkCore/Storage/IMongoClientWrapper.cs @@ -30,19 +30,19 @@ namespace MongoDB.EntityFrameworkCore.Storage; public interface IMongoClientWrapper { /// - /// The underlying . May cause the underlying client to be created. + /// The underlying . Accessing this may cause the underlying client to be created. /// - public IMongoClient Client { get; } + IMongoClient Client { get; } /// - /// The underlying . May cause the underlying client to be created. + /// The underlying . Accessing this may cause the underlying client to be created. /// - public IMongoDatabase Database { get; } + IMongoDatabase Database { get; } /// - /// Gets the name of the underlying . May cause the underlying client to be created. + /// Gets the name of the underlying . Accessing this may cause the underlying client to be created. /// - public string DatabaseName { get; } + string DatabaseName { get; } /// /// Get an for the given . diff --git a/src/MongoDB.EntityFrameworkCore/Storage/IMongoDatabaseCreator.cs b/src/MongoDB.EntityFrameworkCore/Storage/IMongoDatabaseCreator.cs index 24ed4851..ffb27b93 100644 --- a/src/MongoDB.EntityFrameworkCore/Storage/IMongoDatabaseCreator.cs +++ b/src/MongoDB.EntityFrameworkCore/Storage/IMongoDatabaseCreator.cs @@ -48,13 +48,13 @@ public interface IMongoDatabaseCreator : IDatabaseCreator Task EnsureCreatedAsync(MongoDatabaseCreationOptions options, CancellationToken cancellationToken = default); /// - /// Determine if the database already exists or not. + /// Determines if the database already exists or not. /// /// if the database exists, if it does not. bool DatabaseExists(); /// - /// Determine if the database already exists or not asynchronously. + /// Determines if the database already exists or not asynchronously. /// /// A that can be used to cancel this asynchronous request. ///