Init
This commit is contained in:
48
Prefab/Data/Configs/AuditLogConfig.cs
Normal file
48
Prefab/Data/Configs/AuditLogConfig.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Prefab.Data.Entities;
|
||||
|
||||
namespace Prefab.Data.Configs
|
||||
{
|
||||
/// <summary>
|
||||
/// Entity Framework Core configuration for the <see cref="AuditLog"/> entity.
|
||||
/// </summary>
|
||||
public class AuditLogConfig() : Prefab.Data.Configs.EntityConfig<AuditLog>(nameof(IPrefabDb.AuditLogs))
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures the properties and relationships for the <see cref="AuditLog"/> entity.
|
||||
/// </summary>
|
||||
/// <param name="builder">The builder to be used to configure the entity type.</param>
|
||||
public override void Configure(EntityTypeBuilder<AuditLog> builder)
|
||||
{
|
||||
builder.HasKey(e => e.Id);
|
||||
|
||||
builder.Property(e => e.Entity)
|
||||
.HasMaxLength(Rules.EntityMaxLength)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.State)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.CreatedBy)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.CreatedOn)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasMany(e => e.Items)
|
||||
.WithOne(i => i.AuditLog)
|
||||
.HasForeignKey(i => i.AuditLogId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation rules and settings for <see cref="AuditLog"/> properties.
|
||||
/// </summary>
|
||||
public static class Rules
|
||||
{
|
||||
/// <summary>Maximum length for the Entity name.</summary>
|
||||
public const int EntityMaxLength = 200;
|
||||
}
|
||||
}
|
||||
}
|
||||
48
Prefab/Data/Configs/AuditLogItemConfig.cs
Normal file
48
Prefab/Data/Configs/AuditLogItemConfig.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Prefab.Data.Entities;
|
||||
|
||||
namespace Prefab.Data.Configs
|
||||
{
|
||||
/// <summary>
|
||||
/// Entity Framework Core configuration for the <see cref="AuditLogItem"/> entity.
|
||||
/// </summary>
|
||||
public class AuditLogItemConfig() : Prefab.Data.Configs.EntityConfig<AuditLogItem>(nameof(IPrefabDb.AuditLogItems))
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures the properties and relationships for the <see cref="AuditLogItem"/> entity.
|
||||
/// </summary>
|
||||
/// <param name="builder">The builder to be used to configure the entity type.</param>
|
||||
public override void Configure(EntityTypeBuilder<AuditLogItem> builder)
|
||||
{
|
||||
builder.HasKey(e => e.Id);
|
||||
|
||||
builder.Property(e => e.Property)
|
||||
.HasMaxLength(Rules.PropertyMaxLength)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.OldValue)
|
||||
.HasMaxLength(Rules.ValueMaxLength);
|
||||
|
||||
builder.Property(e => e.NewValue)
|
||||
.HasMaxLength(Rules.ValueMaxLength);
|
||||
|
||||
builder.HasOne(e => e.AuditLog)
|
||||
.WithMany(a => a.Items)
|
||||
.HasForeignKey(e => e.AuditLogId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation rules and settings for <see cref="AuditLogItem"/> properties.
|
||||
/// </summary>
|
||||
public static class Rules
|
||||
{
|
||||
/// <summary>Maximum length for the property name.</summary>
|
||||
public const int PropertyMaxLength = 100;
|
||||
/// <summary>Maximum length for the value fields.</summary>
|
||||
public const int ValueMaxLength = 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
27
Prefab/Data/Configs/EntityConfig.cs
Normal file
27
Prefab/Data/Configs/EntityConfig.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Prefab.Data.Configs
|
||||
{
|
||||
/// <summary>
|
||||
/// Base configuration for all entities, sets items like table name and schema.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The entity type.</typeparam>
|
||||
public abstract class EntityConfig<T> : IEntityTypeConfiguration<T> where T : class
|
||||
{
|
||||
private readonly string _schema;
|
||||
private readonly string _tableName;
|
||||
|
||||
protected EntityConfig(string tableName, string? schema = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tableName);
|
||||
_tableName = tableName;
|
||||
_schema = string.IsNullOrWhiteSpace(schema) ? "dbo" : schema;
|
||||
}
|
||||
|
||||
public virtual void Configure(EntityTypeBuilder<T> builder)
|
||||
{
|
||||
builder.ToTable(_tableName, _schema);
|
||||
}
|
||||
}
|
||||
}
|
||||
72
Prefab/Data/Configs/GenericAttributeConfig.cs
Normal file
72
Prefab/Data/Configs/GenericAttributeConfig.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Prefab.Data.Entities;
|
||||
|
||||
namespace Prefab.Data.Configs;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration class for the <see cref="GenericAttribute"/> entity within the dbo schema.
|
||||
/// </summary>
|
||||
public class GenericAttributeConfig() : EntityConfig<GenericAttribute>(nameof(IPrefabDb.GenericAttributes))
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration of Entity Framework mapping for the <see cref="GenericAttribute"/> entity.
|
||||
/// </summary>
|
||||
public override void Configure(EntityTypeBuilder<GenericAttribute> builder)
|
||||
{
|
||||
builder
|
||||
.Ignore(x => x.Id);
|
||||
|
||||
// Define the composite key (and clustered index) for the GenericAttribute entity.
|
||||
builder
|
||||
.HasKey(x => new { x.EntityId, x.KeyGroup, x.Key });
|
||||
|
||||
builder.HasIndex(x => new { x.EntityId, x.KeyGroup })
|
||||
.HasDatabaseName("IX_GenericAttributes_Entity_Group");
|
||||
|
||||
builder.HasIndex(x => x.DeletedOn)
|
||||
.HasFilter("[DeletedOn] IS NULL")
|
||||
.HasDatabaseName("IX_GenericAttributes_Active");
|
||||
|
||||
builder
|
||||
.Property(x => x.KeyGroup)
|
||||
.HasMaxLength(Rules.KeyGroupMaxLength)
|
||||
.IsRequired();
|
||||
|
||||
builder
|
||||
.Property(x => x.Key)
|
||||
.HasMaxLength(Rules.KeyMaxLength)
|
||||
.IsRequired();
|
||||
|
||||
builder
|
||||
.Property(x => x.Value)
|
||||
.HasMaxLength(Rules.ValueMaxLength)
|
||||
.IsRequired();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation rules for the <see cref="GenericAttribute"/> entity.
|
||||
/// </summary>
|
||||
public static class Rules
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum length to set for the generic attribute key group.
|
||||
/// </summary>
|
||||
public const int KeyGroupMaxLength = 400;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum length to set for the generic attribute key.
|
||||
/// </summary>
|
||||
public const int KeyMinLength = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum length to set for the generic attribute key.
|
||||
/// </summary>
|
||||
public const int KeyMaxLength = 400;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum length to set for the generic attribute value.
|
||||
/// </summary>
|
||||
public const int ValueMaxLength = int.MaxValue;
|
||||
}
|
||||
}
|
||||
53
Prefab/Data/Configs/SeederLogConfig.cs
Normal file
53
Prefab/Data/Configs/SeederLogConfig.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Prefab.Data.Entities;
|
||||
|
||||
namespace Prefab.Data.Configs;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration class for the <see cref="SeederLog"/> entity.
|
||||
/// </summary>
|
||||
public class SeederLogConfig() : Prefab.Data.Configs.EntityConfig<SeederLog>(nameof(IPrefabDb.SeederLogs))
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration of Entity Framework mapping for the <see cref="SeederLog"/> entity.
|
||||
/// </summary>
|
||||
public override void Configure(EntityTypeBuilder<SeederLog> builder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
builder
|
||||
.HasKey(x => x.Id);
|
||||
|
||||
builder
|
||||
.Property(x => x.Id)
|
||||
.ValueGeneratedOnAdd();
|
||||
|
||||
builder.Property(x => x.SeederName)
|
||||
.HasMaxLength(Rules.SeederNameMaxLength)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(x => x.RunMode)
|
||||
.HasMaxLength(Rules.SeederRunModeMaxLength)
|
||||
.IsRequired();
|
||||
|
||||
builder
|
||||
.Property(x => x.RunAt)
|
||||
.IsRequired();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation rules for the <see cref="SeederLog"/> entity.
|
||||
/// </summary>
|
||||
public static class Rules
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum length to set for the seeder name.
|
||||
/// </summary>
|
||||
public const int SeederNameMaxLength = 256;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum length to set for the seeder name.
|
||||
/// </summary>
|
||||
public const int SeederRunModeMaxLength = 64;
|
||||
}
|
||||
}
|
||||
39
Prefab/Data/Entities/AuditLog.cs
Normal file
39
Prefab/Data/Entities/AuditLog.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using Prefab.Domain.Common;
|
||||
|
||||
namespace Prefab.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an audit log entry that captures an entity state transition.
|
||||
/// </summary>
|
||||
public class AuditLog : Entity<int>, ICreated
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the correlation identifier tying related audit events together.
|
||||
/// </summary>
|
||||
public Guid CorrelationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the fully qualified entity name that generated the audit record.
|
||||
/// </summary>
|
||||
public string Entity { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the serialized state of the entity when the audit was captured.
|
||||
/// </summary>
|
||||
public string State { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the collection of field-level changes associated with the audit.
|
||||
/// </summary>
|
||||
public virtual ICollection<AuditLogItem> Items { get; set; } = new List<AuditLogItem>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the identifier of the user who triggered the change.
|
||||
/// </summary>
|
||||
public Guid CreatedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timestamp when the change occurred.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedOn { get; set; }
|
||||
}
|
||||
34
Prefab/Data/Entities/AuditLogItem.cs
Normal file
34
Prefab/Data/Entities/AuditLogItem.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using Prefab.Domain.Common;
|
||||
|
||||
namespace Prefab.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a field-level change within an audit log entry.
|
||||
/// </summary>
|
||||
public class AuditLogItem : Entity<int>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the parent audit log identifier.
|
||||
/// </summary>
|
||||
public int AuditLogId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the property name that changed.
|
||||
/// </summary>
|
||||
public string Property { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the previous serialized value.
|
||||
/// </summary>
|
||||
public string? OldValue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the new serialized value.
|
||||
/// </summary>
|
||||
public string? NewValue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the navigation back to the owning audit log.
|
||||
/// </summary>
|
||||
public virtual AuditLog AuditLog { get; set; } = null!;
|
||||
}
|
||||
34
Prefab/Data/Entities/GenericAttribute.cs
Normal file
34
Prefab/Data/Entities/GenericAttribute.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using Prefab.Domain.Common;
|
||||
|
||||
namespace Prefab.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a persisted generic attribute for an entity.
|
||||
/// </summary>
|
||||
public class GenericAttribute : EntityWithAuditAndStatus<int>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the owning entity identifier.
|
||||
/// </summary>
|
||||
public Guid EntityId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the logical group used to partition attributes by entity type.
|
||||
/// </summary>
|
||||
public string KeyGroup { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the attribute key.
|
||||
/// </summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the fully qualified type name of the serialized value.
|
||||
/// </summary>
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the serialized attribute value.
|
||||
/// </summary>
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
24
Prefab/Data/Entities/SeederLog.cs
Normal file
24
Prefab/Data/Entities/SeederLog.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using Prefab.Domain.Common;
|
||||
|
||||
namespace Prefab.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Captures metadata about seeder executions to prevent duplicate runs.
|
||||
/// </summary>
|
||||
public class SeederLog : Entity<int>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the seeder identifier.
|
||||
/// </summary>
|
||||
public string SeederName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the run mode (e.g., Development or Production) used for the execution.
|
||||
/// </summary>
|
||||
public string RunMode { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp when the seeder completed.
|
||||
/// </summary>
|
||||
public DateTime RunAt { get; init; }
|
||||
}
|
||||
14
Prefab/Data/Enums.cs
Normal file
14
Prefab/Data/Enums.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace Prefab.Data;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Represents the available statuses that an entity's record can be in.
|
||||
/// </summary>
|
||||
public enum AuditStatus
|
||||
{
|
||||
Active,
|
||||
|
||||
Inactive,
|
||||
|
||||
Deleted
|
||||
}
|
||||
26
Prefab/Data/Extensions.cs
Normal file
26
Prefab/Data/Extensions.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Prefab.Domain.Common;
|
||||
|
||||
namespace Prefab.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="ModelBuilder"/> when configuring the database model.
|
||||
/// </summary>
|
||||
public static class ModelBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Ignores the domain event collection for all entities implementing <see cref="IHasDomainEvents"/>.
|
||||
/// </summary>
|
||||
/// <param name="builder">The model builder to configure.</param>
|
||||
public static void IgnoreDomainEvents(this ModelBuilder builder)
|
||||
{
|
||||
builder.Model
|
||||
.GetEntityTypes()
|
||||
.Where(e => typeof(IHasDomainEvents).IsAssignableFrom(e.ClrType))
|
||||
.ToList()
|
||||
.ForEach(e =>
|
||||
{
|
||||
builder.Entity(e.ClrType).Ignore(nameof(IHasDomainEvents.Events));
|
||||
});
|
||||
}
|
||||
}
|
||||
49
Prefab/Data/IPrefabDb.cs
Normal file
49
Prefab/Data/IPrefabDb.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Prefab.Data.Entities;
|
||||
|
||||
namespace Prefab.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the Prefab relational database context.
|
||||
/// </summary>
|
||||
public interface IPrefabDb : IDisposable, IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the EF Core database facade for executing commands and migrations.
|
||||
/// </summary>
|
||||
DatabaseFacade Database { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Persists pending changes to the database.
|
||||
/// </summary>
|
||||
int SaveChanges();
|
||||
|
||||
/// <summary>
|
||||
/// Persists pending changes to the database asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token used to cancel the operation.</param>
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the audit log set.
|
||||
/// </summary>
|
||||
DbSet<AuditLog> AuditLogs { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the audit log item set.
|
||||
/// </summary>
|
||||
DbSet<AuditLogItem> AuditLogItems { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the generic attribute set.
|
||||
/// </summary>
|
||||
DbSet<GenericAttribute> GenericAttributes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the seeder audit set.
|
||||
/// </summary>
|
||||
DbSet<SeederLog> SeederLogs { get; }
|
||||
}
|
||||
|
||||
|
||||
8
Prefab/Data/IPrefabDbReadOnly.cs
Normal file
8
Prefab/Data/IPrefabDbReadOnly.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Prefab.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Marker interface to represent read-only access to the Prefab database.
|
||||
/// </summary>
|
||||
public interface IPrefabDbReadOnly : IPrefabDb
|
||||
{
|
||||
}
|
||||
250
Prefab/Data/PrefabDb.cs
Normal file
250
Prefab/Data/PrefabDb.cs
Normal file
@@ -0,0 +1,250 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Prefab.Data.Entities;
|
||||
using Prefab.Domain.Common;
|
||||
using Prefab.Handler;
|
||||
using System.Reflection;
|
||||
|
||||
namespace Prefab.Data;
|
||||
|
||||
public abstract class PrefabDb(DbContextOptions options, IHandlerContextAccessor accessor)
|
||||
: DbContext(options)
|
||||
{
|
||||
/// <summary>
|
||||
/// Exposes the database facade for provider-specific operations.
|
||||
/// </summary>
|
||||
public new DatabaseFacade Database => base.Database;
|
||||
|
||||
/// <summary>
|
||||
/// Applies Prefab conventions in addition to consumer-provided model configuration.
|
||||
/// </summary>
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(IPrefabDb).Assembly);
|
||||
|
||||
PrefabOnModelCreating(modelBuilder);
|
||||
modelBuilder.IgnoreDomainEvents();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Allows derived contexts to extend model configuration.
|
||||
/// </summary>
|
||||
/// <param name="builder">The model builder supplied by EF Core.</param>
|
||||
protected abstract void PrefabOnModelCreating(ModelBuilder builder);
|
||||
|
||||
/// <summary>
|
||||
/// Applies Prefab defaults followed by consumer configuration hooks.
|
||||
/// </summary>
|
||||
/// <param name="optionsBuilder">The options builder used to configure the context.</param>
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
PrefabOnConfiguring(optionsBuilder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Allows derived contexts to customise provider-specific options.
|
||||
/// </summary>
|
||||
/// <param name="optionsBuilder">The options builder supplied by EF Core.</param>
|
||||
protected abstract void PrefabOnConfiguring(DbContextOptionsBuilder optionsBuilder);
|
||||
|
||||
/// <summary>
|
||||
/// Persists changes to the database while executing auditing and outbox processing.
|
||||
/// </summary>
|
||||
public override int SaveChanges() => SaveChangesAsync().ConfigureAwait(false).GetAwaiter().GetResult();
|
||||
|
||||
/// <summary>
|
||||
/// Persists changes to the database while executing auditing and outbox processing.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token used to cancel the save operation.</param>
|
||||
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = new())
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var correlationId = Guid.TryParse(accessor?.Current?.CorrelationId, out var parsedCorrelationId)
|
||||
? parsedCorrelationId
|
||||
: Guid.Empty;
|
||||
|
||||
var auditEntitiesCache = new Dictionary<Type, bool>();
|
||||
var propertiesCache = new Dictionary<Type, (PropertyInfo? CreatedBy, PropertyInfo? CreatedOn, PropertyInfo? ModifiedBy, PropertyInfo? ModifiedOn)>();
|
||||
var auditEntries = new List<Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry>();
|
||||
|
||||
foreach (var entry in ChangeTracker.Entries())
|
||||
{
|
||||
var entityType = entry.Entity.GetType();
|
||||
if (!auditEntitiesCache.TryGetValue(entityType, out var entityIsAuditable))
|
||||
{
|
||||
entityIsAuditable = entityType.GetInterfaces()
|
||||
.Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEntityWithAudit<>));
|
||||
auditEntitiesCache[entityType] = entityIsAuditable;
|
||||
}
|
||||
|
||||
if (!entityIsAuditable)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!propertiesCache.TryGetValue(entityType, out var props))
|
||||
{
|
||||
props = (
|
||||
CreatedBy: entityType.GetProperty(nameof(IAudit.CreatedBy)),
|
||||
CreatedOn: entityType.GetProperty(nameof(IAudit.CreatedOn)),
|
||||
ModifiedBy: entityType.GetProperty(nameof(IAudit.LastModifiedBy)),
|
||||
ModifiedOn: entityType.GetProperty(nameof(IAudit.LastModifiedOn))
|
||||
);
|
||||
propertiesCache[entityType] = props;
|
||||
}
|
||||
|
||||
switch (entry.State)
|
||||
{
|
||||
case EntityState.Added:
|
||||
props.CreatedBy?.SetValue(entry.Entity, props.CreatedBy.GetValue(entry.Entity) ?? accessor?.Current?.UserId);
|
||||
props.CreatedOn?.SetValue(entry.Entity, now);
|
||||
auditEntries.Add(entry);
|
||||
break;
|
||||
case EntityState.Modified:
|
||||
props.ModifiedBy?.SetValue(entry.Entity, accessor?.Current?.UserId ?? accessor?.Current?.UserId);
|
||||
props.ModifiedOn?.SetValue(entry.Entity, now);
|
||||
auditEntries.Add(entry);
|
||||
break;
|
||||
case EntityState.Deleted:
|
||||
if (TrySoftDelete(entry, props, accessor?.Current?.UserId, now))
|
||||
{
|
||||
entry.State = EntityState.Modified;
|
||||
auditEntries.Add(entry);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var entry in auditEntries)
|
||||
{
|
||||
var audit = new AuditLog
|
||||
{
|
||||
CorrelationId = correlationId,
|
||||
Entity = entry.Entity.GetType().Name,
|
||||
State = entry.State.ToString(),
|
||||
CreatedBy = Guid.TryParse(accessor?.Current?.UserId, out var userId) ? userId : Guid.Empty,
|
||||
CreatedOn = now,
|
||||
Items = new List<AuditLogItem>()
|
||||
};
|
||||
|
||||
foreach (var prop in entry.Properties)
|
||||
{
|
||||
if (entry.State == EntityState.Modified && !prop.IsModified)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var oldVal = entry.State == EntityState.Modified ? prop.OriginalValue?.ToString() : null;
|
||||
var newVal = prop.CurrentValue?.ToString();
|
||||
audit.Items.Add(new AuditLogItem
|
||||
{
|
||||
Property = prop.Metadata.Name,
|
||||
OldValue = oldVal,
|
||||
NewValue = newVal
|
||||
});
|
||||
}
|
||||
|
||||
AuditLogs.Add(audit);
|
||||
}
|
||||
|
||||
var domainEventEntities = ChangeTracker
|
||||
.Entries<IHasDomainEvents>()
|
||||
.Where(e => e.Entity.Events.Count > 0)
|
||||
.Select(e => e.Entity)
|
||||
.ToList();
|
||||
|
||||
if (domainEventEntities.Count > 0)
|
||||
{
|
||||
var domainEvents = domainEventEntities.SelectMany(e => e.Events).ToList();
|
||||
foreach (var ev in domainEvents)
|
||||
{
|
||||
//Outbox.Add(Prefab.Queuing.Domain.Outbox.New(ev));
|
||||
}
|
||||
|
||||
domainEventEntities.ForEach(e => e.ClearEvents());
|
||||
}
|
||||
|
||||
return await base.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static bool TrySoftDelete(
|
||||
Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry entry,
|
||||
(PropertyInfo? CreatedBy, PropertyInfo? CreatedOn, PropertyInfo? ModifiedBy, PropertyInfo? ModifiedOn) props,
|
||||
string? userId,
|
||||
DateTimeOffset deletedOn)
|
||||
{
|
||||
var entityType = entry.Entity.GetType();
|
||||
var supportsSoftDelete = entityType.GetInterfaces()
|
||||
.Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEntityWithAuditAndStatus<>));
|
||||
|
||||
if (!supportsSoftDelete)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var deletedOnProp = entityType.GetProperty(nameof(EntityWithAuditAndStatus<Guid>.DeletedOn));
|
||||
var deletedByProp = entityType.GetProperty(nameof(EntityWithAuditAndStatus<Guid>.DeletedBy));
|
||||
|
||||
deletedOnProp?.SetValue(entry.Entity, deletedOn);
|
||||
if (deletedByProp is not null)
|
||||
{
|
||||
if (Guid.TryParse(userId, out var parsedUserId))
|
||||
{
|
||||
deletedByProp.SetValue(entry.Entity, parsedUserId);
|
||||
}
|
||||
else
|
||||
{
|
||||
deletedByProp.SetValue(entry.Entity, null);
|
||||
}
|
||||
}
|
||||
|
||||
const string deletedOnPropertyName = nameof(EntityWithAuditAndStatus<Guid>.DeletedOn);
|
||||
if (entry.Metadata.FindProperty(deletedOnPropertyName) is not null)
|
||||
{
|
||||
entry.Property(deletedOnPropertyName).IsModified = true;
|
||||
}
|
||||
|
||||
const string deletedByPropertyName = nameof(EntityWithAuditAndStatus<Guid>.DeletedBy);
|
||||
if (entry.Metadata.FindProperty(deletedByPropertyName) is not null)
|
||||
{
|
||||
entry.Property(deletedByPropertyName).IsModified = true;
|
||||
}
|
||||
|
||||
props.ModifiedOn?.SetValue(entry.Entity, deletedOn);
|
||||
if (props.ModifiedBy is not null)
|
||||
{
|
||||
if (Guid.TryParse(userId, out var parsedUserId))
|
||||
{
|
||||
props.ModifiedBy.SetValue(entry.Entity, parsedUserId);
|
||||
}
|
||||
else
|
||||
{
|
||||
props.ModifiedBy.SetValue(entry.Entity, null);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the audit log set.
|
||||
/// </summary>
|
||||
public DbSet<AuditLog> AuditLogs { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the audit log item set.
|
||||
/// </summary>
|
||||
public DbSet<AuditLogItem> AuditLogItems { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the generic attribute set.
|
||||
/// </summary>
|
||||
public DbSet<GenericAttribute> GenericAttributes => Set<GenericAttribute>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the seeder log set.
|
||||
/// </summary>
|
||||
public DbSet<SeederLog> SeederLogs => Set<SeederLog>();
|
||||
}
|
||||
39
Prefab/Data/Queries/EntityAuditQueries.cs
Normal file
39
Prefab/Data/Queries/EntityAuditQueries.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using Prefab.Domain.Common;
|
||||
|
||||
namespace Prefab.Data.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Provides reusable audit-related query filters for entity sets.
|
||||
/// </summary>
|
||||
public static class EntityAuditQueries
|
||||
{
|
||||
/// <summary>
|
||||
/// Filters entities created before the specified timestamp.
|
||||
/// </summary>
|
||||
public static IQueryable<IAudit> ThatWasCreateBefore(this IQueryable<IAudit> query, DateTime dateTime) => query.Where(x => x.CreatedOn < dateTime);
|
||||
|
||||
/// <summary>
|
||||
/// Filters entities created after the specified timestamp.
|
||||
/// </summary>
|
||||
public static IQueryable<IAudit> ThatWasCreatedAfter(this IQueryable<IAudit> query, DateTime dateTime) => query.Where(x => x.CreatedOn > dateTime);
|
||||
|
||||
/// <summary>
|
||||
/// Filters entities created between the specified start and end timestamps.
|
||||
/// </summary>
|
||||
public static IQueryable<IAudit> ThatWasCreatedBetween(this IQueryable<IAudit> query, DateTime start, DateTime end) => query.ThatWasCreatedAfter(start).ThatWasCreateBefore(end);
|
||||
|
||||
/// <summary>
|
||||
/// Filters entities last modified before the specified timestamp.
|
||||
/// </summary>
|
||||
public static IQueryable<IAudit> ThatWasLastModifiedBefore(this IQueryable<IAudit> query, DateTime dateTime) => query.Where(x => x.LastModifiedOn < dateTime);
|
||||
|
||||
/// <summary>
|
||||
/// Filters entities last modified after the specified timestamp.
|
||||
/// </summary>
|
||||
public static IQueryable<IAudit> ThatWasLastModifiedAfter(this IQueryable<IAudit> query, DateTime dateTime) => query.Where(x => x.LastModifiedOn > dateTime);
|
||||
|
||||
/// <summary>
|
||||
/// Filters entities last modified between the specified start and end timestamps.
|
||||
/// </summary>
|
||||
public static IQueryable<IAudit> ThatWasLastModifiedBetween(this IQueryable<IAudit> query, DateTime start, DateTime end) => query.ThatWasLastModifiedAfter(start).ThatWasLastModifiedBefore(end);
|
||||
}
|
||||
24
Prefab/Data/Queries/EntityStatusQueries.cs
Normal file
24
Prefab/Data/Queries/EntityStatusQueries.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using Prefab.Domain.Common;
|
||||
|
||||
namespace Prefab.Data.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Provides query helpers for filtering entities by audit status.
|
||||
/// </summary>
|
||||
public static class EntityStatusQueries
|
||||
{
|
||||
/// <summary>
|
||||
/// Filters entities with an <see cref="AuditStatus.Active"/> status.
|
||||
/// </summary>
|
||||
public static IQueryable<IStatus> WithAnActiveStatus(this IQueryable<IStatus> query) => query.Where(x => x.AuditStatus == AuditStatus.Active);
|
||||
|
||||
/// <summary>
|
||||
/// Filters entities with an <see cref="AuditStatus.Inactive"/> status.
|
||||
/// </summary>
|
||||
public static IQueryable<IStatus> WithAnInactiveStatus(this IQueryable<IStatus> query) => query.Where(x => x.AuditStatus == AuditStatus.Inactive);
|
||||
|
||||
/// <summary>
|
||||
/// Filters entities with an <see cref="AuditStatus.Deleted"/> status.
|
||||
/// </summary>
|
||||
public static IQueryable<IStatus> WithADeletedStatus(this IQueryable<IStatus> query) => query.Where(x => x.AuditStatus == AuditStatus.Deleted);
|
||||
}
|
||||
107
Prefab/Data/Queries/GenericAttributeQueries.cs
Normal file
107
Prefab/Data/Queries/GenericAttributeQueries.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Prefab.Data.Entities;
|
||||
|
||||
using Prefab.Domain.Common;
|
||||
|
||||
namespace Prefab.Data.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for the <see cref="GenericAttribute"/> entity.
|
||||
/// </summary>
|
||||
public static class GenericAttributeQueries
|
||||
{
|
||||
/// <summary>
|
||||
/// Query for records where the <see cref="GenericAttribute.EntityId"/> field matches the given entity.
|
||||
/// </summary>
|
||||
public static IQueryable<GenericAttribute> ForEntity(this IQueryable<GenericAttribute> query, IEntity<Guid> entity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
|
||||
return query.Where(x => x.EntityId == entity.Id && x.KeyGroup == entity.GetType().FullName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query for records in a module-specific view where the EntityId matches the given entity.
|
||||
/// </summary>
|
||||
public static IQueryable<TView> ForEntity<TView>(this IQueryable<TView> query, IEntity<Guid> entity, string keyGroup)
|
||||
where TView : class
|
||||
{
|
||||
return GenericAttributeViewQueries.ForEntity(query, entity, keyGroup);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query for records where the <see cref="GenericAttribute.Value"/> field contains characters from the given string value.
|
||||
/// </summary>
|
||||
public static IQueryable<GenericAttribute> WhereTheValueContains(this IQueryable<GenericAttribute> query, string value)
|
||||
{
|
||||
return query.Where(x => x.Value.Contains(value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter by key (exact match).
|
||||
/// </summary>
|
||||
private static IQueryable<GenericAttribute> WhereKey(
|
||||
this IQueryable<GenericAttribute> q, string key) =>
|
||||
q.Where(x => x.Key == key);
|
||||
|
||||
/// <summary>
|
||||
/// Filter by key-group (exact match).
|
||||
/// </summary>
|
||||
public static IQueryable<GenericAttribute> WhereGroup(
|
||||
this IQueryable<GenericAttribute> q, string keyGroup) =>
|
||||
q.Where(x => x.KeyGroup == keyGroup);
|
||||
|
||||
/// <summary>
|
||||
/// Filter by type (exact match).
|
||||
/// </summary>
|
||||
public static IQueryable<GenericAttribute> WhereType(
|
||||
this IQueryable<GenericAttribute> q, string type) =>
|
||||
q.Where(x => x.Type == type);
|
||||
|
||||
/// <summary>
|
||||
/// Filter by any of the given keys.
|
||||
/// </summary>
|
||||
public static IQueryable<GenericAttribute> WhereKeys(
|
||||
this IQueryable<GenericAttribute> q, params string[] keys) =>
|
||||
q.Where(x => keys.Contains(x.Key));
|
||||
|
||||
/// <summary>
|
||||
/// Case-insensitive substring search on Value using EF.Functions.Like.
|
||||
/// </summary>
|
||||
public static IQueryable<GenericAttribute> WhereValueLike(
|
||||
this IQueryable<GenericAttribute> q, string pattern) =>
|
||||
q.Where(x => EF.Functions.Like(x.Value, pattern));
|
||||
|
||||
/// <summary>
|
||||
/// Project into a key→value dictionary.
|
||||
/// </summary>
|
||||
public static async Task<Dictionary<string, string>> ToDictionaryAsync(
|
||||
this IQueryable<GenericAttribute> q,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await q
|
||||
.Select(x => new { x.Key, x.Value })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Value, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shortcut to fetch a single attribute value (or null).
|
||||
/// </summary>
|
||||
public static async Task<string?> GetValueAsync(
|
||||
this IQueryable<GenericAttribute> q, string key, CancellationToken ct = default)
|
||||
{
|
||||
return await q
|
||||
.WhereKey(key)
|
||||
.Select(x => x.Value)
|
||||
.FirstOrDefaultAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply AsNoTracking for read-only queries.
|
||||
/// </summary>
|
||||
public static IQueryable<GenericAttribute> AsReadOnly(
|
||||
this IQueryable<GenericAttribute> q) =>
|
||||
q.AsNoTracking();
|
||||
}
|
||||
30
Prefab/Data/Queries/GenericAttributeViewQueries.cs
Normal file
30
Prefab/Data/Queries/GenericAttributeViewQueries.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Prefab.Domain.Common;
|
||||
|
||||
namespace Prefab.Data.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Provides shared filters for working with generic attribute views.
|
||||
/// </summary>
|
||||
public static class GenericAttributeViewQueries
|
||||
{
|
||||
/// <summary>
|
||||
/// Filters a module-specific view by entity identifier and key group.
|
||||
/// </summary>
|
||||
public static IQueryable<TView> ForEntity<TView>(this IQueryable<TView> query, IEntity<Guid> entity, string keyGroup)
|
||||
where TView : class
|
||||
{
|
||||
if (entity == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(entity));
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(keyGroup))
|
||||
{
|
||||
throw new ArgumentException("KeyGroup cannot be null or empty.", nameof(keyGroup));
|
||||
}
|
||||
|
||||
return query.Where(x => EF.Property<Guid>(x, "EntityId") == entity.Id &&
|
||||
EF.Property<string>(x, "KeyGroup") == keyGroup);
|
||||
}
|
||||
}
|
||||
29
Prefab/Data/Seeder/Extensions.cs
Normal file
29
Prefab/Data/Seeder/Extensions.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace Prefab.Data.Seeder;
|
||||
|
||||
public static class Extensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers a seeder and its options so it can participate in environment-aware execution.
|
||||
/// </summary>
|
||||
/// <typeparam name="TSeeder">Seeder implementation.</typeparam>
|
||||
/// <param name="services">The host service collection.</param>
|
||||
/// <param name="runMode">Determines when the seeder is allowed to run.</param>
|
||||
/// <returns>The provided service collection.</returns>
|
||||
public static IServiceCollection AddSeeder<TSeeder>(this IServiceCollection services, RunMode runMode = RunMode.RunOnFirstLoadOnly) where TSeeder : class, ISeeder
|
||||
{
|
||||
services.AddScoped<ISeeder, TSeeder>();
|
||||
services.AddScoped<TSeeder>();
|
||||
services.Configure<SeederOptions<TSeeder>>(opts => opts.RunMode = runMode);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the channel used to queue seeder operations for background execution.
|
||||
/// </summary>
|
||||
public static Channel<Func<IServiceProvider, CancellationToken, Task>> Channel { get; }
|
||||
= System.Threading.Channels.Channel.CreateUnbounded<Func<IServiceProvider, CancellationToken, Task>>();
|
||||
}
|
||||
12
Prefab/Data/Seeder/ISeeder.cs
Normal file
12
Prefab/Data/Seeder/ISeeder.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Prefab.Data.Seeder;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a seeder that can be used to seed the database with data.
|
||||
/// </summary>
|
||||
public interface ISeeder
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes the seed task.
|
||||
/// </summary>
|
||||
Task Execute(IServiceProvider serviceProvider, CancellationToken cancellationToken);
|
||||
}
|
||||
13
Prefab/Data/Seeder/Options.cs
Normal file
13
Prefab/Data/Seeder/Options.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Prefab.Data.Seeder;
|
||||
|
||||
/// <summary>
|
||||
/// Options for a specific <see cref="ISeeder"/> implementation.
|
||||
/// </summary>
|
||||
/// <typeparam name="TSeeder">The seeder type these options apply to.</typeparam>
|
||||
public class SeederOptions<TSeeder>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets when the seeder should execute.
|
||||
/// </summary>
|
||||
public RunMode RunMode { get; set; } = RunMode.RunOnFirstLoadOnly;
|
||||
}
|
||||
199
Prefab/Data/Seeder/PrefabDbSeeder.cs
Normal file
199
Prefab/Data/Seeder/PrefabDbSeeder.cs
Normal file
@@ -0,0 +1,199 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Prefab.Base;
|
||||
using Prefab.Data.Entities;
|
||||
|
||||
namespace Prefab.Data.Seeder;
|
||||
|
||||
/// <summary>
|
||||
/// Base infrastructure for executing database seeders with lookup synchronization support.
|
||||
/// </summary>
|
||||
/// <typeparam name="TSeeder">The concrete seeder implementation.</typeparam>
|
||||
/// <typeparam name="TDb">The database abstraction used by the seeder.</typeparam>
|
||||
public abstract class PrefabDbSeeder<TSeeder, TDb> : ISeeder where TDb : IPrefabDb
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task Execute(IServiceProvider serviceProvider, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var db = serviceProvider.GetRequiredService<TDb>();
|
||||
var options = serviceProvider.GetService<IOptions<SeederOptions<TSeeder>>>()?.Value ?? new SeederOptions<TSeeder>();
|
||||
var seederName = typeof(TSeeder).FullName ?? typeof(TSeeder).Name;
|
||||
var runMode = options.RunMode;
|
||||
var shouldSeedData = false;
|
||||
|
||||
await SeedLookups(db, cancellationToken);
|
||||
await ApplyViews(db, serviceProvider, cancellationToken);
|
||||
|
||||
switch (runMode)
|
||||
{
|
||||
case RunMode.RunAlways:
|
||||
shouldSeedData = true;
|
||||
break;
|
||||
case RunMode.SingleRun:
|
||||
case RunMode.RunOnFirstLoadOnly:
|
||||
shouldSeedData = !db.SeederLogs.Any(x => x.SeederName == seederName);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!shouldSeedData)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await SeedData(db, serviceProvider, cancellationToken);
|
||||
|
||||
db.SeederLogs.Add(new SeederLog
|
||||
{
|
||||
SeederName = seederName,
|
||||
RunMode = runMode.ToString(),
|
||||
RunAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies database views, ensuring dependent queries exist before data seeding runs.
|
||||
/// </summary>
|
||||
protected abstract Task ApplyViews(TDb db, IServiceProvider serviceProvider, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Seeds data specific to the derived context.
|
||||
/// </summary>
|
||||
protected abstract Task SeedData(TDb db, IServiceProvider serviceProvider, CancellationToken cancellationToken);
|
||||
|
||||
private static async Task SeedLookups(TDb dbContext, CancellationToken cancellationToken)
|
||||
{
|
||||
var processedTypes = new HashSet<Type>();
|
||||
var dbContextType = dbContext.GetType();
|
||||
var dbSetProperties = dbContextType.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(prop => prop.PropertyType.IsGenericType &&
|
||||
prop.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>));
|
||||
|
||||
foreach (var prop in dbSetProperties)
|
||||
{
|
||||
var entityType = prop.PropertyType.GetGenericArguments()[0];
|
||||
if (!processedTypes.Add(entityType))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!IsSubclassOfGeneric(entityType, typeof(LookupEntity<>)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var baseType = entityType.BaseType;
|
||||
if (baseType is not { IsGenericType: true } ||
|
||||
baseType.GetGenericTypeDefinition() != typeof(LookupEntity<>))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var enumType = baseType.GetGenericArguments()[0];
|
||||
var method = typeof(PrefabDbSeeder<TSeeder, TDb>).GetMethod(nameof(SeedLookupAsync), BindingFlags.NonPublic | BindingFlags.Static);
|
||||
if (method is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var genericMethod = method.MakeGenericMethod(entityType, enumType);
|
||||
await (Task)genericMethod.Invoke(null, new object[] { dbContext, cancellationToken })!;
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
static bool IsSubclassOfGeneric(Type type, Type generic)
|
||||
{
|
||||
while (type is not null && type != typeof(object))
|
||||
{
|
||||
var current = type.IsGenericType ? type.GetGenericTypeDefinition() : type;
|
||||
if (current == generic)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
type = type.BaseType!;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SeedLookupAsync<TLookup, TEnum>(DbContext dbContext, CancellationToken cancellationToken)
|
||||
where TLookup : LookupEntity<TEnum>, new()
|
||||
where TEnum : PrefabEnum<TEnum>
|
||||
{
|
||||
var dbSet = dbContext.Set<TLookup>();
|
||||
|
||||
var enumMembers = PrefabEnum<TEnum>.List
|
||||
.Select(e => new
|
||||
{
|
||||
Value = e.Value,
|
||||
Name = e.Name,
|
||||
ShortName = GetOptionalString(e, "ShortName"),
|
||||
Description = GetOptionalString(e, "Description"),
|
||||
SortOrder = e.Value
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var existingRows = await dbSet.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var member in enumMembers)
|
||||
{
|
||||
var lookupRow = existingRows.FirstOrDefault(r => r.Id == member.Value);
|
||||
if (lookupRow is null)
|
||||
{
|
||||
lookupRow = new TLookup
|
||||
{
|
||||
Id = member.Value,
|
||||
Name = member.Name,
|
||||
ShortName = member.ShortName,
|
||||
Description = member.Description,
|
||||
SortOrder = member.SortOrder
|
||||
};
|
||||
dbSet.Add(lookupRow);
|
||||
}
|
||||
else if (lookupRow.Name != member.Name ||
|
||||
lookupRow.ShortName != member.ShortName ||
|
||||
lookupRow.Description != member.Description ||
|
||||
lookupRow.SortOrder != member.SortOrder)
|
||||
{
|
||||
lookupRow.Name = member.Name;
|
||||
lookupRow.ShortName = member.ShortName;
|
||||
lookupRow.Description = member.Description;
|
||||
lookupRow.SortOrder = member.SortOrder;
|
||||
dbSet.Update(lookupRow);
|
||||
}
|
||||
}
|
||||
|
||||
var validValues = enumMembers.Select(m => m.Value).ToHashSet();
|
||||
foreach (var row in existingRows.Where(row => !validValues.Contains(row.Id)))
|
||||
{
|
||||
dbSet.Remove(row);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
static string? GetOptionalString(PrefabEnum<TEnum> value, string propertyName)
|
||||
{
|
||||
var property = value.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.FlattenHierarchy);
|
||||
if (property is null || property.PropertyType != typeof(string))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return property.GetValue(value) as string;
|
||||
}
|
||||
}
|
||||
}
|
||||
21
Prefab/Data/Seeder/RunMode.cs
Normal file
21
Prefab/Data/Seeder/RunMode.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace Prefab.Data.Seeder;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates when a data loader should be run.
|
||||
/// </summary>
|
||||
public enum RunMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Data loader will be run only for a newly-initialized database
|
||||
/// </summary>
|
||||
RunOnFirstLoadOnly,
|
||||
/// <summary>
|
||||
/// Data loader will be run once only in a given database
|
||||
/// </summary>
|
||||
SingleRun,
|
||||
/// <summary>
|
||||
/// Data loader will run every time the app starts up
|
||||
/// </summary>
|
||||
RunAlways
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user