Fix import FK fault: derived/back-linked litter referenced a not-yet-inserted parent
BUG (blocked the supervised live re-import): /import/execute threw Npgsql 23503 FK_Litters_Gerbils_FatherId and rolled back. Root cause was INSERT ORDERING — synthesized derived litters were SaveChanges()'d BEFORE the parent gerbils (created later in the animal loop), so the litter's Father/MotherId pointed at rows that didn't exist yet. The dry-run and the EF in-memory test provider don't enforce FKs, so it slipped through. Fix: - Stage synthesized litters in the context but DON'T save them early; the single SaveChanges after the animal loop lets EF order parents → litters → offspring (all FKs nullable). Saving litters first was the fault. - FK-integrity guard (god's spec): compute the persisted set (existing DB + this run's loadable) and null out any litter parent FK not in it; SKIP a derived litter whose BOTH parents are unresolvable (offspring loads with LitterId=null). Quarantined parents already resolve to null via ResolveParentGid; this is defense-in-depth + makes the invariant explicit. - Report litterParentFksDropped + derivedLittersSkipped (LitterSummary) + a German note — so a green dry-run (0/0) GUARANTEES execute won't FK-fault. Tests: two SQLite-backed regressions (SQLite enforces FKs, unlike the in-memory provider) — a derived litter with NEW chart parents executes without throwing, and a quarantined parent leaves that FK null. 120 C# tests + python green; no schema change (has-pending clean). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<Guid>(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<Guid, (Guid? F, Guid? M)>();
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user