This commit is contained in:
2025-10-27 17:39:18 -04:00
commit 31f723bea4
1579 changed files with 642409 additions and 0 deletions

View 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;
}
}
}

View 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;
}
}
}

View 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);
}
}
}

View 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;
}
}

View 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;
}
}

View 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; }
}

View 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!;
}

View 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;
}

View 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
View 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
View 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
View 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; }
}

View 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
View 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>();
}

View 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);
}

View 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);
}

View 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();
}

View 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);
}
}

View 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>>();
}

View 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);
}

View 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;
}

View 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;
}
}
}

View 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
}