using LiteDB; using LehrerApp.Core.Models; namespace LehrerApp.Data; /// /// Zentrale LiteDB-Verbindung. Singleton – eine Datei = ein Nutzer. /// public class LiteDbContext : IDisposable { /// Aktuelle Schema-Version. Migrationsschritte werden versioniert unter /// 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 Students => _db.GetCollection("students"); public ILiteCollection Groups => _db.GetCollection("groups"); public ILiteCollection Memberships => _db.GetCollection("group_memberships"); public ILiteCollection Exams => _db.GetCollection("exams"); public ILiteCollection ExamResults => _db.GetCollection("exam_results"); public ILiteCollection Grades => _db.GetCollection("grades"); public ILiteCollection GradingSchemes => _db.GetCollection("grading_schemes"); public ILiteCollection ReportGrades => _db.GetCollection("report_grades"); public ILiteCollection GradingKeyTemplates => _db.GetCollection("grading_key_templates"); public ILiteCollection Units => _db.GetCollection("units"); public ILiteCollection Lessons => _db.GetCollection("lessons"); public ILiteCollection Documentation => _db.GetCollection("documentation"); public ILiteStorage Attachments => _db.GetStorage("attachments", "attachments_chunks"); public ILiteCollection Tasks => _db.GetCollection("tasks"); public ILiteCollection TimeEntries => _db.GetCollection("time_entries"); public ILiteCollection ParticipationSessions => _db.GetCollection("participation_sessions"); public ILiteCollection ParticipationEntries => _db.GetCollection("participation"); public ILiteCollection ParticipationAspects => _db.GetCollection("participation_aspects"); public ILiteCollection ParticipationSections => _db.GetCollection("participation_sections"); public ILiteCollection Subjects => _db.GetCollection("subjects"); public ILiteCollection CompetencyDomains => _db.GetCollection("competency_domains"); public ILiteCollection ShorthandCodes => _db.GetCollection("shorthand_codes"); public ILiteCollection AlternativeLessonPaths => _db.GetCollection("alternative_lesson_paths"); public void Checkpoint() => _db.Checkpoint(); public int SchemaVersion => ReadSchemaVersion(); 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("meta").FindById(1); return meta?["SchemaVersion"].AsInt32 ?? 0; } private void WriteSchemaVersion(int version) => _db.GetCollection("meta").Upsert(new BsonDocument { ["_id"] = 1, ["SchemaVersion"] = version, }); private void MigrateExistingData() { MigrateMemberships(); RemoveRedundantLegacyFields(); var groups = _db.GetCollection("groups"); var subjects = _db.GetCollection("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(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 -Zeile der /// neuen Verlaufsplan-Tabelle zusammen. Nur die alten Felder werden gelesen — die typisierte /// -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("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 (ShorthandFrom/ /// ShorthandTo) 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("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 /// (AlternativePath, String) in einen Verweis auf einen Katalogeintrag /// () über — pro bisher verwendetem, distinktem /// Namen wird ein 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("lessons"); var nameToId = new Dictionary(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(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); 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); } public void Dispose() => _db.Dispose(); }