diff --git a/framework/src/Volo.Abp.MongoDB/Volo.Abp.MongoDB.csproj b/framework/src/Volo.Abp.MongoDB/Volo.Abp.MongoDB.csproj index 2b9e46f43d..8d2942c145 100644 --- a/framework/src/Volo.Abp.MongoDB/Volo.Abp.MongoDB.csproj +++ b/framework/src/Volo.Abp.MongoDB/Volo.Abp.MongoDB.csproj @@ -15,7 +15,7 @@ - + diff --git a/framework/src/Volo.Abp.MongoDB/Volo/Abp/Domain/Repositories/MongoDB/MongoDbRepository.cs b/framework/src/Volo.Abp.MongoDB/Volo/Abp/Domain/Repositories/MongoDB/MongoDbRepository.cs index 8676ac0f34..06ed98846d 100644 --- a/framework/src/Volo.Abp.MongoDB/Volo/Abp/Domain/Repositories/MongoDB/MongoDbRepository.cs +++ b/framework/src/Volo.Abp.MongoDB/Volo/Abp/Domain/Repositories/MongoDB/MongoDbRepository.cs @@ -29,6 +29,8 @@ namespace Volo.Abp.Domain.Repositories.MongoDB public virtual IMongoDatabase Database => DbContext.Database; + public virtual IClientSessionHandle SessionHandle => DbContext.SessionHandle; + public virtual TMongoDbContext DbContext => DbContextProvider.GetDbContext(); protected IMongoDbContextProvider DbContextProvider { get; } @@ -60,10 +62,21 @@ namespace Volo.Abp.Domain.Repositories.MongoDB { await ApplyAbpConceptsForAddedEntityAsync(entity); - await Collection.InsertOneAsync( - entity, - cancellationToken: GetCancellationToken(cancellationToken) - ); + if (SessionHandle != null) + { + await Collection.InsertOneAsync( + SessionHandle, + entity, + cancellationToken: GetCancellationToken(cancellationToken) + ); + } + else + { + await Collection.InsertOneAsync( + entity, + cancellationToken: GetCancellationToken(cancellationToken) + ); + } return entity; } @@ -88,12 +101,25 @@ namespace Volo.Abp.Domain.Repositories.MongoDB await TriggerDomainEventsAsync(entity); var oldConcurrencyStamp = SetNewConcurrencyStamp(entity); + ReplaceOneResult result; - var result = await Collection.ReplaceOneAsync( - CreateEntityFilter(entity, true, oldConcurrencyStamp), - entity, - cancellationToken: GetCancellationToken(cancellationToken) - ); + if (SessionHandle != null) + { + result = await Collection.ReplaceOneAsync( + SessionHandle, + CreateEntityFilter(entity, true, oldConcurrencyStamp), + entity, + cancellationToken: GetCancellationToken(cancellationToken) + ); + } + else + { + result = await Collection.ReplaceOneAsync( + CreateEntityFilter(entity, true, oldConcurrencyStamp), + entity, + cancellationToken: GetCancellationToken(cancellationToken) + ); + } if (result.MatchedCount <= 0) { @@ -114,11 +140,25 @@ namespace Volo.Abp.Domain.Repositories.MongoDB if (entity is ISoftDelete softDeleteEntity && !IsHardDeleted(entity)) { softDeleteEntity.IsDeleted = true; - var result = await Collection.ReplaceOneAsync( - CreateEntityFilter(entity, true, oldConcurrencyStamp), - entity, - cancellationToken: GetCancellationToken(cancellationToken) - ); + ReplaceOneResult result; + + if (SessionHandle != null) + { + result = await Collection.ReplaceOneAsync( + SessionHandle, + CreateEntityFilter(entity, true, oldConcurrencyStamp), + entity, + cancellationToken: GetCancellationToken(cancellationToken) + ); + } + else + { + result = await Collection.ReplaceOneAsync( + CreateEntityFilter(entity, true, oldConcurrencyStamp), + entity, + cancellationToken: GetCancellationToken(cancellationToken) + ); + } if (result.MatchedCount <= 0) { @@ -127,10 +167,23 @@ namespace Volo.Abp.Domain.Repositories.MongoDB } else { - var result = await Collection.DeleteOneAsync( - CreateEntityFilter(entity, true, oldConcurrencyStamp), - GetCancellationToken(cancellationToken) - ); + DeleteResult result; + + if (SessionHandle != null) + { + result = await Collection.DeleteOneAsync( + SessionHandle, + CreateEntityFilter(entity, true, oldConcurrencyStamp), + cancellationToken: GetCancellationToken(cancellationToken) + ); + } + else + { + result = await Collection.DeleteOneAsync( + CreateEntityFilter(entity, true, oldConcurrencyStamp), + GetCancellationToken(cancellationToken) + ); + } if (result.DeletedCount <= 0) { @@ -404,6 +457,13 @@ namespace Volo.Abp.Domain.Repositories.MongoDB bool includeDetails = true, CancellationToken cancellationToken = default) { + if (SessionHandle != null) + { + return await Collection + .Find(SessionHandle, RepositoryFilterer.CreateEntityFilter(id, true)) + .FirstOrDefaultAsync(GetCancellationToken(cancellationToken)); + } + return await Collection .Find(RepositoryFilterer.CreateEntityFilter(id, true)) .FirstOrDefaultAsync(GetCancellationToken(cancellationToken)); diff --git a/framework/src/Volo.Abp.MongoDB/Volo/Abp/MongoDB/AbpMongoDbContext.cs b/framework/src/Volo.Abp.MongoDB/Volo/Abp/MongoDB/AbpMongoDbContext.cs index bfa4b87da1..0dea1710d9 100644 --- a/framework/src/Volo.Abp.MongoDB/Volo/Abp/MongoDB/AbpMongoDbContext.cs +++ b/framework/src/Volo.Abp.MongoDB/Volo/Abp/MongoDB/AbpMongoDbContext.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using MongoDB.Bson; using MongoDB.Driver; using Volo.Abp.DependencyInjection; @@ -10,18 +11,23 @@ namespace Volo.Abp.MongoDB public IMongoDatabase Database { get; private set; } + public IClientSessionHandle SessionHandle { get; private set; } + protected internal virtual void CreateModel(IMongoModelBuilder modelBuilder) { } - public virtual void InitializeDatabase(IMongoDatabase database) + public virtual void InitializeDatabase(IMongoDatabase database, IClientSessionHandle sessionHandle) { Database = database; + SessionHandle = sessionHandle; } public virtual IMongoCollection Collection() { + CreateCollectionIfNotExists(); + return Database.GetCollection(GetCollectionName()); } @@ -41,5 +47,23 @@ namespace Volo.Abp.MongoDB return model; } + + protected virtual void CreateCollectionIfNotExists() + { + var collectionName = GetCollectionName(); + + if (!CollectionExists(collectionName)) + { + Database.CreateCollection(collectionName); + } + } + + protected virtual bool CollectionExists(string collectionName) + { + var filter = new BsonDocument("name", collectionName); + var options = new ListCollectionNamesOptions { Filter = filter }; + + return Database.ListCollectionNames(options).Any(); + } } -} \ No newline at end of file +} diff --git a/framework/src/Volo.Abp.MongoDB/Volo/Abp/MongoDB/IAbpMongoDbContext.cs b/framework/src/Volo.Abp.MongoDB/Volo/Abp/MongoDB/IAbpMongoDbContext.cs index a2f48fd323..c4049e1389 100644 --- a/framework/src/Volo.Abp.MongoDB/Volo/Abp/MongoDB/IAbpMongoDbContext.cs +++ b/framework/src/Volo.Abp.MongoDB/Volo/Abp/MongoDB/IAbpMongoDbContext.cs @@ -7,5 +7,7 @@ namespace Volo.Abp.MongoDB IMongoDatabase Database { get; } IMongoCollection Collection(); + + IClientSessionHandle SessionHandle { get; } } -} \ No newline at end of file +} diff --git a/framework/src/Volo.Abp.MongoDB/Volo/Abp/Uow/MongoDB/MongoDbTransactionApi.cs b/framework/src/Volo.Abp.MongoDB/Volo/Abp/Uow/MongoDB/MongoDbTransactionApi.cs new file mode 100644 index 0000000000..6c3ab76ee5 --- /dev/null +++ b/framework/src/Volo.Abp.MongoDB/Volo/Abp/Uow/MongoDB/MongoDbTransactionApi.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MongoDB.Driver; + +namespace Volo.Abp.Uow.MongoDB +{ + public class MongoDbTransactionApi : ITransactionApi, ISupportsRollback + { + public IClientSessionHandle SessionHandle { get; } + + public MongoDbTransactionApi(IClientSessionHandle sessionHandle) + { + SessionHandle = sessionHandle; + } + + public async Task CommitAsync() + { + await SessionHandle.CommitTransactionAsync(); + } + + protected void Commit() + { + SessionHandle.CommitTransaction(); + } + + public void Dispose() + { + SessionHandle.Dispose(); + } + + public void Rollback() + { + SessionHandle.AbortTransaction(); + } + + public async Task RollbackAsync(CancellationToken cancellationToken) + { + await SessionHandle.AbortTransactionAsync(cancellationToken); + } + } +} diff --git a/framework/src/Volo.Abp.MongoDB/Volo/Abp/Uow/MongoDB/UnitOfWorkMongoDbContextProvider.cs b/framework/src/Volo.Abp.MongoDB/Volo/Abp/Uow/MongoDB/UnitOfWorkMongoDbContextProvider.cs index 0d527efc4c..893d80488d 100644 --- a/framework/src/Volo.Abp.MongoDB/Volo/Abp/Uow/MongoDB/UnitOfWorkMongoDbContextProvider.cs +++ b/framework/src/Volo.Abp.MongoDB/Volo/Abp/Uow/MongoDB/UnitOfWorkMongoDbContextProvider.cs @@ -1,5 +1,6 @@ using System; using Microsoft.Extensions.DependencyInjection; +using MongoDB.Bson; using MongoDB.Driver; using Volo.Abp.Data; using Volo.Abp.MongoDB; @@ -25,7 +26,8 @@ namespace Volo.Abp.Uow.MongoDB var unitOfWork = _unitOfWorkManager.Current; if (unitOfWork == null) { - throw new AbpException($"A {nameof(IMongoDatabase)} instance can only be created inside a unit of work!"); + throw new AbpException( + $"A {nameof(IMongoDatabase)} instance can only be created inside a unit of work!"); } var connectionString = _connectionStringResolver.Resolve(); @@ -41,18 +43,42 @@ namespace Volo.Abp.Uow.MongoDB //TODO: Create only single MongoDbClient per connection string in an application (extract MongoClientCache for example). var databaseApi = unitOfWork.GetOrAddDatabaseApi( dbContextKey, - () => + () => new MongoDbDatabaseApi(CreateDbContext(unitOfWork,mongoUrl,databaseName))); + + return ((MongoDbDatabaseApi) databaseApi).DbContext; + } + + private TMongoDbContext CreateDbContext(IUnitOfWork unitOfWork, MongoUrl mongoUrl, string databaseName) + { + var client = new MongoClient(mongoUrl); + var database = client.GetDatabase(databaseName); + var dbContext = unitOfWork.ServiceProvider.GetRequiredService(); + + if (unitOfWork.Options.IsTransactional) + { + var session = client.StartSession(); + + if (unitOfWork.Options.Timeout.HasValue) { - var database = new MongoClient(mongoUrl).GetDatabase(databaseName); + session.AdvanceOperationTime(new BsonTimestamp(unitOfWork.Options.Timeout.Value)); + } - var dbContext = unitOfWork.ServiceProvider.GetRequiredService(); + session.StartTransaction(); - dbContext.ToAbpMongoDbContext().InitializeDatabase(database); + var transactionApiKey = $"MongoDb_{mongoUrl}"; + unitOfWork.AddTransactionApi( + transactionApiKey, + new MongoDbTransactionApi(session) + ); - return new MongoDbDatabaseApi(dbContext); - }); + dbContext.ToAbpMongoDbContext().InitializeDatabase(database, session); + } + else + { + dbContext.ToAbpMongoDbContext().InitializeDatabase(database, null); + } - return ((MongoDbDatabaseApi)databaseApi).DbContext; + return dbContext; } } -} \ No newline at end of file +} diff --git a/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/AbpMongoDbTestModule.cs b/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/AbpMongoDbTestModule.cs index cfa7b033b7..f2b04a3a7f 100644 --- a/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/AbpMongoDbTestModule.cs +++ b/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/AbpMongoDbTestModule.cs @@ -16,9 +16,11 @@ namespace Volo.Abp.MongoDB { public override void ConfigureServices(ServiceConfigurationContext context) { - var connectionString = MongoDbFixture.ConnectionString.EnsureEndsWith('/') + + var stringArray = MongoDbFixture.ConnectionString.Split('?'); + + var connectionString = stringArray[0].EnsureEndsWith('/') + "Db_" + - Guid.NewGuid().ToString("N"); + Guid.NewGuid().ToString("N") + "/?" + stringArray[1]; Configure(options => { diff --git a/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/MongoClientExtension.cs b/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/MongoClientExtension.cs new file mode 100644 index 0000000000..8b7508dabf --- /dev/null +++ b/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/MongoClientExtension.cs @@ -0,0 +1,84 @@ +using System; +using System.Threading; +using MongoDB.Driver; + +namespace Volo.Abp.MongoDB +{ + /// + /// TODO It can be removed, when Mongo2Go solves this issue : https://github.com/Mongo2Go/Mongo2Go/issues/89 + /// + internal static class MongoClientExtension + { + private static readonly TimeSpan InitialDelay = TimeSpan.FromMilliseconds(500); + private static readonly TimeSpan MaxDelay = TimeSpan.FromSeconds(5000); + + + public static void EnsureReplicationSetReady(this IMongoClient mongoClient) + { + var delay = InitialDelay; + var database = mongoClient.GetDatabase("__dummy-db"); + try + { + while (true) + { + try + { + _ = database.GetCollection("__dummy"); + database.DropCollection("__dummy"); + + var session = mongoClient.StartSession(); + + try + { + session.StartTransaction(); + session.AbortTransaction(); + } + finally + { + session.Dispose(); + } + break; + } + catch (NotSupportedException) { } + + Thread.Sleep(delay); + delay = Min(Double(delay), MaxDelay); + } + } + finally + { + mongoClient.DropDatabase("__dummy-db"); + } + } + + private static TimeSpan Min(TimeSpan left, TimeSpan right) + { + return new TimeSpan(Math.Min(left.Ticks, right.Ticks)); + } + + private static TimeSpan Double(TimeSpan timeSpan) + { + long ticks; + try + { + ticks = checked(timeSpan.Ticks * 2); + } + catch (OverflowException) + { + if (timeSpan.Ticks >= 0) + { + return TimeSpan.MaxValue; + } + + return TimeSpan.MinValue; + } + + return new TimeSpan(ticks); + } + + private sealed class DummyEntry + { + public int Id { get; set; } + } + } +} diff --git a/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/MongoDbFixture.cs b/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/MongoDbFixture.cs index 3fa239ebbb..dbb872bee6 100644 --- a/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/MongoDbFixture.cs +++ b/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/MongoDbFixture.cs @@ -1,5 +1,6 @@ using System; using Mongo2Go; +using MongoDB.Driver; namespace Volo.Abp.MongoDB { @@ -10,8 +11,12 @@ namespace Volo.Abp.MongoDB static MongoDbFixture() { - MongoDbRunner = MongoDbRunner.Start(); + MongoDbRunner = MongoDbRunner.Start(singleNodeReplSet: true, singleNodeReplSetWaitTimeout: 10); ConnectionString = MongoDbRunner.ConnectionString; + + //TODO It can be removed, when Mongo2Go solves this issue : https://github.com/Mongo2Go/Mongo2Go/issues/89 + var client = new MongoClient(MongoDbRunner.ConnectionString); + client.EnsureReplicationSetReady(); } public void Dispose() diff --git a/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Transactions/Transaction_Tests.cs b/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Transactions/Transaction_Tests.cs new file mode 100644 index 0000000000..6c82b44f93 --- /dev/null +++ b/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Transactions/Transaction_Tests.cs @@ -0,0 +1,95 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.TestApp.Domain; +using Volo.Abp.TestApp.Testing; +using Volo.Abp.Uow; +using Xunit; + +namespace Volo.Abp.MongoDB.Transactions +{ + public class Transaction_Tests : TestAppTestBase + { + + private readonly IBasicRepository _personRepository; + private readonly IBasicRepository _cityRepository; + private readonly IUnitOfWorkManager _unitOfWorkManager; + + public Transaction_Tests() + { + _personRepository = ServiceProvider.GetRequiredService>(); + _cityRepository = ServiceProvider.GetRequiredService>(); + _unitOfWorkManager = ServiceProvider.GetRequiredService(); + } + + [Fact] + public async Task Should_Rollback_Transaction_When_An_Exception_Is_Thrown() + { + var personId = Guid.NewGuid(); + const string exceptionMessage = "thrown to rollback the transaction!"; + + try + { + await WithUnitOfWorkAsync(new AbpUnitOfWorkOptions {IsTransactional = true}, async () => + { + await _personRepository.InsertAsync(new Person(personId, "Adam", 42)); + throw new Exception(exceptionMessage); + }); + } + catch (Exception e) when (e.Message == exceptionMessage) + { + + } + + var person = await _personRepository.FindAsync(personId); + person.ShouldBeNull(); + } + + [Fact] + public async Task Should_Rollback_Transaction_Manually() + { + var personId = Guid.NewGuid(); + + await WithUnitOfWorkAsync(new AbpUnitOfWorkOptions {IsTransactional = true}, async () => + { + _unitOfWorkManager.Current.ShouldNotBeNull(); + + await _personRepository.InsertAsync(new Person(personId, "Adam", 42)); + + await _unitOfWorkManager.Current.RollbackAsync(); + }); + + var person = await _personRepository.FindAsync(personId); + person.ShouldBeNull(); + } + + [Fact] + public async Task Should_Rollback_Transaction_Manually_With_Double_DbContext_Transaction() + { + var personId = Guid.NewGuid(); + var cityId = Guid.NewGuid(); + + using (var scope = ServiceProvider.CreateScope()) + { + var uowManager = scope.ServiceProvider.GetRequiredService(); + + using (uowManager.Begin(new AbpUnitOfWorkOptions {IsTransactional = true})) + { + _unitOfWorkManager.Current.ShouldNotBeNull(); + + await _personRepository.InsertAsync(new Person(personId, "Adam", 42)); + await _cityRepository.InsertAsync(new City(cityId, cityId.ToString())); + + await _unitOfWorkManager.Current.SaveChangesAsync(); + + //Will automatically rollback since not called the Complete! + } + } + + (await _personRepository.FindAsync(personId)).ShouldBeNull(); + (await _cityRepository.FindAsync(cityId)).ShouldBeNull(); + } + } +}