| | | 1 | | using ArturRios.Data.Relational.Core.Configuration; |
| | | 2 | | using ArturRios.Data.Relational.Core.Repositories; |
| | | 3 | | using ArturRios.Output; |
| | | 4 | | using Microsoft.EntityFrameworkCore.Storage; |
| | | 5 | | |
| | | 6 | | namespace ArturRios.Data.Relational.Core.Transactions; |
| | | 7 | | |
| | | 8 | | /// <summary> |
| | | 9 | | /// Entity Framework Core implementation of <see cref="IUnitOfWork" /> and <see cref="IAsyncUnitOfWork" />. |
| | | 10 | | /// Repository saves issued within the delegate flush but do not commit until the transaction commits. |
| | | 11 | | /// Every failure - including one to begin the transaction - is returned as an error envelope; on rollback |
| | | 12 | | /// the context's change tracker is cleared, since none of the transaction's writes were kept. |
| | | 13 | | /// </summary> |
| | | 14 | | /// <param name="context">The application's <see cref="BaseDbContext" />.</param> |
| | 30 | 15 | | public class EfUnitOfWork(BaseDbContext context) : IUnitOfWork, IAsyncUnitOfWork |
| | | 16 | | { |
| | | 17 | | /// <inheritdoc /> |
| | | 18 | | public Task<ProcessOutput> ExecuteInTransactionAsync(Func<Task> work, CancellationToken ct = default) => |
| | 6 | 19 | | RunAsync(async () => |
| | 6 | 20 | | { |
| | 4 | 21 | | await work().ConfigureAwait(false); |
| | 0 | 22 | | return ProcessOutput.New; |
| | 10 | 23 | | }, error => ProcessOutput.New.WithError(error), ct); |
| | | 24 | | |
| | | 25 | | /// <inheritdoc /> |
| | | 26 | | public Task<DataOutput<TResult>> ExecuteInTransactionAsync<TResult>(Func<Task<TResult>> work, |
| | | 27 | | CancellationToken ct = default) => |
| | 10 | 28 | | RunAsync(async () => DataOutput<TResult>.New.WithData(await work().ConfigureAwait(false)), |
| | 8 | 29 | | error => DataOutput<TResult>.New.WithError(error), ct); |
| | | 30 | | |
| | | 31 | | /// <inheritdoc /> |
| | | 32 | | public async Task<IDbTransactionHandle> BeginTransactionAsync(CancellationToken ct = default) => |
| | 2 | 33 | | new EfTransactionHandle(await context.Database.BeginTransactionAsync(ct).ConfigureAwait(false)); |
| | | 34 | | |
| | | 35 | | /// <inheritdoc /> |
| | | 36 | | public ProcessOutput ExecuteInTransaction(Action work) => |
| | 12 | 37 | | Run(() => |
| | 12 | 38 | | { |
| | 10 | 39 | | work(); |
| | 2 | 40 | | return ProcessOutput.New; |
| | 22 | 41 | | }, error => ProcessOutput.New.WithError(error)); |
| | | 42 | | |
| | | 43 | | /// <inheritdoc /> |
| | | 44 | | public DataOutput<TResult> ExecuteInTransaction<TResult>(Func<TResult> work) => |
| | 4 | 45 | | Run(() => DataOutput<TResult>.New.WithData(work()), error => DataOutput<TResult>.New.WithError(error)); |
| | | 46 | | |
| | | 47 | | // Every step that can fail - beginning the transaction (no connection, or one already open on the |
| | | 48 | | // context), the work and the commit - runs inside the guard, so a failure is enveloped like any |
| | | 49 | | // other instead of escaping. |
| | | 50 | | private async Task<TOutput> RunAsync<TOutput>(Func<Task<TOutput>> work, Func<string, TOutput> fail, |
| | | 51 | | CancellationToken ct) |
| | | 52 | | { |
| | 12 | 53 | | IDbContextTransaction? tx = null; |
| | | 54 | | try |
| | | 55 | | { |
| | 12 | 56 | | tx = await context.Database.BeginTransactionAsync(ct).ConfigureAwait(false); |
| | 8 | 57 | | var output = await work().ConfigureAwait(false); |
| | 4 | 58 | | await tx.CommitAsync(ct).ConfigureAwait(false); |
| | | 59 | | |
| | 4 | 60 | | return output; |
| | | 61 | | } |
| | 8 | 62 | | catch (Exception ex) |
| | | 63 | | { |
| | 8 | 64 | | if (tx is not null) |
| | | 65 | | { |
| | 4 | 66 | | await RollbackQuietlyAsync(tx).ConfigureAwait(false); |
| | | 67 | | } |
| | | 68 | | |
| | 8 | 69 | | if (ex is OperationCanceledException) |
| | | 70 | | { |
| | 2 | 71 | | throw; |
| | | 72 | | } |
| | | 73 | | |
| | 6 | 74 | | return fail(RelationalErrors.Describe(ex)); |
| | | 75 | | } |
| | | 76 | | finally |
| | | 77 | | { |
| | 12 | 78 | | if (tx is not null) |
| | | 79 | | { |
| | 8 | 80 | | await tx.DisposeAsync().ConfigureAwait(false); |
| | | 81 | | } |
| | | 82 | | } |
| | 10 | 83 | | } |
| | | 84 | | |
| | | 85 | | private TOutput Run<TOutput>(Func<TOutput> work, Func<string, TOutput> fail) |
| | | 86 | | { |
| | 14 | 87 | | IDbContextTransaction? tx = null; |
| | | 88 | | try |
| | | 89 | | { |
| | 14 | 90 | | tx = context.Database.BeginTransaction(); |
| | 10 | 91 | | var output = work(); |
| | 2 | 92 | | tx.Commit(); |
| | | 93 | | |
| | 2 | 94 | | return output; |
| | | 95 | | } |
| | 12 | 96 | | catch (Exception ex) |
| | | 97 | | { |
| | 12 | 98 | | if (tx is not null) |
| | | 99 | | { |
| | 8 | 100 | | RollbackQuietly(tx); |
| | | 101 | | } |
| | | 102 | | |
| | 12 | 103 | | if (ex is OperationCanceledException) |
| | | 104 | | { |
| | 0 | 105 | | throw; |
| | | 106 | | } |
| | | 107 | | |
| | 12 | 108 | | return fail(RelationalErrors.Describe(ex)); |
| | | 109 | | } |
| | | 110 | | finally |
| | | 111 | | { |
| | 14 | 112 | | tx?.Dispose(); |
| | 14 | 113 | | } |
| | 14 | 114 | | } |
| | | 115 | | |
| | | 116 | | // Rollback must never mask the failure that triggered it: it runs untied to the caller's |
| | | 117 | | // token (which may already be canceled, making Rollback throw before it rolls anything back) |
| | | 118 | | // and swallows its own errors. Disposing the transaction rolls back whatever is left. |
| | | 119 | | // The change tracker is cleared afterwards: it still holds the transaction's flushed writes as |
| | | 120 | | // saved (and its failed ones as pending), none of which the database kept. |
| | | 121 | | private async Task RollbackQuietlyAsync(IDbContextTransaction transaction) |
| | | 122 | | { |
| | | 123 | | try |
| | | 124 | | { |
| | 4 | 125 | | await transaction.RollbackAsync(CancellationToken.None).ConfigureAwait(false); |
| | 4 | 126 | | } |
| | 0 | 127 | | catch |
| | | 128 | | { |
| | | 129 | | // Already rolled back, or the connection is gone. Dispose completes the cleanup. |
| | 0 | 130 | | } |
| | | 131 | | |
| | 4 | 132 | | context.ChangeTracker.Clear(); |
| | 4 | 133 | | } |
| | | 134 | | |
| | | 135 | | private void RollbackQuietly(IDbContextTransaction transaction) |
| | | 136 | | { |
| | | 137 | | try |
| | | 138 | | { |
| | 8 | 139 | | transaction.Rollback(); |
| | 8 | 140 | | } |
| | 0 | 141 | | catch |
| | | 142 | | { |
| | | 143 | | // Already rolled back, or the connection is gone. Dispose completes the cleanup. |
| | 0 | 144 | | } |
| | | 145 | | |
| | 8 | 146 | | context.ChangeTracker.Clear(); |
| | 8 | 147 | | } |
| | | 148 | | |
| | | 149 | | /// <inheritdoc /> |
| | | 150 | | public IDbTransactionHandle BeginTransaction() => |
| | 2 | 151 | | new EfTransactionHandle(context.Database.BeginTransaction()); |
| | | 152 | | |
| | 4 | 153 | | private sealed class EfTransactionHandle(IDbContextTransaction transaction) : IDbTransactionHandle |
| | | 154 | | { |
| | 0 | 155 | | public void Commit() => transaction.Commit(); |
| | 0 | 156 | | public void Rollback() => transaction.Rollback(); |
| | 0 | 157 | | public Task CommitAsync(CancellationToken ct = default) => transaction.CommitAsync(ct); |
| | 0 | 158 | | public Task RollbackAsync(CancellationToken ct = default) => transaction.RollbackAsync(ct); |
| | 2 | 159 | | public void Dispose() => transaction.Dispose(); |
| | 2 | 160 | | public ValueTask DisposeAsync() => transaction.DisposeAsync(); |
| | | 161 | | } |
| | | 162 | | } |