Init
This commit is contained in:
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