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 _logger; public SVSimDbContext(ILogger logger, DbContextOptions options) : base(options) { _logger = logger; } #region DbSets public DbSet Viewers => Set(); public DbSet Cards => Set(); public DbSet CardSets => Set(); public DbSet Decks => Set(); public DbSet CardCosmeticRewards => Set(); public DbSet Classes => Set(); public DbSet ClassExpCurve => Set(); public DbSet LeaderSkins => Set(); public DbSet Sleeves => Set(); public DbSet Emblems => Set(); public DbSet Degrees => Set(); public DbSet MyPageBackgrounds => Set(); public DbSet Battlefields => Set(); public DbSet RankInfo => Set(); public DbSet Items => Set(); public DbSet GameConfigurations => Set(); // Prod-captured globals — populated by SVSim.Bootstrap, not HasData. See // docs/audits/prod-data-capture-strategy-2026-05-23.md. public DbSet MyRotationSettings => Set(); public DbSet MyRotationAbilities => Set(); public DbSet AvatarAbilities => Set(); public DbSet DefaultDecks => Set(); public DbSet DefaultLeaderSkinSettings => Set(); public DbSet ArenaSeasons => Set(); public DbSet SpotCards => Set(); public DbSet ReprintedCards => Set(); public DbSet UnlimitedRestrictions => Set(); public DbSet LoadingExclusionCards => Set(); public DbSet BattlePassLevels => Set(); public DbSet DailyLoginBonuses => Set(); public DbSet Banners => Set(); public DbSet Colosseums => Set(); public DbSet SealedSeasons => Set(); public DbSet MasterPointRankingPeriods => Set(); public DbSet SpecialDeckFormats => Set(); public DbSet PaymentItems => Set(); public DbSet Packs => Set(); public DbSet MaintenanceCards => Set(); public DbSet FeatureMaintenances => Set(); public DbSet PreReleaseInfos => Set(); public DbSet PracticeOpponents => Set(); #endregion public override async Task 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() .OwnsMany(de => de.Cards); // BaseEntity 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() .Property(d => d.Id) .ValueGeneratedOnAdd(); // EF can't figure this many-to-many out on its own modelBuilder.Entity() .HasMany(se => se.Viewers) .WithMany(v => v.Sleeves); modelBuilder.HasSequence("ShortUdidSequence").StartsAt(400000000); modelBuilder.Entity() .Property(v => v.ShortUdid) .UseSequence("ShortUdidSequence"); modelBuilder.Entity().OwnsMany(p => p.ChildGachas); modelBuilder.Entity().OwnsMany(p => p.Banners); modelBuilder.Entity().OwnsMany(v => v.PackOpenCounts); modelBuilder.Entity(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( model => JsonSerializer.Serialize(model, (JsonSerializerOptions?)null), json => JsonSerializer.Deserialize(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( (a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(b, (JsonSerializerOptions?)null), v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(), v => JsonSerializer.Deserialize( JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null) ?? new GameConfigRoot()); modelBuilder.Entity() .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().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 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."); } /// /// Idempotent runtime seed for entities that can't use HasData (notably GameConfiguration /// because of EF Core 8's HasData+OwnsOne(ToJson) jsonb limitation). /// 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."); } } }