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