Seeding reorg
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user