Seeding reorg

This commit is contained in:
gamer147
2026-05-24 21:13:15 -04:00
parent 34bcc579a5
commit c14408ba06
73 changed files with 4611 additions and 369716 deletions

View File

@@ -1,11 +1,8 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Microsoft.Extensions.Logging;
using SVSim.Database.Common;
using SVSim.Database.DataSeeders;
using SVSim.Database.Models;
using SVSim.Database.Models.Config;
namespace SVSim.Database;
@@ -38,7 +35,7 @@ public class SVSimDbContext : DbContext
public DbSet<RankInfoEntry> RankInfo => Set<RankInfoEntry>();
public DbSet<ItemEntry> Items => Set<ItemEntry>();
public DbSet<GameConfiguration> GameConfigurations => Set<GameConfiguration>();
public DbSet<GameConfigSection> GameConfigs => Set<GameConfigSection>();
// Prod-captured globals — populated by SVSim.Bootstrap, not HasData. See
// docs/audits/prod-data-capture-strategy-2026-05-23.md.
@@ -128,57 +125,18 @@ public class SVSimDbContext : DbContext
.OnDelete(DeleteBehavior.Cascade);
});
// GameConfiguration.Config: on Postgres use EF Core 8's OwnsOne+ToJson(jsonb column).
// On SQLite (tests) ToJson's WriteJson has a known NullReferenceException when owned
// collections are present — use a plain TEXT value converter instead so the same
// entity shape works for both providers without separate test models.
bool isSqlite = Database.ProviderName?.Contains("Sqlite", StringComparison.OrdinalIgnoreCase) == true;
if (isSqlite)
// GameConfigSection: one row per top-level config section. Postgres stores ValueJson as
// jsonb (gives jsonb-side queryability if needed later); SQLite gets a plain TEXT column.
// EF never sees the section POCO shapes — IGameConfigService owns deserialisation via STJ.
// Replaces the old single-row GameConfigurations table with its EF Core 8 OwnsOne+ToJson
// tree; see 2026-05-24 config refactor.
bool isPostgres = Database.ProviderName?.Contains("Npgsql", StringComparison.OrdinalIgnoreCase) == true;
if (isPostgres)
{
// Store as JSON text via a value converter; EF treats Config as a single column.
var configConverter = new ValueConverter<GameConfigRoot, string>(
model => JsonSerializer.Serialize(model, (JsonSerializerOptions?)null),
json => JsonSerializer.Deserialize<GameConfigRoot>(json, (JsonSerializerOptions?)null)
?? new GameConfigRoot());
// Deep-equality comparer: serialize both sides and compare strings so that
// mutations to nested properties (e.g. Config.Rotation.TsRotationId = "10015")
// are detected by EF's snapshot change tracker and written to the DB on SaveChanges.
var configComparer = new ValueComparer<GameConfigRoot>(
(a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null)
== JsonSerializer.Serialize(b, (JsonSerializerOptions?)null),
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(),
v => JsonSerializer.Deserialize<GameConfigRoot>(
JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
(JsonSerializerOptions?)null) ?? new GameConfigRoot());
modelBuilder.Entity<GameConfiguration>()
.Property(c => c.Config)
.HasColumnName("Config")
.HasConversion(configConverter, configComparer);
modelBuilder.Entity<GameConfigSection>()
.Property(s => s.ValueJson)
.HasColumnType("jsonb");
}
else
{
// Production path: jsonb column with full OwnsOne tree so EF can filter/project
// into sub-properties at the DB level if needed.
modelBuilder.Entity<GameConfiguration>().OwnsOne(c => c.Config, b =>
{
b.ToJson("Config");
b.OwnsOne(r => r.DefaultGrants);
b.OwnsOne(r => r.Player);
b.OwnsOne(r => r.DefaultLoadout);
b.OwnsOne(r => r.Challenge);
b.OwnsOne(r => r.Rotation);
b.OwnsOne(r => r.PackRates, pr =>
{
pr.OwnsOne(p => p.Default);
pr.OwnsMany(p => p.PerSlot);
});
});
}
new BaseDataSeeder().Seed(modelBuilder);
new DefaultSettingsSeeder().Seed(modelBuilder);
base.OnModelCreating(modelBuilder);
}
@@ -202,20 +160,60 @@ public class SVSimDbContext : DbContext
}
/// <summary>
/// Idempotent runtime seed for entities that can't use HasData (notably GameConfiguration
/// because of EF Core 8's HasData+OwnsOne(ToJson) jsonb limitation).
/// Idempotent runtime seed for entities that can't use HasData. For GameConfigSection: walks
/// every <see cref="ConfigSectionAttribute"/>-marked POCO in the Models.Config namespace and
/// inserts a row containing its <c>ShippedDefaults()</c> payload if no row for that section
/// name exists. Safe to run on every startup — only missing rows are added; operator-edited
/// rows are left alone.
/// </summary>
public async Task EnsureSeedDataAsync()
{
if (!await GameConfigurations.AnyAsync(c => c.Id == "default"))
var existing = await GameConfigs.Select(s => s.SectionName).ToListAsync();
var existingSet = new HashSet<string>(existing, StringComparer.Ordinal);
int added = 0;
foreach (var (name, json) in EnumerateShippedDefaults())
{
if (existingSet.Contains(name)) continue;
GameConfigs.Add(new GameConfigSection { SectionName = name, ValueJson = json });
added++;
}
if (added > 0)
{
GameConfigurations.Add(new Models.GameConfiguration
{
Id = "default",
Config = new Models.GameConfigRoot(),
});
await SaveChangesAsync();
_logger.LogInformation("Seeded default GameConfiguration row.");
_logger.LogInformation("Seeded {Count} default GameConfigSection row(s).", added);
}
}
private static IEnumerable<(string Name, string Json)> EnumerateShippedDefaults()
{
// Reflect over every [ConfigSection]-marked type in the same assembly as PackRateConfig.
// Each type must expose a parameterless `public static T ShippedDefaults()` — see the
// POCOs in Models/Config for the convention.
var asm = typeof(PackRateConfig).Assembly;
var stjOptions = new System.Text.Json.JsonSerializerOptions
{
WriteIndented = false,
};
foreach (var t in asm.GetTypes())
{
var attr = t.GetCustomAttributes(typeof(ConfigSectionAttribute), inherit: false)
.Cast<ConfigSectionAttribute>().FirstOrDefault();
if (attr is null) continue;
var factory = t.GetMethod("ShippedDefaults",
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static,
binder: null, types: Type.EmptyTypes, modifiers: null);
if (factory is null)
{
throw new InvalidOperationException(
$"[ConfigSection] type {t.FullName} is missing `public static {t.Name} ShippedDefaults()`.");
}
var instance = factory.Invoke(null, null)
?? throw new InvalidOperationException($"{t.FullName}.ShippedDefaults() returned null.");
yield return (attr.Name, System.Text.Json.JsonSerializer.Serialize(instance, t, stjOptions));
}
}
}