diff --git a/src/Centeva.DomainModeling.EFCore/ServiceCollectionExtensions.cs b/src/Centeva.DomainModeling.EFCore/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..649f93e --- /dev/null +++ b/src/Centeva.DomainModeling.EFCore/ServiceCollectionExtensions.cs @@ -0,0 +1,15 @@ +using Centeva.DomainModeling.Interfaces; +using Microsoft.Extensions.DependencyInjection; + +namespace Centeva.DomainModeling.EFCore; + +public static class ServiceCollectionExtensions +{ + /// + /// Add the EF Core implementation of IUnitOfWork to the DI container + /// + /// + /// + public static IServiceCollection AddUnitOfWork(this IServiceCollection services) => + services.AddScoped(); +} \ No newline at end of file diff --git a/src/Centeva.DomainModeling.EFCore/UnitOfWork.cs b/src/Centeva.DomainModeling.EFCore/UnitOfWork.cs new file mode 100644 index 0000000..cbbce09 --- /dev/null +++ b/src/Centeva.DomainModeling.EFCore/UnitOfWork.cs @@ -0,0 +1,80 @@ +using Centeva.DomainModeling.Interfaces; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; + +namespace Centeva.DomainModeling.EFCore; + +/// +/// Unit of Work implementation for Entity Framework Core. Register this with your DI container +/// as an implementation of . If using Microsoft.Extensions.DependencyInjection, +/// use AddScoped to ensure that the same instance is used throughout a single request. You can also +/// use the provided extension method. +/// +public class UnitOfWork : IUnitOfWork +{ + private readonly DbContext _dbContext; + private IDbContextTransaction? _transaction; + + public UnitOfWork(DbContext dbContext) + { + _dbContext = dbContext; + } + + public void Dispose() + { + if (_transaction == null) + { + return; + } + + _transaction.Rollback(); + _transaction.Dispose(); + _transaction = null; + } + + public void BeginTransaction() + { + if (_transaction != null) + { + return; + } + + _transaction = _dbContext.Database.BeginTransaction(); + } + + public void Commit() + { + if (_transaction == null) + { + return; + } + + _transaction.Commit(); + _transaction.Dispose(); + _transaction = null; + } + + public void Rollback() + { + if (_transaction == null) + { + return; + } + + _transaction.Rollback(); + _transaction.Dispose(); + _transaction = null; + } + + public async Task SaveAndCommitAsync(CancellationToken cancellationToken = default) + { + var result = await SaveChangesAsync(cancellationToken); + Commit(); + return result; + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return _dbContext.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/src/Centeva.DomainModeling/Interfaces/IUnitOfWork.cs b/src/Centeva.DomainModeling/Interfaces/IUnitOfWork.cs new file mode 100644 index 0000000..61b1c8f --- /dev/null +++ b/src/Centeva.DomainModeling/Interfaces/IUnitOfWork.cs @@ -0,0 +1,47 @@ +namespace Centeva.DomainModeling.Interfaces; + +/// +/// Represents a Unit of Work for managing multiple repository transactions as a single unit. +/// +/// +/// Any repository modifications that happen between and will be +/// committed together. If is called, all changes will be discarded. This is useful when +/// you are working with multiple repositories and want to ensure that all changes are committed together or not at all. +/// +public interface IUnitOfWork : IDisposable +{ + /// + /// Begin a new transaction. All changes will be rolled back if not committed. + /// + void BeginTransaction(); + + /// + /// Commit the transaction. All changes will be saved. + /// + void Commit(); + + /// + /// Rollback the transaction. All changes will be discarded. + /// + void Rollback(); + + /// + /// Save all pending changes and commit the transaction. + /// + /// A to observe while waiting for the task to complete. + /// + /// A task that represents the asynchronous save operation. The task result contains the + /// number of state entries written. + /// + Task SaveAndCommitAsync(CancellationToken cancellationToken = default); + + /// + /// Save all pending changes. Does not commit the transaction. + /// + /// A to observe while waiting for the task to complete. + /// + /// A task that represents the asynchronous save operation. The task result contains the + /// number of state entries written. + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/test/Centeva.DomainModeling.IntegrationTests/Fixtures/IntegrationTestBase.cs b/test/Centeva.DomainModeling.IntegrationTests/Fixtures/IntegrationTestBase.cs index c6e5478..1137e04 100644 --- a/test/Centeva.DomainModeling.IntegrationTests/Fixtures/IntegrationTestBase.cs +++ b/test/Centeva.DomainModeling.IntegrationTests/Fixtures/IntegrationTestBase.cs @@ -5,10 +5,10 @@ namespace Centeva.DomainModeling.IntegrationTests.Fixtures; public abstract class IntegrationTestBase : IClassFixture { - protected TestDbContext _dbContext; + protected readonly TestDbContext _dbContext; - protected Repository _personRepository; - protected Repository
_addressRepository; + protected readonly Repository _personRepository; + protected readonly Repository
_addressRepository; protected IntegrationTestBase(SharedDatabaseFixture fixture) { diff --git a/test/Centeva.DomainModeling.IntegrationTests/Fixtures/LoggerFactoryProvider.cs b/test/Centeva.DomainModeling.IntegrationTests/Fixtures/LoggerFactoryProvider.cs index 021cd43..4e8092b 100644 --- a/test/Centeva.DomainModeling.IntegrationTests/Fixtures/LoggerFactoryProvider.cs +++ b/test/Centeva.DomainModeling.IntegrationTests/Fixtures/LoggerFactoryProvider.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.Logging; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; namespace Centeva.DomainModeling.IntegrationTests.Fixtures; @@ -7,6 +8,7 @@ public class LoggerFactoryProvider public static readonly ILoggerFactory LoggerFactoryInstance = LoggerFactory.Create(builder => { builder.AddFilter("Centeva.DomainModeling", LogLevel.Debug); + builder.AddFilter(DbLoggerCategory.Database.Transaction.Name, LogLevel.Debug); builder.AddConsole(); }); } \ No newline at end of file diff --git a/test/Centeva.DomainModeling.IntegrationTests/UnitOfWorkTests/RollbackTests.cs b/test/Centeva.DomainModeling.IntegrationTests/UnitOfWorkTests/RollbackTests.cs new file mode 100644 index 0000000..2aa96aa --- /dev/null +++ b/test/Centeva.DomainModeling.IntegrationTests/UnitOfWorkTests/RollbackTests.cs @@ -0,0 +1,56 @@ +using Centeva.DomainModeling.EFCore; +using Centeva.DomainModeling.IntegrationTests.Fixtures; +using Centeva.DomainModeling.UnitTests.Fixtures.Entities; +using Centeva.DomainModeling.UnitTests.Fixtures.Seeds; + +namespace Centeva.DomainModeling.IntegrationTests.UnitOfWorkTests; + +public class RollbackTests : IntegrationTestBase +{ + private readonly UnitOfWork _unitOfWork; + + public RollbackTests(SharedDatabaseFixture fixture) : base(fixture) + { + _unitOfWork = new UnitOfWork(_dbContext); + } + + [Fact] + public async Task RollbackAfterAdd_DoesNotPersistChanges() + { + var person = new Person(Guid.NewGuid(), "Test"); + + _unitOfWork.BeginTransaction(); + await _personRepository.AddAsync(person); + _unitOfWork.Rollback(); + + _dbContext.People.FirstOrDefault(x => x.Id == person.Id).Should().BeNull(); + } + + [Fact] + public async Task RollbackAfterDelete_DoesNotPersist() + { + var person = new Person(Guid.NewGuid(), "Test"); + await _personRepository.AddAsync(person); + + _unitOfWork.BeginTransaction(); + await _personRepository.DeleteAsync(person); + _unitOfWork.Rollback(); + + _dbContext.People.FirstOrDefault(x => x.Id == person.Id).Should().NotBeNull(); + } + + [Fact] + public async Task RollbackAfterUpdate_DoesNotPersist() + { + var person = await _personRepository.GetByIdAsync(PersonSeed.ValidPersonId); + var originalName = person!.Name; + + _unitOfWork.BeginTransaction(); + person.Name = "New Name"; + await _personRepository.UpdateAsync(person); + _unitOfWork.Rollback(); + + _dbContext.People.FirstOrDefault(x => x.Id == PersonSeed.ValidPersonId)! + .Name.Should().Be(originalName); + } +} \ No newline at end of file diff --git a/test/Centeva.DomainModeling.UnitTests/Fixtures/Entities/Person.cs b/test/Centeva.DomainModeling.UnitTests/Fixtures/Entities/Person.cs index b165172..68f8941 100644 --- a/test/Centeva.DomainModeling.UnitTests/Fixtures/Entities/Person.cs +++ b/test/Centeva.DomainModeling.UnitTests/Fixtures/Entities/Person.cs @@ -2,7 +2,7 @@ public class Person : BaseEntity { - public string Name { get; init; } + public string Name { get; set; } public List
Addresses { get; init; } = new(); public Person(Guid id, string name) diff --git a/test/Centeva.DomainModeling.UnitTests/Fixtures/Specs/PersonByNameCaseInsensitiveSpec.cs b/test/Centeva.DomainModeling.UnitTests/Fixtures/Specs/PersonByNameCaseInsensitiveSpec.cs new file mode 100644 index 0000000..fa9c929 --- /dev/null +++ b/test/Centeva.DomainModeling.UnitTests/Fixtures/Specs/PersonByNameCaseInsensitiveSpec.cs @@ -0,0 +1,12 @@ +using Ardalis.Specification; +using Centeva.DomainModeling.UnitTests.Fixtures.Entities; + +namespace Centeva.DomainModeling.UnitTests.Fixtures.Specs; +public class PersonByNameCaseInsensitiveSpec : Specification, ISingleResultSpecification +{ + public PersonByNameCaseInsensitiveSpec(string name) + { + Query + .Where(x => x.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase)); + } +}