using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.ValueGeneration; using SVSim.Database.Models; namespace SVSim.UnitTests.Infrastructure; /// /// Replaces the default in tests. After the normal /// OnModelCreating runs, strips the Postgres sequence the production model declares /// for Viewer.ShortUdid so EnsureCreated can build the schema against SQLite (which /// has no sequence support). /// internal class SqliteFriendlyModelCustomizer : ModelCustomizer { public SqliteFriendlyModelCustomizer(ModelCustomizerDependencies dependencies) : base(dependencies) { } public override void Customize(ModelBuilder modelBuilder, DbContext context) { base.Customize(modelBuilder, context); modelBuilder.Model.RemoveSequence("ShortUdidSequence"); var shortUdidProperty = modelBuilder.Entity().Property(v => v.ShortUdid).Metadata; shortUdidProperty.RemoveAnnotation("Relational:DefaultValueSql"); shortUdidProperty.ValueGenerated = ValueGenerated.Never; AssignClientSideKeyGenerators(modelBuilder.Model); StripCardCosmeticRewardSeed(modelBuilder.Model); } /// /// CardCosmeticReward rows have an FK to Cards.Id, and the production model HasData-seeds /// 1068 rows from card_cosmetic_rewards.csv (chunk A.2). In production those rows have /// matching cards inserted by the CardImporter before runtime. The unit-test factory uses /// SQLite + EnsureCreated + a minimal 3-card seed — most of the cosmetic-reward rows have /// no matching Cards row, and EnsureCreated's FK-deferred batch insert throws SqliteException /// "FOREIGN KEY constraint failed" at host construction time. Strip the seed in tests; the /// test fixture inserts CardCosmeticReward rows ad-hoc when a specific scenario needs them. /// /// HasData seed is stored in the internal EntityType._data field (no public API). Clear it /// via reflection. The clear runs after base.Customize so the HasData call inside Seed() /// has populated the list before we wipe it. /// private static void StripCardCosmeticRewardSeed(IMutableModel model) { var entityType = model.FindEntityType(typeof(CardCosmeticReward)); if (entityType is null) return; // EntityType._data is a List>? — null when empty. var dataField = entityType.GetType().GetField( "_data", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); if (dataField is null) return; var list = dataField.GetValue(entityType) as System.Collections.IList; list?.Clear(); } /// /// Owned-collection shadow PKs are ValueGenerated.OnAdd with the production model /// expecting the database to auto-fill (Postgres IDENTITY). On SQLite a composite-PK column /// is not a ROWID alias, so the DB can't auto-fill it and we get NOT NULL violations. Walk /// every owned entity and swap any auto-add primary-key property to use an in-process /// counter instead. /// private static void AssignClientSideKeyGenerators(IMutableModel model) { foreach (var entityType in model.GetEntityTypes()) { if (!entityType.IsOwned()) continue; foreach (var key in entityType.GetKeys()) { foreach (var property in key.Properties) { if (property.ValueGenerated != ValueGenerated.OnAdd) continue; if (property.ClrType != typeof(int) && property.ClrType != typeof(long)) continue; property.SetValueGeneratorFactory((_, _) => property.ClrType == typeof(int) ? (ValueGenerator)new MonotonicIntValueGenerator() : new MonotonicLongValueGenerator()); } } } } } internal sealed class MonotonicIntValueGenerator : ValueGenerator { private static int _current; public override bool GeneratesTemporaryValues => false; public override int Next(EntityEntry entry) => Interlocked.Increment(ref _current); } internal sealed class MonotonicLongValueGenerator : ValueGenerator { private static long _current; public override bool GeneratesTemporaryValues => false; public override long Next(EntityEntry entry) => Interlocked.Increment(ref _current); }