Files
SVSimServer/SVSim.Database/SVSimDbContext.cs
2026-05-24 17:07:05 -04:00

222 lines
10 KiB
C#

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;
namespace SVSim.Database;
public class SVSimDbContext : DbContext
{
private readonly ILogger<SVSimDbContext> _logger;
public SVSimDbContext(ILogger<SVSimDbContext> logger, DbContextOptions<SVSimDbContext> options) : base(options)
{
_logger = logger;
}
#region DbSets
public DbSet<Viewer> Viewers => Set<Viewer>();
public DbSet<ShadowverseCardEntry> Cards => Set<ShadowverseCardEntry>();
public DbSet<ShadowverseCardSetEntry> CardSets => Set<ShadowverseCardSetEntry>();
public DbSet<ShadowverseDeckEntry> Decks => Set<ShadowverseDeckEntry>();
public DbSet<CardCosmeticReward> CardCosmeticRewards => Set<CardCosmeticReward>();
public DbSet<ClassEntry> Classes => Set<ClassEntry>();
public DbSet<ClassExpEntry> ClassExpCurve => Set<ClassExpEntry>();
public DbSet<LeaderSkinEntry> LeaderSkins => Set<LeaderSkinEntry>();
public DbSet<SleeveEntry> Sleeves => Set<SleeveEntry>();
public DbSet<EmblemEntry> Emblems => Set<EmblemEntry>();
public DbSet<DegreeEntry> Degrees => Set<DegreeEntry>();
public DbSet<MyPageBackgroundEntry> MyPageBackgrounds => Set<MyPageBackgroundEntry>();
public DbSet<BattlefieldEntry> Battlefields => Set<BattlefieldEntry>();
public DbSet<RankInfoEntry> RankInfo => Set<RankInfoEntry>();
public DbSet<ItemEntry> Items => Set<ItemEntry>();
public DbSet<GameConfiguration> GameConfigurations => Set<GameConfiguration>();
// Prod-captured globals — populated by SVSim.Bootstrap, not HasData. See
// docs/audits/prod-data-capture-strategy-2026-05-23.md.
public DbSet<MyRotationSettingEntry> MyRotationSettings => Set<MyRotationSettingEntry>();
public DbSet<MyRotationAbilityEntry> MyRotationAbilities => Set<MyRotationAbilityEntry>();
public DbSet<AvatarAbilityEntry> AvatarAbilities => Set<AvatarAbilityEntry>();
public DbSet<DefaultDeckEntry> DefaultDecks => Set<DefaultDeckEntry>();
public DbSet<DefaultLeaderSkinSettingEntry> DefaultLeaderSkinSettings => Set<DefaultLeaderSkinSettingEntry>();
public DbSet<ArenaSeasonConfig> ArenaSeasons => Set<ArenaSeasonConfig>();
public DbSet<SpotCardEntry> SpotCards => Set<SpotCardEntry>();
public DbSet<ReprintedCardEntry> ReprintedCards => Set<ReprintedCardEntry>();
public DbSet<UnlimitedRestrictionEntry> UnlimitedRestrictions => Set<UnlimitedRestrictionEntry>();
public DbSet<LoadingExclusionCardEntry> LoadingExclusionCards => Set<LoadingExclusionCardEntry>();
public DbSet<BattlePassLevelEntry> BattlePassLevels => Set<BattlePassLevelEntry>();
public DbSet<DailyLoginBonusEntry> DailyLoginBonuses => Set<DailyLoginBonusEntry>();
public DbSet<BannerEntry> Banners => Set<BannerEntry>();
public DbSet<ColosseumConfig> Colosseums => Set<ColosseumConfig>();
public DbSet<SealedConfig> SealedSeasons => Set<SealedConfig>();
public DbSet<MasterPointRankingPeriodEntry> MasterPointRankingPeriods => Set<MasterPointRankingPeriodEntry>();
public DbSet<SpecialDeckFormatEntry> SpecialDeckFormats => Set<SpecialDeckFormatEntry>();
public DbSet<PaymentItemEntry> PaymentItems => Set<PaymentItemEntry>();
public DbSet<PackConfigEntry> Packs => Set<PackConfigEntry>();
public DbSet<MaintenanceCardEntry> MaintenanceCards => Set<MaintenanceCardEntry>();
public DbSet<FeatureMaintenanceEntry> FeatureMaintenances => Set<FeatureMaintenanceEntry>();
public DbSet<PreReleaseInfo> PreReleaseInfos => Set<PreReleaseInfo>();
public DbSet<PracticeOpponentEntry> PracticeOpponents => Set<PracticeOpponentEntry>();
#endregion
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
foreach (var entityEntry in ChangeTracker.Entries())
{
if (entityEntry.Entity is ITimeTrackedEntity timeTrackedEntity)
{
if (entityEntry.State is EntityState.Added && timeTrackedEntity.DateCreated == DateTime.MinValue)
{
timeTrackedEntity.DateCreated = DateTime.UtcNow;
}
if (entityEntry.State is EntityState.Modified or EntityState.Added)
{
timeTrackedEntity.DateUpdated = DateTime.UtcNow;
}
}
}
return await base.SaveChangesAsync(cancellationToken);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ShadowverseDeckEntry>()
.OwnsMany(de => de.Cards);
// BaseEntity<TKey> annotates Id with [DatabaseGenerated(None)] for the integer-PK
// entities seeded via HasData. ShadowverseDeckEntry uses Guid and is created at
// runtime — without client-side generation every new deck gets Guid.Empty and the
// second deck insert collides on PK. (DDL has no column default; this only works
// because EF generates a sequential Guid before INSERT.)
modelBuilder.Entity<ShadowverseDeckEntry>()
.Property(d => d.Id)
.ValueGeneratedOnAdd();
// EF can't figure this many-to-many out on its own
modelBuilder.Entity<SleeveEntry>()
.HasMany(se => se.Viewers)
.WithMany(v => v.Sleeves);
modelBuilder.HasSequence<long>("ShortUdidSequence").StartsAt(400000000);
modelBuilder.Entity<Viewer>()
.Property(v => v.ShortUdid)
.UseSequence("ShortUdidSequence");
modelBuilder.Entity<PackConfigEntry>().OwnsMany(p => p.ChildGachas);
modelBuilder.Entity<PackConfigEntry>().OwnsMany(p => p.Banners);
modelBuilder.Entity<Viewer>().OwnsMany(v => v.PackOpenCounts);
modelBuilder.Entity<CardCosmeticReward>(b =>
{
b.HasKey(r => new { r.CardId, r.Type, r.CosmeticId });
b.HasIndex(r => r.CardId);
// No inverse nav on the Card side — avoid forcing CosmeticRewards to load on every
// Card query. See project_ef_split_query memory for the cartesian-explode risk.
b.HasOne(r => r.Card)
.WithMany()
.HasForeignKey(r => r.CardId)
.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)
{
// 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);
}
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);
}
public void UpdateDatabase()
{
IEnumerable<string> pendingMigrations = Database.GetPendingMigrations();
if (!pendingMigrations.Any())
{
_logger.LogDebug("No pending migrations found, continuing.");
return;
}
foreach (string migration in pendingMigrations)
{
_logger.LogInformation("Found pending migration with name {migrationName}.", migration);
}
_logger.LogInformation("Attempting to apply pending migrations...");
Database.Migrate();
_logger.LogInformation("Migrations applied.");
}
/// <summary>
/// Idempotent runtime seed for entities that can't use HasData (notably GameConfiguration
/// because of EF Core 8's HasData+OwnsOne(ToJson) jsonb limitation).
/// </summary>
public async Task EnsureSeedDataAsync()
{
if (!await GameConfigurations.AnyAsync(c => c.Id == "default"))
{
GameConfigurations.Add(new Models.GameConfiguration
{
Id = "default",
Config = new Models.GameConfigRoot(),
});
await SaveChangesAsync();
_logger.LogInformation("Seeded default GameConfiguration row.");
}
}
}