244 lines
10 KiB
C#
244 lines
10 KiB
C#
using LiteDB;
|
||
using LehrerApp.Core.Models;
|
||
|
||
namespace LehrerApp.Data;
|
||
|
||
/// <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 = 1;
|
||
|
||
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<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 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;
|
||
}
|
||
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);
|
||
}
|
||
}
|
||
|
||
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);
|
||
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);
|
||
}
|
||
|
||
public void Dispose() => _db.Dispose();
|
||
}
|