using System.Linq.Expressions; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.EntityFrameworkCore; namespace DataModel.Database.Common; public sealed class DataContext : DbContext, IDataContext { public DbSet Settings { get; set; } public DbSet
Sections { get; set; } public DbSet TextElements { get; set; } public DbSet Translations { get; set; } public DataContext(DbContextOptions contextOptions) : base(contextOptions) { } protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); #region Settings modelBuilder.Entity().HasIndex(n => n.Id); modelBuilder.Entity().HasIndex(n => n.Code).IsUnique(); modelBuilder.Entity().HasIndex(n => n.BoolValue); modelBuilder.Entity().HasIndex(n => n.GuidValue); modelBuilder.Entity().HasIndex(n => n.IntegerValue); modelBuilder.Entity().HasIndex(n => n.TextValue); #endregion #region Sections modelBuilder.Entity
().HasIndex(n => n.Id); modelBuilder.Entity
().HasIndex(n => n.Name); modelBuilder.Entity
().HasIndex(n => n.Depth); modelBuilder.Entity
().HasIndex(n => n.DataKey); // modelBuilder.Entity
().Navigation(n => n.Parent).AutoInclude(); // Cycle-reference, does not work, though. modelBuilder.Entity
().Navigation(n => n.TextElements).AutoInclude(); #endregion #region TextElements modelBuilder.Entity().HasIndex(n => n.Id); modelBuilder.Entity().HasIndex(n => n.Code); modelBuilder.Entity().HasIndex(n => n.Name); modelBuilder.Entity().HasIndex(n => n.IsMultiLine); modelBuilder.Entity().Navigation(n => n.Section).AutoInclude(); modelBuilder.Entity().Navigation(n => n.Translations).AutoInclude(); #endregion #region Translations modelBuilder.Entity().HasIndex(n => n.Id); modelBuilder.Entity().HasIndex(n => n.Culture); modelBuilder.Entity().HasIndex(n => n.Text); modelBuilder.Entity().HasIndex(n => n.TranslateManual); modelBuilder.Entity().Navigation(n => n.TextElement).AutoInclude(); #endregion } #region Export and import private readonly record struct JsonData( IList Settings, IList Sections, IList TextElements, IList Translations ); /// /// Represents a unique identifier for a JSON export and import. /// internal readonly record struct JsonUniqueId(string Code, Guid UniqueId, string Prefix = "") { public override string ToString() => string.IsNullOrWhiteSpace(this.Prefix) ? $"{this.Code}::{this.UniqueId}" : $"{this.Prefix}::{this.Code}::{this.UniqueId}"; public static implicit operator string(JsonUniqueId id) => id.ToString(); } /// /// A JSON converter to serialize and deserialize JsonUniqueId instances. /// private sealed class JsonUniqueIdConverter : JsonConverter { public override JsonUniqueId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { var json = reader.GetString(); var parts = json?.Split("::"); return parts?.Length switch { 2 => new JsonUniqueId(parts[0], Guid.Parse(parts[1])), 3 => new JsonUniqueId(parts[1], Guid.Parse(parts[2]), parts[0]), _ => throw new JsonException($"Invalid format of JsonUniqueId: {json}") }; } public override void Write(Utf8JsonWriter writer, JsonUniqueId value, JsonSerializerOptions options) { writer.WriteStringValue(value); } } internal readonly record struct JsonSetting( JsonUniqueId UniqueId, string Code, string TextValue, int IntegerValue, bool BoolValue, Guid GuidValue ); internal readonly record struct JsonSection( JsonUniqueId UniqueId, string Name, string DataKey, int Depth, JsonUniqueId ParentUniqueId, List TextElements ); internal readonly record struct JsonTextElement( JsonUniqueId UniqueId, string Code, string Name, bool IsMultiLine, JsonUniqueId SectionUniqueId, List Translations ); internal readonly record struct JsonTranslation( JsonUniqueId UniqueId, string Culture, string Text, bool TranslateManual, JsonUniqueId TextElementUniqueId ); /// /// Exports this database to a JSON file. /// /// The path to the JSON file. /// When false, exclude sensitive data from export. public async Task ExportAsync(string path, bool includeSensitiveData = false) { Console.WriteLine("Exporting database to JSON file..."); var jsonSettings = new JsonSerializerOptions { WriteIndented = true, Converters = { new JsonUniqueIdConverter() }, }; // Maintained list of sensitive data to be removed from the export: var sensitiveSettingCodes = new HashSet { SettingNames.DEEPL_API_KEY, }; // A local filter function to remove sensitive data from the export. // Removing just the sensitive values instead of the entire setting. IEnumerable FilterSensitiveSettings(IEnumerable settings) { foreach (var setting in settings) { if (sensitiveSettingCodes!.Contains(setting.Code)) yield return new JsonSetting(setting.UniqueId, setting.Code, string.Empty, 0, false, Guid.Empty); else yield return setting; } } // Use a local reference to the database to use it in the lambda expression trees below: // (we cannot use "this" in a lambda expression tree; yields an exception at runtime) var db = this; await using var fileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None); await JsonSerializer.SerializeAsync(fileStream, new JsonData { // Settings don't have references to other entities; we can just use them here: Settings = includeSensitiveData ? // Include all settings, including sensitive data: this.Settings.OrderBy(n => n.UniqueId).Select(n => n.ToJsonSetting()).ToList() : // Exclude sensitive data: FilterSensitiveSettings(this.Settings.OrderBy(n => n.UniqueId).Select(n => n.ToJsonSetting()).AsEnumerable()).ToList(), // Warning: the parents cannot pre-loaded, thus, we must load them now inside the lambda expression tree: Sections = this.Sections.OrderBy(n => n.UniqueId).Select(n => n.ToJsonSection(db)).ToList(), // All text elements references are pre-loaded, so we can use them here: TextElements = this.TextElements.OrderBy(n => n.UniqueId).Select(n => n.ToJsonTextElement()).ToList(), // All translation references are pre-loaded, so we can use them here: Translations = this.Translations.OrderBy(n => n.UniqueId).Select(n => n.ToJsonTranslation()).ToList(), }, jsonSettings); Console.WriteLine("Export complete."); } /// /// Stores data needed to resolve a parent-child relationship. /// /// The parent id we want to resolve. /// The entity for which we want to resolve the parent. /// The type of the entity. private readonly record struct TreeResolver(Guid ParentId, T Entity); /// /// Imports data from a JSON file into an empty database. /// /// The path to the JSON export. /// When the database is not empty. public async Task ImportAsync(string path) { if(await this.Settings.AnyAsync() || await this.Sections.AnyAsync() || await this.TextElements.AnyAsync() || await this.Translations.AnyAsync()) throw new InvalidOperationException("The database is not empty. In order to import data, the database must be empty."); Console.WriteLine("Start importing data from JSON file..."); // Start a transaction: await using var transaction = await this.Database.BeginTransactionAsync(); // Configure the JSON serializer: var jsonSettings = new JsonSerializerOptions { Converters = { new JsonUniqueIdConverter() }, }; await using var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); var jsonData = await JsonSerializer.DeserializeAsync(fileStream, jsonSettings); // -------------------- // Import the settings: // -------------------- foreach (var setting in jsonData.Settings) this.Settings.Add(Setting.FromJsonSetting(setting)); // -------------------- // Import the sections: // -------------------- // We must store the intermediate data in a list, because we need to resolve // the parent-child relationships in a second step. var allSections = new Dictionary>(); var sectionToTextElements = new Dictionary>(); // Read the data from the JSON file: foreach (var section in jsonData.Sections) { // Convert the next element: var nextSection = Section.FromJsonSection(section); // Notice: the parent id is not yet resolved. // Store the element: allSections.Add(nextSection.UniqueId, new (section.ParentUniqueId.UniqueId, nextSection)); sectionToTextElements.Add(nextSection.UniqueId, section.TextElements.Select(n => n.UniqueId).ToList()); } // Now, resolve the parent-child relationships for the sections: foreach (var (uniqueId, (parentId, section)) in allSections) { if(parentId == Guid.Empty) { if(section.Depth != 0) Console.WriteLine($"""Section {uniqueId} "{section.Name}" has no parent."""); else Console.WriteLine($"""Section {uniqueId} "{section.Name}" is a root section, thus, has no parent."""); section.Parent = null; continue; } if(allSections.TryGetValue(parentId, out var parent)) section.Parent = parent.Entity; else { Console.WriteLine($"""Parent of section {uniqueId} "{section.Name}" was not found."""); section.Parent = null; continue; } } // ------------------------- // Import the text elements: // ------------------------- // We must store the intermediate data in a list, because we need to resolve // the parent-child relationships in a second step. var allTextElements = new Dictionary(); var textElementToTranslations = new Dictionary>(); // Read the data from the JSON file: foreach (var textElement in jsonData.TextElements) { // Convert the next element: var nextTextElement = TextElement.FromJsonTextElement(textElement); // We know that the section is already imported, because we imported the sections first: nextTextElement.Section = allSections[textElement.SectionUniqueId.UniqueId].Entity; // Store the element in the list: allTextElements.Add(nextTextElement.UniqueId, nextTextElement); textElementToTranslations.Add(nextTextElement.UniqueId, textElement.Translations.Select(n => n.UniqueId).ToList()); } // Now, resolve the parent-child relationships for the text elements to the sections: foreach (var (sectionUniqueId, textElementsIds) in sectionToTextElements) { var section = allSections[sectionUniqueId].Entity; section.TextElements.AddRange(textElementsIds.Select(n => allTextElements[n])); } // Free the memory: sectionToTextElements.Clear(); // ------------------------ // Import the translations: // ------------------------ // We must store the intermediate data in a list, because we need to resolve // the parent-child relationships in a second step. var allTranslations = new Dictionary(); // Read the data from the JSON file: foreach (var translation in jsonData.Translations) { // Convert the next element: var nextTranslation = Translation.FromJsonTranslation(translation); // We know that the text element is already imported, because we imported the text elements first: nextTranslation.TextElement = allTextElements[translation.TextElementUniqueId.UniqueId]; // Store the element in the list: allTranslations.Add(nextTranslation.UniqueId, nextTranslation); } // Now, resolve the parent-child relationships for the translations to the text elements: foreach (var (textElementUniqueId, translationsIds) in textElementToTranslations) { var textElement = allTextElements[textElementUniqueId]; textElement.Translations.AddRange(translationsIds.Select(n => allTranslations[n])); } // Free the memory: textElementToTranslations.Clear(); // --------------------------------- // Add all the data to the database: // --------------------------------- this.Sections.AddRange(allSections.Values.Select(n => n.Entity)); this.TextElements.AddRange(allTextElements.Values); this.Translations.AddRange(allTranslations.Values); // Save the changes: await this.SaveChangesAsync(); // Commit the transaction: await transaction.CommitAsync(); Console.WriteLine("Finished importing data from JSON file."); } #endregion #region Tools internal Task LoadElementsAsync(TEntry entry, Expression> selector) where TEntry : class where TProp : class => this.Entry(entry).Reference(selector).LoadAsync(); #endregion }