From 831cc1c16cd527aa962de34cb0cb286a5df46528 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Mon, 17 Aug 2026 11:20:30 +0200 Subject: [PATCH] Baustein 2: Outbound-Hook-Infrastruktur (Kapitel 10) LiteDbContext.OnChange-Hook (Sync-agnostisch, kein Verweis auf LehrerApp.Sync aus LehrerApp.Data), damit Repositories lokale Schreibvorgaenge signalisieren koennen, ohne dass Data von Sync abhaengt. StudentRepository als Vorlage verdrahtet. GroupRepository.Delete-Kaskade (14 betroffene Collections) nach LiteDbContext.CascadeDeleteGroup extrahiert - reine Verschiebung, kein Verhaltensunterschied, macht sie aber von einem spaeter eingehenden Sync-Ereignis (Baustein 5) wiederverwendbar, ohne ueber Repository-Save/Delete (und damit erneut ueber OnChange) zu laufen. Co-Authored-By: Claude Sonnet 5 --- LehrerApp.Data.Tests/ChangeHookTests.cs | 60 +++++++++++++ LehrerApp.Data/LiteDbContext.cs | 89 +++++++++++++++++++ .../Repositories/AllRepositories.cs | 80 ++--------------- 3 files changed, 157 insertions(+), 72 deletions(-) create mode 100644 LehrerApp.Data.Tests/ChangeHookTests.cs diff --git a/LehrerApp.Data.Tests/ChangeHookTests.cs b/LehrerApp.Data.Tests/ChangeHookTests.cs new file mode 100644 index 0000000..b0f4f6e --- /dev/null +++ b/LehrerApp.Data.Tests/ChangeHookTests.cs @@ -0,0 +1,60 @@ +using LehrerApp.Core.Models; +using LehrerApp.Data.Repositories; +using Xunit; + +namespace LehrerApp.Data.Tests; + +// Vorlage-Test für den OnChange-Hook (Baustein 2), den die übrigen Repositories in den +// Bausteinen 3/4 auf dieselbe Weise bekommen. +public sealed class ChangeHookTests +{ + private static LiteDbContext NewInMemoryContext() => new(new MemoryStream()); + + [Fact] + public void StudentRepository_Save_LoestOnChangeMitKorrektenArgumentenAus() + { + using var db = NewInMemoryContext(); + var calls = new List<(string EntityType, string EntityId, string Operation, object? Payload)>(); + db.OnChange = (type, id, op, payload) => calls.Add((type, id, op, payload)); + var repo = new StudentRepository(db); + var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; + + repo.Save(student); + + var call = Assert.Single(calls); + Assert.Equal("Student", call.EntityType); + Assert.Equal(student.Id.ToString(), call.EntityId); + Assert.Equal("Save", call.Operation); + Assert.Same(student, call.Payload); + } + + [Fact] + public void StudentRepository_Delete_LoestOnChangeMitNullPayloadAus() + { + using var db = NewInMemoryContext(); + var repo = new StudentRepository(db); + var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; + repo.Save(student); + var calls = new List<(string EntityType, string EntityId, string Operation, object? Payload)>(); + db.OnChange = (type, id, op, payload) => calls.Add((type, id, op, payload)); + + repo.Delete(student.Id); + + var call = Assert.Single(calls); + Assert.Equal("Student", call.EntityType); + Assert.Equal(student.Id.ToString(), call.EntityId); + Assert.Equal("Delete", call.Operation); + Assert.Null(call.Payload); + } + + [Fact] + public void StudentRepository_Save_OhneGesetztenHook_WirftNicht() + { + using var db = NewInMemoryContext(); + var repo = new StudentRepository(db); + + var exception = Record.Exception(() => repo.Save(new Student { FirstName = "Anna", LastName = "Beispiel" })); + + Assert.Null(exception); + } +} diff --git a/LehrerApp.Data/LiteDbContext.cs b/LehrerApp.Data/LiteDbContext.cs index 940859d..bd7768b 100644 --- a/LehrerApp.Data/LiteDbContext.cs +++ b/LehrerApp.Data/LiteDbContext.cs @@ -3,6 +3,10 @@ using LehrerApp.Core.Models; namespace LehrerApp.Data; +/// Wird nach jedem Save/Delete einer Entität aufgerufen; payload ist die gespeicherte Entität +/// bzw. null bei Delete. Sync-agnostisch – siehe . +public delegate void ChangeHandler(string entityType, string entityId, string operation, object? payload); + /// /// Zentrale LiteDB-Verbindung. Singleton – eine Datei = ein Nutzer. /// @@ -67,6 +71,91 @@ public class LiteDbContext : IDisposable public int SchemaVersion => ReadSchemaVersion(); + /// Wird von den Repositories nach jedem Save/Delete aufgerufen (payload = die gespeicherte + /// Entität bzw. null bei Delete). Bewusst hier statt in LehrerApp.Sync definiert, damit Data + /// weiterhin ohne Verweis auf Sync auskommt — die eigentliche Sync-Anbindung setzt diesen + /// Hook von außen (siehe AppBootstrapper). + public ChangeHandler? OnChange { get; set; } + + /// Führt dieselbe Kaskade wie GroupRepository.Delete aus. Hier auf dem Context statt + /// im Repository, damit ein später eingehendes Sync-Ereignis (Baustein 5) dieselbe Kaskade + /// nachvollziehen kann, ohne über die Repository-Save/Delete-Methoden (und damit erneut über + /// ) zu laufen. + internal void CascadeDeleteGroup(Guid id) + { + ExecuteInTransaction(() => + { + foreach (var membership in Memberships.Find(e => e.GroupId == id).ToList()) + Memberships.Delete(membership.Id); + + foreach (var exam in Exams.Find(e => e.GroupId == id).ToList()) + { + foreach (var result in ExamResults.Find(r => r.ExamId == exam.Id).ToList()) + ExamResults.Delete(result.Id); + Exams.Delete(exam.Id); + } + + foreach (var grade in Grades.Find(g => g.GroupId == id).ToList()) + Grades.Delete(grade.Id); + + foreach (var reportGrade in ReportGrades.Find(g => g.GroupId == id).ToList()) + ReportGrades.Delete(reportGrade.Id); + + foreach (var scheme in GradingSchemes.Find(s => s.GroupId == id).ToList()) + GradingSchemes.Delete(scheme.Id); + + foreach (var unit in Units.Find(u => u.GroupId == id).ToList()) + { + foreach (var lesson in Lessons.Find(l => l.UnitId == unit.Id).ToList()) + Lessons.Delete(lesson.Id); + Units.Delete(unit.Id); + } + + foreach (var lesson in Lessons.Find(l => l.GroupId == id).ToList()) + Lessons.Delete(lesson.Id); + + foreach (var session in ParticipationSessions.Find(s => s.GroupId == id).ToList()) + { + foreach (var entry in ParticipationEntries.Find(e => e.SessionId == session.Id).ToList()) + ParticipationEntries.Delete(entry.Id); + ParticipationSessions.Delete(session.Id); + } + + foreach (var aspect in ParticipationAspects.Find(a => a.GroupId == id).ToList()) + ParticipationAspects.Delete(aspect.Id); + + foreach (var section in ParticipationSections.Find(s => s.GroupId == id).ToList()) + ParticipationSections.Delete(section.Id); + + foreach (var plan in SeatingPlans.Find(p => p.GroupId == id).ToList()) + SeatingPlans.Delete(plan.Id); + + // Dokumentation und Arbeitszeit sind historische Nachweise. Sie bleiben erhalten, + // werden aber von der nicht mehr existierenden Lerngruppe entkoppelt. + foreach (var documentation in Documentation.Find(d => d.GroupId == id).ToList()) + { + documentation.GroupId = null; + documentation.UpdatedAt = DateTime.UtcNow; + Documentation.Update(documentation); + } + + foreach (var task in Tasks.Find(t => t.GroupId == id).ToList()) + { + task.GroupId = null; + task.UpdatedAt = DateTime.UtcNow; + Tasks.Update(task); + } + + foreach (var timeEntry in TimeEntries.Find(t => t.GroupId == id).ToList()) + { + timeEntry.GroupId = null; + TimeEntries.Update(timeEntry); + } + + Groups.Delete(id); + }); + } + internal void ExecuteInTransaction(Action action) { _db.BeginTrans(); diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index 7fdb50c..7c9af89 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -35,7 +35,12 @@ public class StudentRepository(LiteDbContext db) : IStudentRepository db.ReportGrades.Count(g => g.StudentId == studentId), db.ParticipationEntries.Count(e => e.StudentId == studentId), db.Documentation.Count(d => d.StudentId == studentId)); - public void Save(Student s) { s.UpdatedAt = DateTime.UtcNow; db.Students.Upsert(s); } + public void Save(Student s) + { + s.UpdatedAt = DateTime.UtcNow; + db.Students.Upsert(s); + db.OnChange?.Invoke(nameof(Student), s.Id.ToString(), "Save", s); + } public void Delete(Guid id) { var references = GetReferenceSummary(id); @@ -43,6 +48,7 @@ public class StudentRepository(LiteDbContext db) : IStudentRepository throw new InvalidOperationException( "Der Schüler besitzt verknüpfte Daten und kann nur deaktiviert werden."); db.Students.Delete(id); + db.OnChange?.Invoke(nameof(Student), id.ToString(), "Delete", null); } } @@ -77,77 +83,7 @@ public class GroupRepository(LiteDbContext db) : IGroupRepository public void Delete(Guid id) { ArchivedGroupWriteGuard.EnsureActive(db, id); - db.ExecuteInTransaction(() => - { - foreach (var membership in db.Memberships.Find(e => e.GroupId == id).ToList()) - db.Memberships.Delete(membership.Id); - - foreach (var exam in db.Exams.Find(e => e.GroupId == id).ToList()) - { - foreach (var result in db.ExamResults.Find(r => r.ExamId == exam.Id).ToList()) - db.ExamResults.Delete(result.Id); - db.Exams.Delete(exam.Id); - } - - foreach (var grade in db.Grades.Find(g => g.GroupId == id).ToList()) - db.Grades.Delete(grade.Id); - - foreach (var reportGrade in db.ReportGrades.Find(g => g.GroupId == id).ToList()) - db.ReportGrades.Delete(reportGrade.Id); - - foreach (var scheme in db.GradingSchemes.Find(s => s.GroupId == id).ToList()) - db.GradingSchemes.Delete(scheme.Id); - - foreach (var unit in db.Units.Find(u => u.GroupId == id).ToList()) - { - foreach (var lesson in db.Lessons.Find(l => l.UnitId == unit.Id).ToList()) - db.Lessons.Delete(lesson.Id); - db.Units.Delete(unit.Id); - } - - foreach (var lesson in db.Lessons.Find(l => l.GroupId == id).ToList()) - db.Lessons.Delete(lesson.Id); - - foreach (var session in db.ParticipationSessions.Find(s => s.GroupId == id).ToList()) - { - foreach (var entry in db.ParticipationEntries.Find(e => e.SessionId == session.Id).ToList()) - db.ParticipationEntries.Delete(entry.Id); - db.ParticipationSessions.Delete(session.Id); - } - - foreach (var aspect in db.ParticipationAspects.Find(a => a.GroupId == id).ToList()) - db.ParticipationAspects.Delete(aspect.Id); - - foreach (var section in db.ParticipationSections.Find(s => s.GroupId == id).ToList()) - db.ParticipationSections.Delete(section.Id); - - foreach (var plan in db.SeatingPlans.Find(p => p.GroupId == id).ToList()) - db.SeatingPlans.Delete(plan.Id); - - // Dokumentation und Arbeitszeit sind historische Nachweise. Sie bleiben erhalten, - // werden aber von der nicht mehr existierenden Lerngruppe entkoppelt. - foreach (var documentation in db.Documentation.Find(d => d.GroupId == id).ToList()) - { - documentation.GroupId = null; - documentation.UpdatedAt = DateTime.UtcNow; - db.Documentation.Update(documentation); - } - - foreach (var task in db.Tasks.Find(t => t.GroupId == id).ToList()) - { - task.GroupId = null; - task.UpdatedAt = DateTime.UtcNow; - db.Tasks.Update(task); - } - - foreach (var timeEntry in db.TimeEntries.Find(t => t.GroupId == id).ToList()) - { - timeEntry.GroupId = null; - db.TimeEntries.Update(timeEntry); - } - - db.Groups.Delete(id); - }); + db.CascadeDeleteGroup(id); } }