diff --git a/GerbilManager.Tests/ImportServiceTests.cs b/GerbilManager.Tests/ImportServiceTests.cs index 5c18846..4dd10b3 100644 --- a/GerbilManager.Tests/ImportServiceTests.cs +++ b/GerbilManager.Tests/ImportServiceTests.cs @@ -1,5 +1,6 @@ using GerbilManagerWebAPI.Import; using GerbilManagerWebAPI.Models; +using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; namespace GerbilManager.Tests @@ -191,6 +192,92 @@ namespace GerbilManager.Tests finally { try { Directory.Delete(dir, recursive: true); } catch { } } } + [Fact] + public async Task Execute_on_relational_db_with_new_chart_parents_does_not_FK_throw() + { + // Regression for FK_Litters_Gerbils_FatherId: a derived litter references parent gerbils + // created in the SAME run, so they must be inserted before the litter. The EF in-memory + // provider does NOT enforce FKs (which masked the bug), so this uses SQLite — which does. + var dir = Path.Combine(Path.GetTempPath(), "fkfix-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + using var conn = new SqliteConnection("DataSource=:memory:"); + conn.Open(); + try + { + File.WriteAllText(Path.Combine(dir, "litters.json"), "[]"); + File.WriteAllText(Path.Combine(dir, "animals.json"), """ + [ + {"id":"papa","name":"Papa v.d. Test","dob":"01.01.2022","death":"","farbschlag":"", + "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false}, + {"id":"mama","name":"Mama v.d. Test","dob":"02.02.2022","death":"","farbschlag":"", + "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false}, + {"id":"c","name":"C","dob":"29.04.2024","death":"","farbschlag":"", + "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false, + "parentRefs":[ + {"name":"Papa v.d. Test","dob":"01.01.2022","roleGuess":"father","method":"chart-position","confidence":"medium"}, + {"name":"Mama v.d. Test","dob":"02.02.2022","roleGuess":"mother","method":"chart-position","confidence":"medium"} + ]} + ] + """); + var opts = new DbContextOptionsBuilder().UseSqlite(conn).Options; + using var db = new ApplicationContext(opts); + await db.Database.EnsureCreatedAsync(); // schema WITH enforced FK constraints + + // must not throw FK_Litters_Gerbils_FatherId (parents inserted before the litter) + var report = await new ImportService(db, dir, dir).RunAsync(execute: true); + + var c = await db.Gerbils.SingleAsync(g => g.ExternalRef == "c"); + Assert.NotNull(c.LitterId); + var litter = await db.Litters.SingleAsync(l => l.Id == c.LitterId); + Assert.Equal((await db.Gerbils.SingleAsync(g => g.ExternalRef == "papa")).Id, litter.FatherId); + Assert.Equal(0, report.Litters.ParentFksDropped); + Assert.Equal(0, report.Litters.DerivedSkipped); + } + finally { try { Directory.Delete(dir, recursive: true); } catch { } } + } + + [Fact] + public async Task Derived_litter_with_quarantined_parent_leaves_FK_null_no_throw() + { + // A chart parentRef pointing to a QUARANTINED (conflict) animal must not become an FK — + // the derived litter keeps that side null; if both sides are unresolvable, no litter. + var dir = Path.Combine(Path.GetTempPath(), "fkq-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + using var conn = new SqliteConnection("DataSource=:memory:"); + conn.Open(); + try + { + File.WriteAllText(Path.Combine(dir, "litters.json"), "[]"); + File.WriteAllText(Path.Combine(dir, "animals.json"), """ + [ + {"id":"badpa","name":"BadPapa v.d. Test","dob":"01.01.2022","death":"","farbschlag":"", + "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true}, + {"id":"goodma","name":"GoodMama v.d. Test","dob":"02.02.2022","death":"","farbschlag":"", + "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false}, + {"id":"c2","name":"C2","dob":"29.04.2024","death":"","farbschlag":"", + "genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false, + "parentRefs":[ + {"name":"BadPapa v.d. Test","dob":"01.01.2022","roleGuess":"father","method":"chart-position","confidence":"medium"}, + {"name":"GoodMama v.d. Test","dob":"02.02.2022","roleGuess":"mother","method":"chart-position","confidence":"medium"} + ]} + ] + """); + var opts = new DbContextOptionsBuilder().UseSqlite(conn).Options; + using var db = new ApplicationContext(opts); + await db.Database.EnsureCreatedAsync(); + + await new ImportService(db, dir, dir).RunAsync(execute: true); // must not throw + + Assert.False(await db.Gerbils.AnyAsync(g => g.ExternalRef == "badpa")); // quarantined + var c2 = await db.Gerbils.SingleAsync(g => g.ExternalRef == "c2"); + Assert.NotNull(c2.LitterId); // still linked (via mother) + var litter = await db.Litters.SingleAsync(l => l.Id == c2.LitterId); + Assert.Null(litter.FatherId); // quarantined father -> null FK + Assert.Equal((await db.Gerbils.SingleAsync(g => g.ExternalRef == "goodma")).Id, litter.MotherId); + } + finally { try { Directory.Delete(dir, recursive: true); } catch { } } + } + [Fact] public void ComposeGenotype_strips_carets_and_fills_missing_loci() { diff --git a/GerbilManagerWebAPI/Import/ImportModels.cs b/GerbilManagerWebAPI/Import/ImportModels.cs index 5cec5dd..79019f7 100644 --- a/GerbilManagerWebAPI/Import/ImportModels.cs +++ b/GerbilManagerWebAPI/Import/ImportModels.cs @@ -81,7 +81,8 @@ namespace GerbilManagerWebAPI.Import /// Zuchtname animals made resident because they parented a Clan offspring (rule b). public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule); - public sealed record LitterSummary(int InSource, int Created, int AlreadyImported, int DerivedFromChart = 0); + public sealed record LitterSummary(int InSource, int Created, int AlreadyImported, + int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0); public sealed record AnimalSummary( int InSource, diff --git a/GerbilManagerWebAPI/Import/ImportService.cs b/GerbilManagerWebAPI/Import/ImportService.cs index 7ede547..0971fe0 100644 --- a/GerbilManagerWebAPI/Import/ImportService.cs +++ b/GerbilManagerWebAPI/Import/ImportService.cs @@ -192,18 +192,52 @@ namespace GerbilManagerWebAPI.Import parentLinksAdded++; } - // litter id -> (father, mother) gids, across synthesized + Wurfchronik (by name) litters. - // Used by the residency rule (b) below; augmented with existing DB litters under execute. + // FK-INTEGRITY (PEDIGREE-LINK bug fix): a litter's Father/MotherId must resolve to a + // gerbil that is created-or-existing, or Postgres throws FK_Litters_Gerbils_*. Compute + // the persisted set (existing DB rows + this run's loadable animals) and drop any parent + // FK that isn't in it; SKIP a derived litter whose BOTH parents are unresolvable (its + // offspring then load with LitterId=null — still better than 'unbekannt' won't regress). + // This runs in dry-run too, so a green dry-run GUARANTEES /import/execute won't FK-fault. + var persisted = new HashSet(existingRows.Select(r => r.Id)); + foreach (var p in plan) persisted.Add(p.Gid); + int litterParentFksDropped = 0, derivedLittersSkipped = 0; + foreach (var key in synthLitters.Keys.ToList()) + { + var sl = synthLitters[key]; + var f = sl.Father is Guid gf && persisted.Contains(gf) ? sl.Father : null; + var m = sl.Mother is Guid gm && persisted.Contains(gm) ? sl.Mother : null; + if (sl.Father is not null && f is null) litterParentFksDropped++; + if (sl.Mother is not null && m is null) litterParentFksDropped++; + if (f is null && m is null) + { + derivedLittersSkipped++; derivedLitters--; + foreach (var gid in synthLitterForGid.Where(kv => kv.Value == sl.Id).Select(kv => kv.Key).ToList()) + synthLitterForGid.Remove(gid); + synthLitters.Remove(key); + continue; + } + synthLitters[key] = sl with { Father = f, Mother = m }; + } + parentLinksAdded = synthLitterForGid.Count; + + // litter id -> (father, mother) gids, across synthesized + Wurfchronik (by name) litters, + // each FK guarded by the persisted set. Used by residency rule (b) below; augmented with + // existing DB litters under execute. var litterParents = new Dictionary(); foreach (var sl in synthLitters.Values) litterParents[sl.Id] = (sl.Father, sl.Mother); foreach (var sl in litters) if (litterIdMap.TryGetValue(sl.Id, out var lid)) - litterParents[lid] = ( - createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.SireName)), out var fid) ? fid : (Guid?)null, - createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.DamName)), out var mid) ? mid : (Guid?)null); + { + Guid? f = createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.SireName)), out var fid) && persisted.Contains(fid) ? fid : null; + Guid? m = createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.DamName)), out var mid) && persisted.Contains(mid) ? mid : null; + litterParents[lid] = (f, m); + } - // PASS 2: write (litters synthesized first so offspring FK resolves), then animals + photos. + // PASS 2: stage synthesized litters (parents already guarded above). DO NOT save them + // before the animals — the offspring AND the parent gerbils are created in the loop + // below, so a single SaveChanges at the end lets EF order parents→litters→offspring + // (all FKs are nullable). Saving litters first is exactly what caused the FK fault. if (execute) { // reuse an existing litter with the same parents+date instead of duplicating. @@ -237,7 +271,7 @@ namespace GerbilManagerWebAPI.Import Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})", }); } - await _db.SaveChangesAsync(); + // NOTE: no SaveChanges here — staged with the gerbils below. } // OWNERSHIP/RESIDENCY (runs AFTER litter links exist): (a) Zuchtname matches the Clan @@ -356,9 +390,10 @@ namespace GerbilManagerWebAPI.Import if (!litterIdMap.TryGetValue(sl.Id, out var lid)) continue; var litter = await _db.Litters.FirstOrDefaultAsync(l => l.Id == lid); if (litter is null) continue; - if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.SireName)), out var fId)) + // guard: only link parents that are actually persisted (avoid an orphan FK). + if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.SireName)), out var fId) && persisted.Contains(fId)) litter.FatherId = fId; - if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.DamName)), out var mId)) + if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.DamName)), out var mId) && persisted.Contains(mId)) litter.MotherId = mId; } await _db.SaveChangesAsync(); @@ -367,12 +402,13 @@ namespace GerbilManagerWebAPI.Import notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin."); if (parentLinksAdded > 0) notes.Add($"Stammbaum-Diagramm: {parentLinksAdded} Tiere über Eltern-Verknüpfung einem (abgeleiteten) Wurf zugeordnet ({derivedLitters} abgeleitete Würfe)."); + notes.Add($"FK-Integrität: {litterParentFksDropped} Eltern-Verknüpfung(en) verworfen (Elternteil nicht ladbar), {derivedLittersSkipped} abgeleitete Würfe übersprungen (kein ladbares Elternteil). Bei 0/0 ist /import/execute FK-sicher."); notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt)."); if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten."); return new ImportReport( Executed: execute, - Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters), + Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped), Animals: new AnimalSummary( animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting, new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),