< 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
77%
Covered lines: 144
Uncovered lines: 41
Coverable lines: 185
Total lines: 340
Line coverage: 77.8%
Branch coverage
60%
Covered branches: 24
Total branches: 40
Branch coverage: 60%
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>
 2221    public ApplicationAuditedTransaction(
 2222        ApplicationDbContext dbContext,
 2223        IApplicationMutationAuditReceiptAccessor receiptAccessor)
 24    {
 2225        ArgumentNullException.ThrowIfNull(dbContext);
 2226        ArgumentNullException.ThrowIfNull(receiptAccessor);
 27
 2228        _dbContext = dbContext;
 2229        _receiptAccessor = receiptAccessor;
 2230    }
 31
 32    /// <inheritdoc />
 33    public ApplicationAuditedTransactionResult Execute(
 34        Action<ApplicationDbContext> mutation,
 35        Action<ApplicationDbContext, ApplicationMutationAuditReceipt>? persistLocalCompletion = null,
 36        IsolationLevel? isolationLevel = null)
 37    {
 238        ArgumentNullException.ThrowIfNull(mutation);
 239        EnsureSupportedTransactionEnvironment();
 240        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            }
 067            catch
 268            {
 069                transaction.Rollback();
 070                ResetAfterRollback();
 071                throw;
 272            }
 473        });
 74
 275        return result
 276            ?? 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    {
 2086        ArgumentNullException.ThrowIfNull(mutation);
 2087        cancellationToken.ThrowIfCancellationRequested();
 1888        EnsureSupportedTransactionEnvironment();
 1889        EnsureCleanChangeTracker();
 90
 1891        IDbContextTransaction? existingTransaction = _dbContext.Database.CurrentTransaction;
 1892        if (existingTransaction is not null)
 93        {
 494            return await ExecuteWithinExistingTransactionAsync(
 495                    existingTransaction,
 496                    mutation,
 497                    persistLocalCompletion,
 498                    isolationLevel,
 499                    cancellationToken)
 4100                .ConfigureAwait(false);
 101        }
 102
 14103        IExecutionStrategy executionStrategy = _dbContext.Database.CreateExecutionStrategy();
 14104        ApplicationAuditedTransactionResult? result = null;
 105
 14106        await executionStrategy.ExecuteAsync(async () =>
 14107        {
 16108            await using IDbContextTransaction transaction = await BeginTransactionAsync(
 16109                    isolationLevel,
 16110                    cancellationToken)
 16111                .ConfigureAwait(false);
 14112
 14113            try
 14114            {
 16115                result = await ExecuteCoreAsync(
 16116                        mutation,
 16117                        persistLocalCompletion,
 16118                        ApplicationAuditedTransactionOwnership.CoordinatorOwned,
 16119                        usedSavepoint: false,
 16120                        cancellationToken)
 16121                    .ConfigureAwait(false);
 6122                await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
 6123            }
 10124            catch
 14125            {
 10126                await transaction.RollbackAsync(CancellationToken.None).ConfigureAwait(false);
 10127                ResetAfterRollback();
 10128                throw;
 14129            }
 14130        }).ConfigureAwait(false);
 131
 6132        return result
 6133            ?? throw new InvalidOperationException("The audited transaction execution strategy completed without a resul
 14134    }
 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    {
 4171        EnsureExistingTransactionCanBeJoined(existingTransaction, isolationLevel);
 4172        string savepointName = CreateSavepointName();
 4173        await existingTransaction.CreateSavepointAsync(savepointName, cancellationToken).ConfigureAwait(false);
 174
 175        try
 176        {
 4177            ApplicationAuditedTransactionResult result = await ExecuteCoreAsync(
 4178                    mutation,
 4179                    persistLocalCompletion,
 4180                    ApplicationAuditedTransactionOwnership.ExistingTransaction,
 4181                    usedSavepoint: true,
 4182                    cancellationToken)
 4183                .ConfigureAwait(false);
 2184            await existingTransaction.ReleaseSavepointAsync(savepointName, CancellationToken.None).ConfigureAwait(false)
 2185            return result;
 186        }
 2187        catch
 188        {
 2189            await existingTransaction.RollbackToSavepointAsync(savepointName, CancellationToken.None).ConfigureAwait(fal
 2190            ResetAfterRollback();
 2191            throw;
 192        }
 2193    }
 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        {
 0210            if (receipt is null)
 211            {
 0212                throw new InvalidOperationException(
 0213                    "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
 2220        return new ApplicationAuditedTransactionResult(
 2221            mutationSaveChangesCount,
 2222            completionSaveChangesCount,
 2223            receipt,
 2224            ownership,
 2225            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    {
 20235        ApplicationMutationAuditReceipt? previousReceipt = _receiptAccessor.LastCompletedReceipt;
 20236        await mutation(_dbContext, cancellationToken).ConfigureAwait(false);
 237
 16238        int mutationSaveChangesCount = await _dbContext
 16239            .SaveChangesAsync(cancellationToken)
 16240            .ConfigureAwait(false);
 14241        ApplicationMutationAuditReceipt? receipt = ResolveNewReceipt(previousReceipt);
 14242        int completionSaveChangesCount = 0;
 243
 14244        if (persistLocalCompletion is not null)
 245        {
 8246            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
 8252            await persistLocalCompletion(_dbContext, receipt, cancellationToken).ConfigureAwait(false);
 2253            completionSaveChangesCount = await _dbContext
 2254                .SaveChangesAsync(cancellationToken)
 2255                .ConfigureAwait(false);
 256        }
 257
 8258        return new ApplicationAuditedTransactionResult(
 8259            mutationSaveChangesCount,
 8260            completionSaveChangesCount,
 8261            receipt,
 8262            ownership,
 8263            usedSavepoint);
 8264    }
 265
 266    private ApplicationMutationAuditReceipt? ResolveNewReceipt(
 267        ApplicationMutationAuditReceipt? previousReceipt)
 268    {
 16269        ApplicationMutationAuditReceipt? currentReceipt = _receiptAccessor.LastCompletedReceipt;
 16270        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    {
 16284        return isolationLevel.HasValue
 16285            ? _dbContext.Database.BeginTransactionAsync(isolationLevel.Value, cancellationToken)
 16286            : _dbContext.Database.BeginTransactionAsync(cancellationToken);
 287    }
 288
 289    private void EnsureSupportedTransactionEnvironment()
 290    {
 20291        if (!_dbContext.Database.IsRelational())
 292        {
 0293            throw new NotSupportedException(
 0294                "Application audited transactions require an EF Core relational database provider.");
 295        }
 296
 20297        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        }
 20302    }
 303
 304    private void EnsureCleanChangeTracker()
 305    {
 20306        if (_dbContext.HasUnsavedChanges())
 307        {
 0308            throw new InvalidOperationException(
 0309                "The application database context already contains unsaved changes. Stage the audited mutation inside th
 310        }
 20311    }
 312
 313    private void ResetAfterRollback()
 314    {
 12315        _dbContext.ChangeTracker.Clear();
 12316    }
 317
 318    private static void EnsureExistingTransactionCanBeJoined(
 319        IDbContextTransaction existingTransaction,
 320        IsolationLevel? isolationLevel)
 321    {
 4322        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
 4328        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        }
 4333    }
 334
 335    private static string CreateSavepointName()
 336    {
 4337        string suffix = Guid.NewGuid().ToString("N");
 4338        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()
<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()