Files
LehrerApp/LehrerApp.Data/LiteDbContext.cs
T
adminandClaude Sonnet 5 831cc1c16c 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>
2026-08-17 11:20:30 +02:00

477 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using LiteDB;
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 <see cref="LiteDbContext.OnChange"/>.
public delegate void ChangeHandler(string entityType, string entityId, string operation, object? payload);
/// <summary>
/// Zentrale LiteDB-Verbindung. Singleton eine Datei = ein Nutzer.
/// </summary>
public class LiteDbContext : IDisposable
{
/// Aktuelle Schema-Version. Migrationsschritte werden versioniert unter
/// <see cref="RunVersionedMigrations"/> ergänzt, statt bei jedem Start erneut
/// (idempotent, aber unnötig) über alle Daten zu laufen.
private const int CurrentSchemaVersion = 4;
private readonly LiteDatabase _db;
public LiteDbContext(string databasePath, string? password = null)
{
_db = new LiteDatabase(new ConnectionString(databasePath)
{
Connection = ConnectionType.Shared,
Password = password,
});
RunVersionedMigrations();
EnsureIndexes();
}
/// Für Tests: In-Memory-Datenbank ohne Datei auf der Festplatte.
public LiteDbContext(Stream stream)
{
_db = new LiteDatabase(stream);
RunVersionedMigrations();
EnsureIndexes();
}
public ILiteCollection<Student> Students => _db.GetCollection<Student>("students");
public ILiteCollection<LearningGroup> Groups => _db.GetCollection<LearningGroup>("groups");
public ILiteCollection<SeatingPlan> SeatingPlans => _db.GetCollection<SeatingPlan>("seating_plans");
public ILiteCollection<GroupMembership> Memberships => _db.GetCollection<GroupMembership>("group_memberships");
public ILiteCollection<Exam> Exams => _db.GetCollection<Exam>("exams");
public ILiteCollection<ExamResult> ExamResults => _db.GetCollection<ExamResult>("exam_results");
public ILiteCollection<Grade> Grades => _db.GetCollection<Grade>("grades");
public ILiteCollection<GradingScheme> GradingSchemes => _db.GetCollection<GradingScheme>("grading_schemes");
public ILiteCollection<ReportGrade> ReportGrades => _db.GetCollection<ReportGrade>("report_grades");
public ILiteCollection<GradingKeyTemplate> GradingKeyTemplates => _db.GetCollection<GradingKeyTemplate>("grading_key_templates");
public ILiteCollection<Unit> Units => _db.GetCollection<Unit>("units");
public ILiteCollection<Lesson> Lessons => _db.GetCollection<Lesson>("lessons");
public ILiteCollection<Documentation> Documentation => _db.GetCollection<Documentation>("documentation");
public ILiteStorage<string> Attachments => _db.GetStorage<string>("attachments", "attachments_chunks");
public ILiteCollection<WorkTask> Tasks => _db.GetCollection<WorkTask>("tasks");
public ILiteCollection<TimeEntry> TimeEntries => _db.GetCollection<TimeEntry>("time_entries");
public ILiteCollection<ParticipationSession> ParticipationSessions => _db.GetCollection<ParticipationSession>("participation_sessions");
public ILiteCollection<ParticipationEntry> ParticipationEntries => _db.GetCollection<ParticipationEntry>("participation");
public ILiteCollection<ParticipationAspect> ParticipationAspects => _db.GetCollection<ParticipationAspect>("participation_aspects");
public ILiteCollection<ParticipationSection> ParticipationSections => _db.GetCollection<ParticipationSection>("participation_sections");
public ILiteCollection<Subject> Subjects => _db.GetCollection<Subject>("subjects");
public ILiteCollection<CompetencyDomain> CompetencyDomains => _db.GetCollection<CompetencyDomain>("competency_domains");
public ILiteCollection<ShorthandCode> ShorthandCodes => _db.GetCollection<ShorthandCode>("shorthand_codes");
public ILiteCollection<AlternativeLessonPath> AlternativeLessonPaths => _db.GetCollection<AlternativeLessonPath>("alternative_lesson_paths");
public ILiteCollection<TimetableSlot> TimetableSlots => _db.GetCollection<TimetableSlot>("timetable_slots");
public ILiteCollection<SchoolHoliday> SchoolHolidays => _db.GetCollection<SchoolHoliday>("school_holidays");
public ILiteCollection<SupervisionDuty> SupervisionDuties => _db.GetCollection<SupervisionDuty>("supervision_duties");
public ILiteCollection<SubstitutionEntry> SubstitutionEntries => _db.GetCollection<SubstitutionEntry>("substitution_entries");
public void Checkpoint() => _db.Checkpoint();
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)
{
_db.BeginTrans();
try
{
action();
_db.Commit();
}
catch
{
_db.Rollback();
throw;
}
}
private void RunVersionedMigrations()
{
var version = ReadSchemaVersion();
if (version < 1)
{
MigrateExistingData();
version = 1;
}
if (version < 2)
{
MigrateLessonPhases();
version = 2;
}
if (version < 3)
{
MigrateLessonShorthand();
version = 3;
}
if (version < 4)
{
MigrateLessonAlternativePaths();
version = 4;
}
WriteSchemaVersion(version);
}
private int ReadSchemaVersion()
{
var meta = _db.GetCollection<BsonDocument>("meta").FindById(1);
return meta?["SchemaVersion"].AsInt32 ?? 0;
}
private void WriteSchemaVersion(int version) =>
_db.GetCollection<BsonDocument>("meta").Upsert(new BsonDocument
{
["_id"] = 1,
["SchemaVersion"] = version,
});
private void MigrateExistingData()
{
MigrateMemberships();
RemoveRedundantLegacyFields();
var groups = _db.GetCollection<BsonDocument>("groups");
var subjects = _db.GetCollection<Subject>("subjects");
var missingArchiveState = groups.FindAll()
.Where(g => !g.ContainsKey(nameof(LearningGroup.IsActive)))
.ToList();
foreach (var group in missingArchiveState)
{
group[nameof(LearningGroup.IsActive)] = true;
groups.Update(group);
}
// Subject war anfangs nur als Text in der Gruppe gespeichert. Alte
// Werte werden einmalig an die Fachstammdaten angebunden und danach
// entfernt, damit es nur noch eine führende Fachbezeichnung gibt.
foreach (var group in groups.FindAll().ToList())
{
var changed = false;
var legacyName = group.TryGetValue("Subject", out var value) && value.IsString
? value.AsString.Trim()
: "";
var hasSubjectId = group.TryGetValue(nameof(LearningGroup.SubjectId), out var id)
&& id.IsGuid && subjects.FindById(id.AsGuid) is not null;
if (!hasSubjectId && legacyName.Length > 0)
{
var subject = subjects.FindAll().FirstOrDefault(s =>
string.Equals(s.Name.Trim(), legacyName, StringComparison.OrdinalIgnoreCase));
if (subject is null)
{
subject = new Subject { Name = legacyName };
subjects.Insert(subject);
}
group[nameof(LearningGroup.SubjectId)] = subject.Id;
changed = true;
}
else if (!hasSubjectId && group.ContainsKey(nameof(LearningGroup.SubjectId)))
{
group[nameof(LearningGroup.SubjectId)] = BsonValue.Null;
changed = true;
}
changed |= group.Remove("Subject");
if (changed) groups.Update(group);
}
}
private void MigrateMemberships()
{
const string oldName = "enrollments";
const string newName = "group_memberships";
var names = _db.GetCollectionNames().ToHashSet(StringComparer.OrdinalIgnoreCase);
if (names.Contains(oldName) && !names.Contains(newName))
_db.RenameCollection(oldName, newName);
var memberships = _db.GetCollection<BsonDocument>(newName);
foreach (var membership in memberships.FindAll().ToList())
{
var changed = false;
if (!membership.ContainsKey(nameof(GroupMembership.AddedOn)) &&
membership.TryGetValue("EnrolledAt", out var enrolledAt))
{
membership[nameof(GroupMembership.AddedOn)] = enrolledAt;
changed = true;
}
changed |= membership.Remove("EnrolledAt");
changed |= membership.Remove("SchoolYear");
if (changed) memberships.Update(membership);
}
}
/// Fasst die frühere flache Struktur einer Stunde (einzelnes Phase-Textfeld, Methoden-/
/// Materialien-Listen) verlustfrei in eine einzige <see cref="LessonPhaseStep"/>-Zeile der
/// neuen Verlaufsplan-Tabelle zusammen. Nur die alten Felder werden gelesen — die typisierte
/// <see cref="Lesson"/>-Klasse kennt sie nicht mehr, ein Zugriff über die typisierte
/// Lessons-Collection würde sie beim Deserialisieren bereits verwerfen.
private void MigrateLessonPhases()
{
var lessons = _db.GetCollection<BsonDocument>("lessons");
foreach (var lesson in lessons.FindAll().ToList())
{
if (lesson.ContainsKey(nameof(Lesson.Phases))) continue;
var phase = lesson.TryGetValue("Phase", out var p) && p.IsString ? p.AsString.Trim() : "";
var methods = lesson.TryGetValue("Methods", out var m) && m.IsArray
? string.Join("; ", m.AsArray.Select(x => x.AsString)) : "";
var materials = lesson.TryGetValue("Materials", out var mat) && mat.IsArray
? string.Join(", ", mat.AsArray.Select(x => x.AsString)) : "";
var phases = new BsonArray();
if (phase.Length > 0 || methods.Length > 0 || materials.Length > 0)
{
phases.Add(new BsonDocument
{
[nameof(LessonPhaseStep.Id)] = Guid.NewGuid(),
[nameof(LessonPhaseStep.Name)] = phase,
[nameof(LessonPhaseStep.DurationMinutes)] = 0,
[nameof(LessonPhaseStep.Activity)] = methods,
[nameof(LessonPhaseStep.Material)] = materials,
[nameof(LessonPhaseStep.Shorthand)] = "",
});
}
lesson[nameof(Lesson.Phases)] = phases;
lesson.Remove("Phase");
lesson.Remove("Methods");
lesson.Remove("Materials");
lessons.Update(lesson);
}
}
/// Führt die anfangs erzwungene Von/Nach-Struktur des Kurzsymbols (<c>ShorthandFrom</c>/
/// <c>ShorthandTo</c>) in ein einzelnes Freitextfeld zusammen — der Praxis nach ist ein
/// Kurzsymbol nicht immer ein Materialfluss-Pfeil, manchmal nur eine Sozialform ("Plenum").
/// Beide Felder gesetzt ergeben "Von->Nach", nur eines gesetzt bleibt als Einzelwert erhalten.
private void MigrateLessonShorthand()
{
var lessons = _db.GetCollection<BsonDocument>("lessons");
foreach (var lesson in lessons.FindAll().ToList())
{
if (!lesson.TryGetValue(nameof(Lesson.Phases), out var phasesValue) || !phasesValue.IsArray) continue;
var changed = false;
foreach (var phaseValue in phasesValue.AsArray)
{
if (phaseValue is not BsonDocument phase) continue;
if (phase.ContainsKey(nameof(LessonPhaseStep.Shorthand))) continue;
var from = phase.TryGetValue("ShorthandFrom", out var f) && f.IsString ? f.AsString : "";
var to = phase.TryGetValue("ShorthandTo", out var t) && t.IsString ? t.AsString : "";
phase[nameof(LessonPhaseStep.Shorthand)] = (from.Length > 0, to.Length > 0) switch
{
(true, true) => $"{from}->{to}",
(true, false) => from,
(false, true) => to,
_ => "",
};
phase.Remove("ShorthandFrom");
phase.Remove("ShorthandTo");
changed = true;
}
if (changed) lessons.Update(lesson);
}
}
/// Führt das anfangs freie Textfeld für den alternativen Ablauf einer Phase
/// (<c>AlternativePath</c>, String) in einen Verweis auf einen Katalogeintrag
/// (<see cref="LessonPhaseStep.AlternativePathId"/>) über — pro bisher verwendetem, distinktem
/// Namen wird ein <see cref="AlternativeLessonPath"/> angelegt (bzw. ein gleichnamiger
/// wiederverwendet) und referenziert. Die neue Collection ist von dieser Migration selbst nicht
/// betroffen (keine Altstruktur), daher direkter Zugriff über die typisierte Collection.
private void MigrateLessonAlternativePaths()
{
var lessons = _db.GetCollection<BsonDocument>("lessons");
var nameToId = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
foreach (var existing in AlternativeLessonPaths.FindAll()) nameToId[existing.Name] = existing.Id;
foreach (var lesson in lessons.FindAll().ToList())
{
if (!lesson.TryGetValue(nameof(Lesson.Phases), out var phasesValue) || !phasesValue.IsArray) continue;
var changed = false;
foreach (var phaseValue in phasesValue.AsArray)
{
if (phaseValue is not BsonDocument phase) continue;
if (!phase.TryGetValue("AlternativePath", out var oldVal) || !oldVal.IsString) continue;
var name = oldVal.AsString.Trim();
phase.Remove("AlternativePath");
changed = true;
if (name.Length == 0) continue;
if (!nameToId.TryGetValue(name, out var id))
{
var entry = new AlternativeLessonPath { Name = name };
AlternativeLessonPaths.Insert(entry);
id = entry.Id;
nameToId[name] = id;
}
phase[nameof(LessonPhaseStep.AlternativePathId)] = id;
}
if (changed) lessons.Update(lesson);
}
}
private void RemoveRedundantLegacyFields()
{
RemoveFields("exams", "Subject");
RemoveFields("units", "Subject", "SchoolYear");
RemoveFields("grades", "SchoolYear");
RemoveFields("participation", "GroupId", "Date");
}
private void RemoveFields(string collectionName, params string[] fieldNames)
{
var collection = _db.GetCollection<BsonDocument>(collectionName);
foreach (var document in collection.FindAll().ToList())
{
var changed = false;
foreach (var fieldName in fieldNames) changed |= document.Remove(fieldName);
if (changed) collection.Update(document);
}
}
private void EnsureIndexes()
{
Students.EnsureIndex(x => x.LastName);
Students.EnsureIndex(x => x.IsActive);
Groups.EnsureIndex(x => x.SchoolYear);
Groups.EnsureIndex(x => x.IsActive);
SeatingPlans.EnsureIndex(x => x.GroupId);
Memberships.EnsureIndex(x => x.StudentId);
Memberships.EnsureIndex(x => x.GroupId);
Memberships.EnsureIndex("ux_student_group",
BsonExpression.Create("STRING($.StudentId) + ':' + STRING($.GroupId)"), unique: true);
Exams.EnsureIndex(x => x.GroupId);
Exams.EnsureIndex(x => x.Status);
ExamResults.EnsureIndex(x => x.ExamId);
ExamResults.EnsureIndex(x => x.StudentId);
ExamResults.EnsureIndex("ux_exam_student",
BsonExpression.Create("STRING($.ExamId) + ':' + STRING($.StudentId)"), unique: true);
Grades.EnsureIndex(x => x.StudentId);
Grades.EnsureIndex(x => x.GroupId);
GradingSchemes.EnsureIndex(x => x.GroupId);
GradingSchemes.EnsureIndex(x => x.GroupType);
ReportGrades.EnsureIndex(x => x.GroupId);
ReportGrades.EnsureIndex(x => x.StudentId);
ReportGrades.EnsureIndex("ux_student_group_period",
BsonExpression.Create("STRING($.StudentId) + ':' + STRING($.GroupId) + ':' + $.Period"), unique: true);
GradingKeyTemplates.EnsureIndex(x => x.GradingSystem);
Units.EnsureIndex(x => x.GroupId);
Lessons.EnsureIndex(x => x.UnitId);
Lessons.EnsureIndex(x => x.GroupId);
Lessons.EnsureIndex(x => x.Date);
Documentation.EnsureIndex(x => x.StudentId);
Tasks.EnsureIndex(x => x.Status);
TimeEntries.EnsureIndex(x => x.Date);
ParticipationSessions.EnsureIndex(x => x.GroupId);
ParticipationSessions.EnsureIndex(x => x.Date);
ParticipationEntries.EnsureIndex(x => x.SessionId);
ParticipationEntries.EnsureIndex(x => x.StudentId);
ParticipationEntries.EnsureIndex("ux_session_student",
BsonExpression.Create("STRING($.SessionId) + ':' + STRING($.StudentId)"), unique: true);
ParticipationAspects.EnsureIndex(x => x.GroupId);
ParticipationSections.EnsureIndex(x => x.GroupId);
Subjects.EnsureIndex(x => x.Name);
Subjects.EnsureIndex("ux_subject_name", BsonExpression.Create("LOWER(TRIM($.Name))"), unique: true);
CompetencyDomains.EnsureIndex(x => x.SubjectId);
CompetencyDomains.EnsureIndex(x => x.GradeLevel);
ShorthandCodes.EnsureIndex("ux_shorthand_code", BsonExpression.Create("LOWER(TRIM($.Code))"), unique: true);
AlternativeLessonPaths.EnsureIndex("ux_alt_lesson_path_name", BsonExpression.Create("LOWER(TRIM($.Name))"), unique: true);
TimetableSlots.EnsureIndex(x => x.GroupId);
TimetableSlots.EnsureIndex("ux_timetable_weekday_period",
BsonExpression.Create("STRING($.Weekday) + ':' + STRING($.PeriodNumber)"), unique: true);
SupervisionDuties.EnsureIndex("ux_supervision_weekday_period",
BsonExpression.Create("STRING($.Weekday) + ':' + STRING($.AfterPeriod)"), unique: true);
SubstitutionEntries.EnsureIndex(x => x.Date);
}
public void Dispose() => _db.Dispose();
}