diff --git a/Directory.Packages.props b/Directory.Packages.props index 4664a2e..7fd7d12 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,5 +9,11 @@ + + + + + + diff --git a/HrynCo.DAL.EF.Tests/EfUnitOfWorkTests.cs b/HrynCo.DAL.EF.Tests/EfUnitOfWorkTests.cs new file mode 100644 index 0000000..9e392ce --- /dev/null +++ b/HrynCo.DAL.EF.Tests/EfUnitOfWorkTests.cs @@ -0,0 +1,213 @@ +namespace HrynCo.DAL.EF.Tests; + +using HrynCo.DAL.Abstract; +using Microsoft.EntityFrameworkCore; +using Xunit; + +public sealed class EfUnitOfWorkTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ActionFailure_ReportsBothExceptionsWhenRollbackFails(bool returnsResult) + { + var original = new InvalidOperationException("Action failed."); + var rollbackFailure = new InvalidOperationException("Transaction completed."); + var interceptor = new TransactionFailureInterceptor + { + RollbackException = rollbackFailure + }; + await using DbContext context = await CreateContextAsync(interceptor); + var unitOfWork = new TestUnitOfWork(context); + + AggregateException actual = await Assert.ThrowsAsync(() => + ExecuteAsync(unitOfWork, returnsResult, () => Task.FromException(original))); + + Assert.Collection(actual.InnerExceptions, + error => Assert.Same(original, error), + error => Assert.Same(rollbackFailure, error)); + Assert.Equal(1, interceptor.RollbackAttempts); + Assert.Null(unitOfWork.GetCurrentTransaction()); + Assert.Null(context.Database.CurrentTransaction); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CommitFailure_ReportsBothExceptionsWhenRollbackFails(bool returnsResult) + { + var original = new InvalidOperationException("Commit failed."); + var rollbackFailure = new InvalidOperationException("Transaction completed."); + var interceptor = new TransactionFailureInterceptor + { + CommitException = original, + RollbackException = rollbackFailure + }; + await using DbContext context = await CreateContextAsync(interceptor); + var unitOfWork = new TestUnitOfWork(context); + + AggregateException actual = await Assert.ThrowsAsync(() => + ExecuteAsync(unitOfWork, returnsResult, () => Task.CompletedTask)); + + Assert.Collection(actual.InnerExceptions, + error => Assert.Same(original, error), + error => Assert.Same(rollbackFailure, error)); + Assert.Equal(1, interceptor.CommitAttempts); + Assert.Equal(1, interceptor.RollbackAttempts); + Assert.Null(unitOfWork.GetCurrentTransaction()); + Assert.Null(context.Database.CurrentTransaction); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CompletedTransaction_ReportsActionAndRollbackExceptions(bool returnsResult) + { + await using DbContext context = await CreateContextAsync(); + var unitOfWork = new TestUnitOfWork(context); + var original = new InvalidOperationException("Action failed after the provider completed the transaction."); + + AggregateException actual = await Assert.ThrowsAsync(() => + ExecuteAsync(unitOfWork, returnsResult, async () => + { + await context.Database.CurrentTransaction!.CommitAsync(); + throw original; + })); + + Assert.Same(original, actual.InnerExceptions[0]); + Assert.IsType(actual.InnerExceptions[1]); + Assert.Null(unitOfWork.GetCurrentTransaction()); + Assert.Null(context.Database.CurrentTransaction); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ActionFailure_RollsBackWritesAndAllowsNextTransaction(bool returnsResult) + { + await using DbContext context = await CreateContextAsync(); + var unitOfWork = new TestUnitOfWork(context); + var original = new InvalidOperationException("Action failed."); + + Exception actual = await Assert.ThrowsAsync(() => + ExecuteAsync(unitOfWork, returnsResult, async () => + { + await context.Database.ExecuteSqlRawAsync("INSERT INTO records VALUES (1)"); + throw original; + })); + + Assert.Same(original, actual); + Assert.Equal(0, await CountRecordsAsync(context)); + Assert.Null(unitOfWork.GetCurrentTransaction()); + await ExecuteAsync(unitOfWork, returnsResult, + () => context.Database.ExecuteSqlRawAsync("INSERT INTO records VALUES (2)")); + Assert.Equal(1, await CountRecordsAsync(context)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Success_CommitsWritesAndCreatesFreshTransactionForNextCall(bool returnsResult) + { + var interceptor = new TransactionFailureInterceptor(); + await using DbContext context = await CreateContextAsync(interceptor); + var unitOfWork = new TestUnitOfWork(context); + ITransaction? first = null; + + await ExecuteAsync(unitOfWork, returnsResult, async () => + { + first = unitOfWork.GetCurrentTransaction(); + await context.Database.ExecuteSqlRawAsync("INSERT INTO records VALUES (1)"); + }); + Assert.Null(unitOfWork.GetCurrentTransaction()); + Assert.Null(context.Database.CurrentTransaction); + await ExecuteAsync(unitOfWork, returnsResult, async () => + { + Assert.NotSame(first, unitOfWork.GetCurrentTransaction()); + await context.Database.ExecuteSqlRawAsync("INSERT INTO records VALUES (2)"); + }); + + Assert.Equal(2, await CountRecordsAsync(context)); + Assert.Equal(2, interceptor.CommitAttempts); + Assert.Equal(0, interceptor.RollbackAttempts); + Assert.Null(unitOfWork.GetCurrentTransaction()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CallerOwnedTransaction_SuccessLeavesLifecycleToCaller(bool returnsResult) + { + var interceptor = new TransactionFailureInterceptor(); + await using DbContext context = await CreateContextAsync(interceptor); + var unitOfWork = new TestUnitOfWork(context); + await using ITransaction transaction = await unitOfWork.BeginTransactionAsync(); + + await ExecuteAsync(unitOfWork, returnsResult, + () => context.Database.ExecuteSqlRawAsync("INSERT INTO records VALUES (1)")); + + Assert.Same(transaction, unitOfWork.GetCurrentTransaction()); + Assert.NotNull(context.Database.CurrentTransaction); + Assert.Equal(0, interceptor.CommitAttempts); + Assert.Equal(0, interceptor.RollbackAttempts); + await transaction.RollbackAsync(); + Assert.Equal(0, await CountRecordsAsync(context)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CallerOwnedTransaction_FailureLeavesLifecycleToCaller(bool returnsResult) + { + var interceptor = new TransactionFailureInterceptor(); + await using DbContext context = await CreateContextAsync(interceptor); + var unitOfWork = new TestUnitOfWork(context); + await using ITransaction transaction = await unitOfWork.BeginTransactionAsync(); + var original = new InvalidOperationException("Action failed."); + + Exception actual = await Assert.ThrowsAsync(() => + ExecuteAsync(unitOfWork, returnsResult, () => Task.FromException(original))); + + Assert.Same(original, actual); + Assert.Same(transaction, unitOfWork.GetCurrentTransaction()); + Assert.NotNull(context.Database.CurrentTransaction); + Assert.Equal(0, interceptor.CommitAttempts); + Assert.Equal(0, interceptor.RollbackAttempts); + await transaction.RollbackAsync(); + } + + private static async Task ExecuteAsync(TestUnitOfWork unitOfWork, bool returnsResult, Func action) + { + if (returnsResult) + { + int result = await unitOfWork.ExecuteInTransactionAsync(async () => + { + await action(); + return 42; + }); + Assert.Equal(42, result); + return; + } + + await unitOfWork.ExecuteInTransactionAsync(action); + } + + private static async Task CreateContextAsync(TransactionFailureInterceptor? interceptor = null) + { + var options = new DbContextOptionsBuilder().UseSqlite("Data Source=:memory:"); + if (interceptor is not null) + { + options.AddInterceptors(interceptor); + } + + var context = new DbContext(options.Options); + await context.Database.OpenConnectionAsync(); + await context.Database.ExecuteSqlRawAsync("CREATE TABLE records (id INTEGER NOT NULL)"); + return context; + } + + private static Task CountRecordsAsync(DbContext context) + { + return context.Database.SqlQueryRaw("SELECT COUNT(*) AS Value FROM records").SingleAsync(); + } +} diff --git a/HrynCo.DAL.EF.Tests/HrynCo.DAL.EF.Tests.csproj b/HrynCo.DAL.EF.Tests/HrynCo.DAL.EF.Tests.csproj new file mode 100644 index 0000000..d852dd9 --- /dev/null +++ b/HrynCo.DAL.EF.Tests/HrynCo.DAL.EF.Tests.csproj @@ -0,0 +1,20 @@ + + + net10.0 + enable + enable + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/HrynCo.DAL.EF.Tests/TestUnitOfWork.cs b/HrynCo.DAL.EF.Tests/TestUnitOfWork.cs new file mode 100644 index 0000000..bc77e68 --- /dev/null +++ b/HrynCo.DAL.EF.Tests/TestUnitOfWork.cs @@ -0,0 +1,6 @@ +namespace HrynCo.DAL.EF.Tests; + +using HrynCo.DAL.EF.Core; +using Microsoft.EntityFrameworkCore; + +internal sealed class TestUnitOfWork(DbContext context) : EfUnitOfWork(context); diff --git a/HrynCo.DAL.EF.Tests/TransactionFailureInterceptor.cs b/HrynCo.DAL.EF.Tests/TransactionFailureInterceptor.cs new file mode 100644 index 0000000..19c3b51 --- /dev/null +++ b/HrynCo.DAL.EF.Tests/TransactionFailureInterceptor.cs @@ -0,0 +1,42 @@ +namespace HrynCo.DAL.EF.Tests; + +using System.Data.Common; +using Microsoft.EntityFrameworkCore.Diagnostics; + +internal sealed class TransactionFailureInterceptor : DbTransactionInterceptor +{ + public Exception? CommitException { get; init; } + public Exception? RollbackException { get; init; } + public int CommitAttempts { get; private set; } + public int RollbackAttempts { get; private set; } + + public override ValueTask TransactionCommittingAsync( + DbTransaction transaction, + TransactionEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + CommitAttempts++; + if (CommitException is not null) + { + throw CommitException; + } + + return ValueTask.FromResult(result); + } + + public override ValueTask TransactionRollingBackAsync( + DbTransaction transaction, + TransactionEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + RollbackAttempts++; + if (RollbackException is not null) + { + throw RollbackException; + } + + return ValueTask.FromResult(result); + } +} diff --git a/HrynCo.DAL.EF/Core/EfUnitOfWork.cs b/HrynCo.DAL.EF/Core/EfUnitOfWork.cs index 3aa272c..43aae4e 100644 --- a/HrynCo.DAL.EF/Core/EfUnitOfWork.cs +++ b/HrynCo.DAL.EF/Core/EfUnitOfWork.cs @@ -42,11 +42,11 @@ public class EfUnitOfWork : IUnitOfWork await tx.CommitAsync(); } } - catch + catch (Exception original) { if (ownsTransaction) { - await tx.RollbackAsync(); + await RollbackPreservingFailureAsync(tx, original); } throw; @@ -55,7 +55,14 @@ public class EfUnitOfWork : IUnitOfWork { if (ownsTransaction) { - await tx.DisposeAsync(); + try + { + await tx.DisposeAsync(); + } + finally + { + _currentTransaction = null; + } } } } @@ -77,11 +84,11 @@ public class EfUnitOfWork : IUnitOfWork return result; } - catch + catch (Exception original) { if (ownsTransaction) { - await tx.RollbackAsync(); + await RollbackPreservingFailureAsync(tx, original); } throw; @@ -90,11 +97,30 @@ public class EfUnitOfWork : IUnitOfWork { if (ownsTransaction) { - await tx.DisposeAsync(); + try + { + await tx.DisposeAsync(); + } + finally + { + _currentTransaction = null; + } } } } + private static async Task RollbackPreservingFailureAsync(ITransaction transaction, Exception original) + { + try + { + await transaction.RollbackAsync(); + } + catch (Exception rollbackFailure) + { + throw new AggregateException("The transaction and its rollback both failed.", original, rollbackFailure); + } + } + public ITransaction? GetCurrentTransaction() { return _currentTransaction; diff --git a/README.md b/README.md index 8e5903f..1aa05ee 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,13 @@ Reusable Entity Framework Core base library for HrynCo applications. ## Solution -The solution (`hrynco-ef.slnx`) contains two projects: +The solution (`hrynco-ef.slnx`) contains the following projects: | Project | Description | |---|---| | `HrynCo.DAL.Abstract` | Infrastructure-agnostic contracts: entities, repository interfaces, unit of work, transactions, pagination. No EF Core dependency. | | `HrynCo.DAL.EF` | Entity Framework Core implementations of the abstract contracts. Depends on `HrynCo.DAL.Abstract` and EF Core. | +| `HrynCo.DAL.EF.Tests` | Transaction lifecycle regression tests using SQLite in memory. | The split allows consuming projects to reference only `HrynCo.DAL.Abstract` in domain/application layers, keeping those layers free of EF Core. @@ -181,6 +182,18 @@ Entity Framework Core implementations of the abstract contracts. ## Usage +### Transaction ownership and failures + +Both `ExecuteInTransactionAsync` overloads create, commit, and dispose a transaction when no transaction is already cached by the unit of work. After disposal, the cached reference is cleared, so a later call starts a fresh transaction. + +If the action or commit fails, the owned transaction is rolled back. If rollback also fails (including when the provider reports a completed transaction), an `AggregateException` contains the original failure first and the rollback failure second. Disposal still runs. + +When `BeginTransactionAsync` has already supplied a transaction to the caller, `ExecuteInTransactionAsync` runs the action inside that transaction and leaves commit, rollback, and disposal to the caller. Its cached reference is retained as before; the automatic cleanup above applies only to transactions owned by `ExecuteInTransactionAsync`. + +Run the regression suite with `dotnet test hrynco-ef.slnx`. + +### Package references + Reference `HrynCo.DAL.Abstract` for contracts only (e.g. in domain/application layers). Reference `HrynCo.DAL.EF` for the full EF Core implementation (infrastructure layer). diff --git a/hrynco-ef.slnx b/hrynco-ef.slnx index ef03f8d..c9a3b3f 100644 --- a/hrynco-ef.slnx +++ b/hrynco-ef.slnx @@ -1,4 +1,5 @@ +