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 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,10 @@ using LehrerApp.Core.Models;
|
|||||||
|
|
||||||
namespace LehrerApp.Data;
|
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 <see cref="LiteDbContext.OnChange"/>.
|
||||||
|
public delegate void ChangeHandler(string entityType, string entityId, string operation, object? payload);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Zentrale LiteDB-Verbindung. Singleton – eine Datei = ein Nutzer.
|
/// Zentrale LiteDB-Verbindung. Singleton – eine Datei = ein Nutzer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -67,6 +71,91 @@ public class LiteDbContext : IDisposable
|
|||||||
|
|
||||||
public int SchemaVersion => ReadSchemaVersion();
|
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 <c>GroupRepository.Delete</c> 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
|
||||||
|
/// <see cref="OnChange"/>) 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)
|
internal void ExecuteInTransaction(Action action)
|
||||||
{
|
{
|
||||||
_db.BeginTrans();
|
_db.BeginTrans();
|
||||||
|
|||||||
@@ -35,7 +35,12 @@ public class StudentRepository(LiteDbContext db) : IStudentRepository
|
|||||||
db.ReportGrades.Count(g => g.StudentId == studentId),
|
db.ReportGrades.Count(g => g.StudentId == studentId),
|
||||||
db.ParticipationEntries.Count(e => e.StudentId == studentId),
|
db.ParticipationEntries.Count(e => e.StudentId == studentId),
|
||||||
db.Documentation.Count(d => d.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)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
var references = GetReferenceSummary(id);
|
var references = GetReferenceSummary(id);
|
||||||
@@ -43,6 +48,7 @@ public class StudentRepository(LiteDbContext db) : IStudentRepository
|
|||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"Der Schüler besitzt verknüpfte Daten und kann nur deaktiviert werden.");
|
"Der Schüler besitzt verknüpfte Daten und kann nur deaktiviert werden.");
|
||||||
db.Students.Delete(id);
|
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)
|
public void Delete(Guid id)
|
||||||
{
|
{
|
||||||
ArchivedGroupWriteGuard.EnsureActive(db, id);
|
ArchivedGroupWriteGuard.EnsureActive(db, id);
|
||||||
db.ExecuteInTransaction(() =>
|
db.CascadeDeleteGroup(id);
|
||||||
{
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user