< Summary

Information
Class: ProjectTemplate.Infrastructure.Data.Auditing.ApplicationAuditedTransaction
Assembly: ProjectTemplate.Infrastructure
File(s): /home/runner/work/NetCoreApplicationTemplate/NetCoreApplicationTemplate/src/ProjectTemplate.Infrastructure/Data/Auditing/ApplicationAuditedTransaction.cs
Line coverage
82%
Covered lines: 153
Uncovered lines: 32
Coverable lines: 185
Total lines: 340
Line coverage: 82.7%
Branch coverage
68%
Covered branches: 22
Total branches: 32
Branch coverage: 68.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/NetCoreApplicationTemplate/NetCoreApplicationTemplate/src/ProjectTemplate.Infrastructure/Data/Auditing/ApplicationAuditedTransaction.cs

#LineLine coverage
 1using System.Data;
 2using Microsoft.EntityFrameworkCore;
 3using Microsoft.EntityFrameworkCore.Storage;
 4
 5namespace ProjectTemplate.Infrastructure.Data.Auditing;
 6
 7/// <summary>
 8/// Coordinates an application mutation, NCAT audit persistence, generated-value audit completion,
 9/// and an optional local completion handoff inside one relational database transaction boundary.
 10/// </summary>
 11public sealed class ApplicationAuditedTransaction : IApplicationAuditedTransaction
 12{
 13    private const string _savepointPrefix = "NCAT_";
 14
 15    private readonly ApplicationDbContext _dbContext;
 16    private readonly IApplicationMutationAuditReceiptAccessor _receiptAccessor;
 17
 18    /// <summary>
 19    /// Initializes a new instance of the <see cref="ApplicationAuditedTransaction" /> class.
 20    /// </summary>
 1421    public ApplicationAuditedTransaction(
 1422        ApplicationDbContext dbContext,
 1423        IApplicationMutationAuditReceiptAccessor receiptAccessor)
 24    {
 1425        ArgumentNullException.ThrowIfNull(dbContext);
 1426        ArgumentNullException.ThrowIfNull(receiptAccessor);
 27
 1428        _dbContext = dbContext;
 1429        _receiptAccessor = receiptAccessor;
 1430    }
 31
 32    /// <inheritdoc />
 33    public ApplicationAuditedTransactionResult Execute(
 34        Action<ApplicationDbContext> mutation,
 35        Action<ApplicationDbContext, ApplicationMutationAuditReceipt>? persistLocalCompletion = null,
 36        IsolationLevel? isolationLevel = null)
 37    {
 338        ArgumentNullException.ThrowIfNull(mutation);
 339        EnsureSupportedTransactionEnvironment();
 340        EnsureCleanChangeTracker();
 41
 242        IDbContextTransaction? existingTransaction = _dbContext.Database.CurrentTransaction;
 243        if (existingTransaction is not null)
 44        {
 045            return ExecuteWithinExistingTransaction(
 046                existingTransaction,
 047                mutation,
 048                persistLocalCompletion,
 049                isolationLevel);
 50        }
 51
 252        IExecutionStrategy executionStrategy = _dbContext.Database.CreateExecutionStrategy();
 253        ApplicationAuditedTransactionResult? result = null;
 54
 255        executionStrategy.Execute(() =>
 256        {
 257            using IDbContextTransaction transaction = BeginTransaction(isolationLevel);
 258            try
 259            {
 260                result = ExecuteCore(
 261                    mutation,
 262                    persistLocalCompletion,
 263                    ApplicationAuditedTransactionOwnership.CoordinatorOwned,
 264                    usedSavepoint: false);
 265                transaction.Commit();
 266            }
 267            catch
 268            {
 269                transaction.Rollback();
 270                ResetAfterRollback();
 271                throw;
 272            }
 273        });
 74
 175        return result
 176            ?? throw new InvalidOperationException("The audited transaction execution strategy completed without a resul
 77    }
 78
 79    /// <inheritdoc />
 80    public async Task<ApplicationAuditedTransactionResult> ExecuteAsync(
 81        Func<ApplicationDbContext, CancellationToken, Task> mutation,
 82        Func<ApplicationDbContext, ApplicationMutationAuditReceipt, CancellationToken, Task>? persistLocalCompletion = n
 83        IsolationLevel? isolationLevel = null,
 84        CancellationToken cancellationToken = default)
 85    {
 1186        ArgumentNullException.ThrowIfNull(mutation);
 1187        cancellationToken.ThrowIfCancellationRequested();
 1088        EnsureSupportedTransactionEnvironment();
 1089        EnsureCleanChangeTracker();
 90
 1091        IDbContextTransaction? existingTransaction = _dbContext.Database.CurrentTransaction;
 1092        if (existingTransaction is not null)
 93        {
 294            return await ExecuteWithinExistingTransactionAsync(
 295                    existingTransaction,
 296                    mutation,
 297                    persistLocalCompletion,
 298                    isolationLevel,
 299                    cancellationToken)
 2100                .ConfigureAwait(false);
 101        }
 102
 8103        IExecutionStrategy executionStrategy = _dbContext.Database.CreateExecutionStrategy();
 8104        ApplicationAuditedTransactionResult? result = null;
 105
 8106        await executionStrategy.ExecuteAsync(async () =>
 8107        {
 8108            await using IDbContextTransaction transaction = await BeginTransactionAsync(
 8109                    isolationLevel,
 8110                    cancellationToken)
 8111                .ConfigureAwait(false);
 8112
 8113            try
 8114            {
 8115                result = await ExecuteCoreAsync(
 8116                        mutation,
 8117                        persistLocalCompletion,
 8118                        ApplicationAuditedTransactionOwnership.CoordinatorOwned,
 8119                        usedSavepoint: false,
 8120                        cancellationToken)
 8121                    .ConfigureAwait(false);
 8122                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
 8123            }
 8124            catch
 8125            {
 8126                await transaction.RollbackAsync(CancellationToken.None).ConfigureAwait(false);
 8127                ResetAfterRollback();
 8128                throw;
 8129            }
 8130        }).ConfigureAwait(false);
 131
 3132        return result
 3133            ?? throw new InvalidOperationException("The audited transaction execution strategy completed without a resul
 4134    }
 135
 136    private ApplicationAuditedTransactionResult ExecuteWithinExistingTransaction(
 137        IDbContextTransaction existingTransaction,
 138        Action<ApplicationDbContext> mutation,
 139        Action<ApplicationDbContext, ApplicationMutationAuditReceipt>? persistLocalCompletion,
 140        IsolationLevel? isolationLevel)
 141    {
 0142        EnsureExistingTransactionCanBeJoined(existingTransaction, isolationLevel);
 0143        string savepointName = CreateSavepointName();
 0144        existingTransaction.CreateSavepoint(savepointName);
 145
 146        try
 147        {
 0148            ApplicationAuditedTransactionResult result = ExecuteCore(
 0149                mutation,
 0150                persistLocalCompletion,
 0151                ApplicationAuditedTransactionOwnership.ExistingTransaction,
 0152                usedSavepoint: true);
 0153            existingTransaction.ReleaseSavepoint(savepointName);
 0154            return result;
 155        }
 0156        catch
 157        {
 0158            existingTransaction.RollbackToSavepoint(savepointName);
 0159            ResetAfterRollback();
 0160            throw;
 161        }
 0162    }
 163
 164    private async Task<ApplicationAuditedTransactionResult> ExecuteWithinExistingTransactionAsync(
 165        IDbContextTransaction existingTransaction,
 166        Func<ApplicationDbContext, CancellationToken, Task> mutation,
 167        Func<ApplicationDbContext, ApplicationMutationAuditReceipt, CancellationToken, Task>? persistLocalCompletion,
 168        IsolationLevel? isolationLevel,
 169        CancellationToken cancellationToken)
 170    {
 2171        EnsureExistingTransactionCanBeJoined(existingTransaction, isolationLevel);
 2172        string savepointName = CreateSavepointName();
 2173        await existingTransaction.CreateSavepointAsync(savepointName, cancellationToken).ConfigureAwait(false);
 174
 175        try
 176        {
 2177            ApplicationAuditedTransactionResult result = await ExecuteCoreAsync(
 2178                    mutation,
 2179                    persistLocalCompletion,
 2180                    ApplicationAuditedTransactionOwnership.ExistingTransaction,
 2181                    usedSavepoint: true,
 2182                    cancellationToken)
 2183                .ConfigureAwait(false);
 1184            await existingTransaction.ReleaseSavepointAsync(savepointName, CancellationToken.None).ConfigureAwait(false)
 1185            return result;
 186        }
 1187        catch
 188        {
 1189            await existingTransaction.RollbackToSavepointAsync(savepointName, CancellationToken.None).ConfigureAwait(fal
 1190            ResetAfterRollback();
 1191            throw;
 192        }
 1193    }
 194
 195    private ApplicationAuditedTransactionResult ExecuteCore(
 196        Action<ApplicationDbContext> mutation,
 197        Action<ApplicationDbContext, ApplicationMutationAuditReceipt>? persistLocalCompletion,
 198        ApplicationAuditedTransactionOwnership ownership,
 199        bool usedSavepoint)
 200    {
 2201        ApplicationMutationAuditReceipt? previousReceipt = _receiptAccessor.LastCompletedReceipt;
 2202        mutation(_dbContext);
 203
 2204        int mutationSaveChangesCount = _dbContext.SaveChanges();
 2205        ApplicationMutationAuditReceipt? receipt = ResolveNewReceipt(previousReceipt);
 2206        int completionSaveChangesCount = 0;
 207
 2208        if (persistLocalCompletion is not null)
 209        {
 1210            if (receipt is null)
 211            {
 1212                throw new InvalidOperationException(
 1213                    "A local audit completion handoff was requested, but the mutation save produced no new audit receipt
 214            }
 215
 0216            persistLocalCompletion(_dbContext, receipt);
 0217            completionSaveChangesCount = _dbContext.SaveChanges();
 218        }
 219
 1220        return new ApplicationAuditedTransactionResult(
 1221            mutationSaveChangesCount,
 1222            completionSaveChangesCount,
 1223            receipt,
 1224            ownership,
 1225            usedSavepoint);
 226    }
 227
 228    private async Task<ApplicationAuditedTransactionResult> ExecuteCoreAsync(
 229        Func<ApplicationDbContext, CancellationToken, Task> mutation,
 230        Func<ApplicationDbContext, ApplicationMutationAuditReceipt, CancellationToken, Task>? persistLocalCompletion,
 231        ApplicationAuditedTransactionOwnership ownership,
 232        bool usedSavepoint,
 233        CancellationToken cancellationToken)
 234    {
 11235        ApplicationMutationAuditReceipt? previousReceipt = _receiptAccessor.LastCompletedReceipt;
 11236        await mutation(_dbContext, cancellationToken).ConfigureAwait(false);
 237
 9238        int mutationSaveChangesCount = await _dbContext
 9239            .SaveChangesAsync(cancellationToken)
 9240            .ConfigureAwait(false);
 8241        ApplicationMutationAuditReceipt? receipt = ResolveNewReceipt(previousReceipt);
 8242        int completionSaveChangesCount = 0;
 243
 8244        if (persistLocalCompletion is not null)
 245        {
 5246            if (receipt is null)
 247            {
 0248                throw new InvalidOperationException(
 0249                    "A local audit completion handoff was requested, but the mutation save produced no new audit receipt
 250            }
 251
 5252            await persistLocalCompletion(_dbContext, receipt, cancellationToken).ConfigureAwait(false);
 1253            completionSaveChangesCount = await _dbContext
 1254                .SaveChangesAsync(cancellationToken)
 1255                .ConfigureAwait(false);
 256        }
 257
 4258        return new ApplicationAuditedTransactionResult(
 4259            mutationSaveChangesCount,
 4260            completionSaveChangesCount,
 4261            receipt,
 4262            ownership,
 4263            usedSavepoint);
 4264    }
 265
 266    private ApplicationMutationAuditReceipt? ResolveNewReceipt(
 267        ApplicationMutationAuditReceipt? previousReceipt)
 268    {
 10269        ApplicationMutationAuditReceipt? currentReceipt = _receiptAccessor.LastCompletedReceipt;
 10270        return ReferenceEquals(previousReceipt, currentReceipt) ? null : currentReceipt;
 271    }
 272
 273    private IDbContextTransaction BeginTransaction(IsolationLevel? isolationLevel)
 274    {
 2275        return isolationLevel.HasValue
 2276            ? _dbContext.Database.BeginTransaction(isolationLevel.Value)
 2277            : _dbContext.Database.BeginTransaction();
 278    }
 279
 280    private Task<IDbContextTransaction> BeginTransactionAsync(
 281        IsolationLevel? isolationLevel,
 282        CancellationToken cancellationToken)
 283    {
 9284        return isolationLevel.HasValue
 9285            ? _dbContext.Database.BeginTransactionAsync(isolationLevel.Value, cancellationToken)
 9286            : _dbContext.Database.BeginTransactionAsync(cancellationToken);
 287    }
 288
 289    private void EnsureSupportedTransactionEnvironment()
 290    {
 13291        if (!_dbContext.Database.IsRelational())
 292        {
 0293            throw new NotSupportedException(
 0294                "Application audited transactions require an EF Core relational database provider.");
 295        }
 296
 13297        if (System.Transactions.Transaction.Current is not null)
 298        {
 0299            throw new NotSupportedException(
 0300                "Ambient System.Transactions transactions are not supported. Use an explicit EF Core transaction so owne
 301        }
 13302    }
 303
 304    private void EnsureCleanChangeTracker()
 305    {
 13306        if (_dbContext.HasUnsavedChanges())
 307        {
 1308            throw new InvalidOperationException(
 1309                "The application database context already contains unsaved changes. Stage the audited mutation inside th
 310        }
 12311    }
 312
 313    private void ResetAfterRollback()
 314    {
 8315        _dbContext.ChangeTracker.Clear();
 8316    }
 317
 318    private static void EnsureExistingTransactionCanBeJoined(
 319        IDbContextTransaction existingTransaction,
 320        IsolationLevel? isolationLevel)
 321    {
 2322        if (isolationLevel.HasValue)
 323        {
 0324            throw new InvalidOperationException(
 0325                "An isolation level cannot be supplied when the audited transaction coordinator joins an existing EF Cor
 326        }
 327
 2328        if (!existingTransaction.SupportsSavepoints)
 329        {
 0330            throw new NotSupportedException(
 0331                "The existing EF Core transaction does not support savepoints, so the audited transaction coordinator ca
 332        }
 2333    }
 334
 335    private static string CreateSavepointName()
 336    {
 2337        string suffix = Guid.NewGuid().ToString("N");
 2338        return $"{_savepointPrefix}{suffix[..27]}";
 339    }
 340}

Methods/Properties

.ctor(ProjectTemplate.Infrastructure.Data.ApplicationDbContext,ProjectTemplate.Infrastructure.Data.Auditing.IApplicationMutationAuditReceiptAccessor)
Execute(System.Action`1<ProjectTemplate.Infrastructure.Data.ApplicationDbContext>,System.Action`2<ProjectTemplate.Infrastructure.Data.ApplicationDbContext,ProjectTemplate.Infrastructure.Data.Auditing.ApplicationMutationAuditReceipt>,System.Nullable`1<System.Data.IsolationLevel>)
ExecuteAsync()
ExecuteWithinExistingTransaction(Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction,System.Action`1<ProjectTemplate.Infrastructure.Data.ApplicationDbContext>,System.Action`2<ProjectTemplate.Infrastructure.Data.ApplicationDbContext,ProjectTemplate.Infrastructure.Data.Auditing.ApplicationMutationAuditReceipt>,System.Nullable`1<System.Data.IsolationLevel>)
ExecuteWithinExistingTransactionAsync()
ExecuteCore(System.Action`1<ProjectTemplate.Infrastructure.Data.ApplicationDbContext>,System.Action`2<ProjectTemplate.Infrastructure.Data.ApplicationDbContext,ProjectTemplate.Infrastructure.Data.Auditing.ApplicationMutationAuditReceipt>,ProjectTemplate.Infrastructure.Data.Auditing.ApplicationAuditedTransactionOwnership,System.Boolean)
ExecuteCoreAsync()
ResolveNewReceipt(ProjectTemplate.Infrastructure.Data.Auditing.ApplicationMutationAuditReceipt)
BeginTransaction(System.Nullable`1<System.Data.IsolationLevel>)
BeginTransactionAsync(System.Nullable`1<System.Data.IsolationLevel>,System.Threading.CancellationToken)
EnsureSupportedTransactionEnvironment()
EnsureCleanChangeTracker()
ResetAfterRollback()
EnsureExistingTransactionCanBeJoined(Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction,System.Nullable`1<System.Data.IsolationLevel>)
CreateSavepointName()