I18NCommander/I18N Commander/DataModel/Database/Common/DataContext.cs

383 lines
16 KiB
C#
Raw Permalink Normal View History

using System.Linq.Expressions;
using System.Text.Json;
2022-11-14 19:32:41 +00:00
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
2022-06-12 15:15:30 +00:00
namespace DataModel.Database.Common;
public sealed class DataContext : DbContext, IDataContext
2022-06-12 15:15:30 +00:00
{
2022-07-09 13:03:50 +00:00
public DbSet<Setting> Settings { get; set; }
2022-06-12 15:15:30 +00:00
2022-07-09 13:03:50 +00:00
public DbSet<Section> Sections { get; set; }
2022-06-12 19:42:47 +00:00
2022-07-09 13:03:50 +00:00
public DbSet<TextElement> TextElements { get; set; }
2022-06-12 19:42:47 +00:00
2022-07-09 13:03:50 +00:00
public DbSet<Translation> Translations { get; set; }
2022-06-12 19:42:47 +00:00
2022-06-12 15:15:30 +00:00
public DataContext(DbContextOptions<DataContext> contextOptions) : base(contextOptions)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
2022-06-12 19:42:47 +00:00
#region Settings
2022-06-12 15:15:30 +00:00
modelBuilder.Entity<Setting>().HasIndex(n => n.Id);
2022-07-25 17:03:49 +00:00
modelBuilder.Entity<Setting>().HasIndex(n => n.Code).IsUnique();
2022-06-12 15:15:30 +00:00
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
2022-06-12 19:42:47 +00:00
#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);
2022-07-16 20:28:40 +00:00
// modelBuilder.Entity<Section>().Navigation(n => n.Parent).AutoInclude(); // Cycle-reference, does not work, though.
modelBuilder.Entity<Section>().Navigation(n => n.TextElements).AutoInclude();
2022-06-12 19:42:47 +00:00
#endregion
#region TextElements
modelBuilder.Entity<TextElement>().HasIndex(n => n.Id);
modelBuilder.Entity<TextElement>().HasIndex(n => n.Code);
2022-07-10 17:30:22 +00:00
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();
2022-06-12 19:42:47 +00:00
#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();
2022-06-12 19:42:47 +00:00
#endregion
2022-06-12 15:15:30 +00:00
}
2022-11-14 19:32:41 +00:00
2023-01-02 19:50:11 +00:00
#region Export and import
2022-11-14 19:32:41 +00:00
private readonly record struct JsonData(
2023-01-18 19:00:25 +00:00
IList<JsonSetting> Settings,
IList<JsonSection> Sections,
IList<JsonTextElement> TextElements,
IList<JsonTranslation> Translations
2022-11-14 19:32:41 +00:00
);
2023-01-18 19:00:03 +00:00
/// <summary>
/// Represents a unique identifier for a JSON export and import.
/// </summary>
2022-11-14 19:32:41 +00:00
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();
}
2023-01-18 19:00:03 +00:00
/// <summary>
/// A JSON converter to serialize and deserialize JsonUniqueId instances.
/// </summary>
2022-11-14 19:32:41 +00:00
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
);
2023-01-18 19:00:03 +00:00
/// <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)
2022-11-14 19:32:41 +00:00
{
Console.WriteLine("Exporting database to JSON file...");
2022-11-14 19:32:41 +00:00
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;
}
}
2022-11-14 19:32:41 +00:00
// 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;
2022-11-14 19:32:41 +00:00
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(),
2022-11-14 19:32:41 +00:00
}, jsonSettings);
Console.WriteLine("Export complete.");
2022-11-14 19:32:41 +00:00
}
2023-01-02 19:50:11 +00:00
/// <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)
2023-02-12 13:07:30 +00:00
Console.WriteLine($"""Section {uniqueId} "{section.Name}" has no parent.""");
else
2023-02-12 13:07:30 +00:00
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
{
2023-02-12 13:07:30 +00:00
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.");
}
2023-01-02 19:50:11 +00:00
#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
2022-06-12 15:15:30 +00:00
}