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<Setting> Settings { get; set; } public DbSet<Section> Sections { get; set; } public DbSet<TextElement> TextElements { get; set; } public DbSet<Translation> Translations { get; set; } public DataContext(DbContextOptions<DataContext> contextOptions) : base(contextOptions) { } protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); #region Settings modelBuilder.Entity<Setting>().HasIndex(n => n.Id); modelBuilder.Entity<Setting>().HasIndex(n => n.Code).IsUnique(); modelBuilder.Entity<Setting>().HasIndex(n => n.BoolValue); modelBuilder.Entity<Setting>().HasIndex(n => n.GuidValue); modelBuilder.Entity<Setting>().HasIndex(n => n.IntegerValue); modelBuilder.Entity<Setting>().HasIndex(n => n.TextValue); #endregion #region Sections modelBuilder.Entity<Section>().HasIndex(n => n.Id); modelBuilder.Entity<Section>().HasIndex(n => n.Name); modelBuilder.Entity<Section>().HasIndex(n => n.Depth); modelBuilder.Entity<Section>().HasIndex(n => n.DataKey); // modelBuilder.Entity<Section>().Navigation(n => n.Parent).AutoInclude(); // Cycle-reference, does not work, though. modelBuilder.Entity<Section>().Navigation(n => n.TextElements).AutoInclude(); #endregion #region TextElements modelBuilder.Entity<TextElement>().HasIndex(n => n.Id); modelBuilder.Entity<TextElement>().HasIndex(n => n.Code); modelBuilder.Entity<TextElement>().HasIndex(n => n.Name); modelBuilder.Entity<TextElement>().HasIndex(n => n.IsMultiLine); modelBuilder.Entity<TextElement>().Navigation(n => n.Section).AutoInclude(); modelBuilder.Entity<TextElement>().Navigation(n => n.Translations).AutoInclude(); #endregion #region Translations modelBuilder.Entity<Translation>().HasIndex(n => n.Id); modelBuilder.Entity<Translation>().HasIndex(n => n.Culture); modelBuilder.Entity<Translation>().HasIndex(n => n.Text); modelBuilder.Entity<Translation>().HasIndex(n => n.TranslateManual); modelBuilder.Entity<Translation>().Navigation(n => n.TextElement).AutoInclude(); #endregion } #region Export and import private readonly record struct JsonData( IList<JsonSetting> Settings, IList<JsonSection> Sections, IList<JsonTextElement> TextElements, IList<JsonTranslation> Translations ); /// <summary> /// Represents a unique identifier for a JSON export and import. /// </summary> 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(); } /// <summary> /// A JSON converter to serialize and deserialize JsonUniqueId instances. /// </summary> private sealed class JsonUniqueIdConverter : JsonConverter<JsonUniqueId> { 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<JsonUniqueId> TextElements ); internal readonly record struct JsonTextElement( JsonUniqueId UniqueId, string Code, string Name, bool IsMultiLine, JsonUniqueId SectionUniqueId, List<JsonUniqueId> Translations ); internal readonly record struct JsonTranslation( JsonUniqueId UniqueId, string Culture, string Text, bool TranslateManual, JsonUniqueId TextElementUniqueId ); /// <summary> /// Exports this database to a JSON file. /// </summary> /// <param name="path">The path to the JSON file.</param> /// <param name="includeSensitiveData">When false, exclude sensitive data from export.</param> 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<string> { 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<JsonSetting> FilterSensitiveSettings(IEnumerable<JsonSetting> 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."); } /// <summary> /// Stores data needed to resolve a parent-child relationship. /// </summary> /// <param name="ParentId">The parent id we want to resolve.</param> /// <param name="Entity">The entity for which we want to resolve the parent.</param> /// <typeparam name="T">The type of the entity.</typeparam> private readonly record struct TreeResolver<T>(Guid ParentId, T Entity); /// <summary> /// Imports data from a JSON file into an empty database. /// </summary> /// <param name="path">The path to the JSON export.</param> /// <exception cref="InvalidOperationException">When the database is not empty.</exception> 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<JsonData>(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<Guid, TreeResolver<Section>>(); var sectionToTextElements = new Dictionary<Guid, List<Guid>>(); // 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<Guid, TextElement>(); var textElementToTranslations = new Dictionary<Guid, List<Guid>>(); // 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<Guid, Translation>(); // 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, TProp>(TEntry entry, Expression<Func<TEntry, TProp?>> selector) where TEntry : class where TProp : class => this.Entry<TEntry>(entry).Reference(selector).LoadAsync(); #endregion }