< Summary

Information
Class: ProjectTemplate.Infrastructure.Data.ApplicationDbContext
Assembly: ProjectTemplate.Infrastructure
File(s): /home/runner/work/NetCoreApplicationTemplate/NetCoreApplicationTemplate/src/ProjectTemplate.Infrastructure/Data/ApplicationDbContext.cs
Line coverage
88%
Covered lines: 167
Uncovered lines: 22
Coverable lines: 189
Total lines: 488
Line coverage: 88.3%
Branch coverage
78%
Covered branches: 112
Total branches: 142
Branch coverage: 78.8%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/NetCoreApplicationTemplate/NetCoreApplicationTemplate/src/ProjectTemplate.Infrastructure/Data/ApplicationDbContext.cs

#LineLine coverage
 1using Microsoft.EntityFrameworkCore;
 2using Microsoft.EntityFrameworkCore.ChangeTracking;
 3using Microsoft.EntityFrameworkCore.Metadata;
 4using Microsoft.Extensions.Logging;
 5using Microsoft.Extensions.Options;
 6using ProjectTemplate.Infrastructure.Data.Entities;
 7using ProjectTemplate.Infrastructure.Data.Options;
 8
 9namespace ProjectTemplate.Infrastructure.Data;
 10
 11/// <summary>
 12/// Represents the EF Core database context for the ProjectTemplate application.
 13/// </summary>
 14public sealed partial class ApplicationDbContext(
 15    DbContextOptions<ApplicationDbContext> options,
 16    ILogger<ApplicationDbContext> logger,
 17    ICurrentActorAccessor currentActorAccessor,
 18    IOptions<DataAccessOptions> dataAccessOptions
 19)
 10020    : DbContext(options)
 21{
 10022    private readonly ILogger<ApplicationDbContext> _logger = logger;
 10023    private readonly ICurrentActorAccessor _currentActorAccessor = currentActorAccessor;
 10024    private readonly bool _auditOptions = dataAccessOptions.Value.Auditing.Enabled;
 25
 26    /// <summary>
 27    /// Gets the audit records for the application.
 28    /// </summary>
 4429    public DbSet<AuditRecord> AuditRecords => Set<AuditRecord>();
 30    /// <summary>
 31    /// Gets the external login account links for the application.
 32    /// </summary>
 10233    public DbSet<ExternalLoginAccount> ExternalLoginAccounts => Set<ExternalLoginAccount>();
 34
 35    [LoggerMessage(
 36        EventId = 19000,
 37        Level = LogLevel.Trace,
 38        Message = "{EfCoreMessage}")]
 39    private static partial void LogEfCoreMessage(
 40        ILogger logger,
 41        string efCoreMessage);
 42
 43    [LoggerMessage(
 44        EventId = 19001,
 45        Level = LogLevel.Warning,
 46        Message = "Optimistic concurrency conflict detected while saving {EntryCount} tracked entity entries.")]
 47    private static partial void LogOptimisticConcurrencyConflict(
 48        ILogger logger,
 49        int entryCount,
 50        Exception exception);
 51
 52    /// <inheritdoc />
 53    protected override void OnModelCreating(ModelBuilder modelBuilder)
 54    {
 655        ArgumentNullException.ThrowIfNull(modelBuilder);
 56
 657        modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly);
 658        ConfigureDataEntityDefaults(modelBuilder);
 659        ConfigureTimestampDefaults(modelBuilder);
 60
 661        base.OnModelCreating(modelBuilder);
 662    }
 63
 64    /// <inheritdoc />
 65    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
 66    {
 10067        ArgumentNullException.ThrowIfNull(optionsBuilder);
 68
 10069        optionsBuilder
 607870            .LogTo(message => LogEfCoreMessage(_logger, message), LogLevel.Trace)
 10071            .EnableDetailedErrors();
 72
 10073        base.OnConfiguring(optionsBuilder);
 10074    }
 75
 76    public bool HasUnsavedChanges()
 77    {
 078        return ChangeTracker.HasChanges();
 79    }
 80
 81    public override int SaveChanges()
 82    {
 1083        return SaveChanges(acceptAllChangesOnSuccess: true);
 84    }
 85
 86    public override int SaveChanges(bool acceptAllChangesOnSuccess = true)
 87    {
 1088        ApplyPersistedStringCanonicalization();
 1089        ApplyLookupStringNormalization();
 1090        ApplyTimestampNormalization();
 1091        ApplyConcurrencyStamps();
 92
 1093        if (!_auditOptions)
 94        {
 895            return SaveChangesWithConcurrencyHandling(
 1696                () => base.SaveChanges(acceptAllChangesOnSuccess));
 97        }
 98
 299        List<AuditEntry> auditEntries = OnBeforeSaveChanges();
 100
 2101        int result = SaveChangesWithConcurrencyHandling(
 4102            () => base.SaveChanges(acceptAllChangesOnSuccess));
 103
 2104        _ = OnAfterSaveChanges(auditEntries);
 105
 2106        return result;
 107    }
 108
 109    public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
 110    {
 62111        return SaveChangesAsync(
 62112            acceptAllChangesOnSuccess: true,
 62113            cancellationToken);
 114    }
 115
 116    public override async Task<int> SaveChangesAsync(
 117        bool acceptAllChangesOnSuccess,
 118        CancellationToken cancellationToken = default)
 119    {
 62120        ApplyPersistedStringCanonicalization();
 62121        ApplyLookupStringNormalization();
 62122        ApplyTimestampNormalization();
 62123        ApplyConcurrencyStamps();
 124
 62125        if (!_auditOptions)
 126        {
 42127            return await SaveChangesWithConcurrencyHandlingAsync(
 84128                () => base.SaveChangesAsync(
 84129                    acceptAllChangesOnSuccess,
 84130                    cancellationToken)).ConfigureAwait(false);
 131        }
 132
 20133        List<AuditEntry> auditEntries = OnBeforeSaveChanges();
 134
 20135        int result = await SaveChangesWithConcurrencyHandlingAsync(
 40136            () => base.SaveChangesAsync(
 40137                acceptAllChangesOnSuccess,
 40138                cancellationToken)).ConfigureAwait(false);
 139
 18140        _ = await OnAfterSaveChangesAsync(auditEntries, cancellationToken)
 18141            .ConfigureAwait(false);
 142
 18143        return result;
 58144    }
 145
 146    private void ApplyPersistedStringCanonicalization()
 147    {
 72148        ChangeTracker.DetectChanges();
 149
 300150        foreach (EntityEntry entry in ChangeTracker.Entries())
 151        {
 78152            if (entry.Entity is AuditRecord ||
 78153                entry.State is not (EntityState.Added or EntityState.Modified))
 154            {
 155                continue;
 156            }
 157
 1872158            foreach (PropertyEntry property in entry.Properties)
 159            {
 864160                if (property.Metadata.ClrType != typeof(string) ||
 864161                    property.Metadata.IsPrimaryKey() ||
 864162                    property.Metadata.IsConcurrencyToken)
 163                {
 164                    continue;
 165                }
 166
 432167                if (entry.State == EntityState.Modified && !property.IsModified)
 168                {
 169                    continue;
 170                }
 171
 352172                if (property.CurrentValue is not string currentValue)
 173                {
 174                    continue;
 175                }
 176
 294177                string canonicalValue = PersistenceStringCanonicalizer.Canonicalize(currentValue);
 178
 294179                if (!string.Equals(canonicalValue, currentValue, StringComparison.Ordinal))
 180                {
 24181                    property.CurrentValue = canonicalValue;
 182                }
 183            }
 184        }
 72185    }
 186
 187    private void ApplyLookupStringNormalization()
 188    {
 72189        ChangeTracker.DetectChanges();
 190
 296191        foreach (EntityEntry<ExternalLoginAccount> entry in ChangeTracker.Entries<ExternalLoginAccount>())
 192        {
 76193            if (entry.State is not (EntityState.Added or EntityState.Modified))
 194            {
 195                continue;
 196            }
 197
 72198            ExternalLoginAccount account = entry.Entity;
 199
 72200            account.ProviderName =
 72201                PersistenceStringComparisonNormalizer.NormalizeRequiredDisplayValue(account.ProviderName);
 202
 72203            account.NormalizedProviderName =
 72204                PersistenceStringComparisonNormalizer.NormalizeRequiredLookupValue(account.ProviderName);
 205
 72206            account.ProviderUserId =
 72207                PersistenceStringComparisonNormalizer.NormalizeRequiredDisplayValue(account.ProviderUserId);
 208
 72209            account.DisplayName =
 72210                PersistenceStringComparisonNormalizer.NormalizeOptionalDisplayValue(account.DisplayName);
 211
 72212            account.Email =
 72213                PersistenceStringComparisonNormalizer.NormalizeOptionalDisplayValue(account.Email);
 214
 72215            account.NormalizedEmail =
 72216                PersistenceStringComparisonNormalizer.NormalizeOptionalLookupValue(account.Email);
 217        }
 72218    }
 219
 220    private void ApplyTimestampNormalization()
 221    {
 72222        ChangeTracker.DetectChanges();
 223
 300224        foreach (EntityEntry entry in ChangeTracker.Entries())
 225        {
 78226            if (entry.State is not (EntityState.Added or EntityState.Modified))
 227            {
 228                continue;
 229            }
 230
 1916231            foreach (PropertyEntry property in entry.Properties)
 232            {
 884233                if (!IsUtcTimestampProperty(property.Metadata.Name))
 234                {
 235                    continue;
 236                }
 237
 218238                Type propertyType = Nullable.GetUnderlyingType(property.Metadata.ClrType)
 218239                    ?? property.Metadata.ClrType;
 240
 218241                if (propertyType == typeof(DateTime) &&
 218242                    property.CurrentValue is DateTime dateTimeValue)
 243                {
 84244                    property.CurrentValue = PersistenceTimestamp.NormalizeUtc(dateTimeValue);
 84245                    continue;
 246                }
 247
 134248                if (propertyType == typeof(DateTimeOffset) &&
 134249                    property.CurrentValue is DateTimeOffset dateTimeOffsetValue)
 250                {
 0251                    property.CurrentValue = PersistenceTimestamp.NormalizeUtc(dateTimeOffsetValue);
 252                }
 253            }
 254        }
 72255    }
 256
 257    private static bool IsUtcTimestampProperty(string propertyName)
 258    {
 908259        return propertyName.EndsWith("Utc", StringComparison.Ordinal);
 260    }
 261    private List<AuditEntry> OnBeforeSaveChanges()
 262    {
 22263        ChangeTracker.DetectChanges();
 22264        var auditEntries = new List<AuditEntry>();
 100265        foreach (EntityEntry entry in ChangeTracker.Entries())
 266        {
 28267            if (entry.Entity is AuditRecord || entry.State == EntityState.Detached || entry.State == EntityState.Unchang
 268            {
 269                continue;
 270            }
 271
 24272            AuditEntry auditEntry = new(entry)
 24273            {
 24274                TableName = entry.Metadata.GetTableName() ?? string.Empty,
 24275                ModifiedBy = _currentActorAccessor.CurrentActor,
 24276                ModifiedOnUtc = PersistenceTimestamp.UtcNow(),
 24277                State = entry.State.ToString()
 24278            };
 24279            auditEntries.Add(auditEntry);
 280
 624281            foreach (PropertyEntry property in entry.Properties)
 282            {
 288283                if (property.IsTemporary)
 284                {
 285                    // value will be generated by the database, get the value after saving
 0286                    auditEntry.TemporaryProperties.Add(property);
 0287                    continue;
 288                }
 289
 288290                string propertyName = property.Metadata.Name;
 288291                if (property.Metadata.IsPrimaryKey())
 292                {
 24293                    auditEntry.KeyValues[propertyName] = property.CurrentValue ?? string.Empty;
 24294                    continue;
 295                }
 296
 264297                switch (entry.State)
 298                {
 299                    case EntityState.Added:
 198300                        auditEntry.CurrentValues[propertyName] = property.CurrentValue ?? string.Empty;
 198301                        break;
 302
 303                    case EntityState.Deleted:
 22304                        auditEntry.OriginalValues[propertyName] = property.OriginalValue ?? string.Empty;
 22305                        break;
 306
 307                    case EntityState.Modified:
 44308                        if (property.IsModified)
 309                        {
 10310                            auditEntry.OriginalValues[propertyName] = property.OriginalValue ?? string.Empty;
 10311                            auditEntry.CurrentValues[propertyName] = property.CurrentValue ?? string.Empty;
 312                        }
 313                        break;
 314                    case EntityState.Detached:
 315                    case EntityState.Unchanged:
 316                    default:
 317                        break;
 318                }
 319            }
 320        }
 321
 322        // Save audit entities that have all the modifications
 116323        foreach (AuditEntry auditEntry in auditEntries.Where(_ => !_.HasTemporaryProperties))
 324        {
 24325            AuditRecords.Add(auditEntry.ToAuditRecord());
 326        }
 327
 328        // keep a list of entries where the value of some properties are unknown at this step
 46329        return [.. auditEntries.Where(_ => _.HasTemporaryProperties)];
 330    }
 331
 332    [System.Diagnostics.CodeAnalysis.SuppressMessage(
 333        "Style",
 334        "IDE0002:Simplify Member Access",
 335        Justification = "The base call intentionally bypasses the overridden SaveChanges audit pipeline.")]
 336    private int OnAfterSaveChanges(List<AuditEntry> auditEntries)
 337    {
 2338        if (auditEntries == null || auditEntries.Count == 0)
 339        {
 2340            return 0;
 341        }
 342
 0343        foreach (AuditEntry auditEntry in auditEntries)
 344        {
 345            // Get the final value of the temporary properties
 0346            foreach (PropertyEntry prop in auditEntry.TemporaryProperties)
 347            {
 0348                if (prop.Metadata.IsPrimaryKey())
 349                {
 0350                    auditEntry.KeyValues[prop.Metadata.Name] = prop.CurrentValue ?? string.Empty;
 351                }
 352                else
 353                {
 0354                    auditEntry.CurrentValues[prop.Metadata.Name] = prop.CurrentValue ?? string.Empty;
 355                }
 356            }
 357
 358            // Save the audit entry.
 0359            AuditRecords.Add(auditEntry.ToAuditRecord());
 360
 361        }
 362
 0363        return base.SaveChanges();
 364    }
 365
 366    [System.Diagnostics.CodeAnalysis.SuppressMessage(
 367        "Style",
 368        "IDE0002:Simplify Member Access",
 369        Justification = "The base call intentionally bypasses the overridden SaveChanges audit pipeline.")]
 370    private async Task<int> OnAfterSaveChangesAsync(
 371        List<AuditEntry> auditEntries,
 372        CancellationToken cancellationToken)
 373    {
 18374        if (auditEntries == null || auditEntries.Count == 0)
 375        {
 18376            return 0;
 377        }
 378
 0379        foreach (AuditEntry auditEntry in auditEntries)
 380        {
 381            // Get the final value of the temporary properties
 0382            foreach (PropertyEntry prop in auditEntry.TemporaryProperties)
 383            {
 0384                if (prop.Metadata.IsPrimaryKey())
 385                {
 0386                    auditEntry.KeyValues[prop.Metadata.Name] = prop.CurrentValue ?? string.Empty;
 387                }
 388                else
 389                {
 0390                    auditEntry.CurrentValues[prop.Metadata.Name] = prop.CurrentValue ?? string.Empty;
 391                }
 392            }
 393
 394            // Save the audit entry.
 0395            await AuditRecords
 0396                .AddAsync(auditEntry.ToAuditRecord(), cancellationToken)
 0397                .ConfigureAwait(false);
 398        }
 399
 0400        return await base
 0401            .SaveChangesAsync(cancellationToken)
 0402            .ConfigureAwait(false);
 18403    }
 404
 405    private static void ConfigureDataEntityDefaults(ModelBuilder modelBuilder)
 406    {
 36407        foreach (IMutableEntityType entityType in modelBuilder.Model.GetEntityTypes())
 408        {
 12409            if (!typeof(DataEntity).IsAssignableFrom(entityType.ClrType))
 410            {
 411                continue;
 412            }
 413
 12414            modelBuilder.Entity(entityType.ClrType)
 12415                .Property<string>(nameof(DataEntity.ConcurrencyStamp))
 12416                .HasMaxLength(64)
 12417                .IsRequired()
 12418                .IsConcurrencyToken();
 419        }
 6420    }
 421
 422    private static void ConfigureTimestampDefaults(ModelBuilder modelBuilder)
 423    {
 36424        foreach (IMutableEntityType entityType in modelBuilder.Model.GetEntityTypes())
 425        {
 288426            foreach (IMutableProperty property in entityType.GetProperties())
 427            {
 132428                Type propertyType = Nullable.GetUnderlyingType(property.ClrType)
 132429                    ?? property.ClrType;
 430
 132431                if ((propertyType == typeof(DateTime) || propertyType == typeof(DateTimeOffset)) &&
 132432                    IsUtcTimestampProperty(property.Name))
 433                {
 24434                    property.SetPrecision(PersistenceTimestamp.Precision);
 435                }
 436            }
 437        }
 6438    }
 439
 440    private void ApplyConcurrencyStamps()
 441    {
 72442        ChangeTracker.DetectChanges();
 443
 300444        foreach (EntityEntry<DataEntity> entry in ChangeTracker.Entries<DataEntity>())
 445        {
 78446            if (entry.State == EntityState.Added)
 447            {
 58448                if (string.IsNullOrWhiteSpace(entry.Entity.ConcurrencyStamp))
 449                {
 6450                    entry.Entity.ConcurrencyStamp = DataEntity.NewConcurrencyStamp();
 451                }
 452
 6453                continue;
 454            }
 455
 20456            if (entry.State == EntityState.Modified)
 457            {
 16458                entry.Entity.ConcurrencyStamp = DataEntity.NewConcurrencyStamp();
 459            }
 460        }
 72461    }
 462
 463    private int SaveChangesWithConcurrencyHandling(Func<int> saveChanges)
 464    {
 465        try
 466        {
 10467            return saveChanges();
 468        }
 2469        catch (DbUpdateConcurrencyException exception)
 470        {
 2471            LogOptimisticConcurrencyConflict(_logger, exception.Entries.Count, exception);
 2472            throw;
 473        }
 8474    }
 475
 476    private async Task<int> SaveChangesWithConcurrencyHandlingAsync(Func<Task<int>> saveChanges)
 477    {
 478        try
 479        {
 62480            return await saveChanges().ConfigureAwait(false);
 481        }
 2482        catch (DbUpdateConcurrencyException exception)
 483        {
 2484            LogOptimisticConcurrencyConflict(_logger, exception.Entries.Count, exception);
 2485            throw;
 486        }
 58487    }
 488}