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:
2026-06-06 11:32:44 +02:00
parent 36454f3747
commit fd48f7fc09
3 changed files with 135 additions and 11 deletions

View File

@@ -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),