//----------------------------------------------------------------------- // // Copyright (c) aliasvault. All rights reserved. // Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. // //----------------------------------------------------------------------- namespace AliasClientDb; using System.Globalization; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.Extensions.Configuration; /// /// The AliasClientDbContext class. /// public class AliasClientDbContext : DbContext { /// /// Initializes a new instance of the class. /// public AliasClientDbContext() { } /// /// Initializes a new instance of the class. /// /// The SQLite connection to use to connect to the SQLite database. /// The action to perform for logging. public AliasClientDbContext(SqliteConnection sqliteConnection, Action logAction) : base(GetOptions(sqliteConnection, logAction)) { } /// /// Initializes a new instance of the class. /// /// DbContextOptions to use. public AliasClientDbContext(DbContextOptions options) : base(options) { } /// /// Gets or sets the Attachment DbSet. /// public DbSet Attachments { get; set; } /// /// Gets or sets the EncryptionKey DbSet. /// public DbSet EncryptionKeys { get; set; } /// /// Gets or sets the Settings DbSet. /// public DbSet Settings { get; set; } /// /// Gets or sets the TotpCodes DbSet. /// public DbSet TotpCodes { get; set; } /// /// Gets or sets the Passkeys DbSet. /// public DbSet Passkeys { get; set; } /// /// Gets or sets the Items DbSet. /// public DbSet Items { get; set; } /// /// Gets or sets the Folders DbSet. /// public DbSet Folders { get; set; } /// /// Gets or sets the Logos DbSet. /// public DbSet Logos { get; set; } /// /// Gets or sets the FieldDefinitions DbSet. /// public DbSet FieldDefinitions { get; set; } /// /// Gets or sets the FieldValues DbSet. /// public DbSet FieldValues { get; set; } /// /// Gets or sets the FieldHistories DbSet. /// public DbSet FieldHistories { get; set; } /// /// Gets or sets the Tags DbSet. /// public DbSet Tags { get; set; } /// /// Gets or sets the ItemTags DbSet. /// public DbSet ItemTags { get; set; } /// /// The OnModelCreating method. /// /// ModelBuilder instance. protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); foreach (var entity in modelBuilder.Model.GetEntityTypes()) { foreach (var property in entity.GetProperties()) { // SQLite does not support varchar(max) so we use TEXT. if (property.ClrType == typeof(string) && property.GetMaxLength() == null) { property.SetColumnType("TEXT"); } } } // Configure Attachment - Item relationship modelBuilder.Entity() .HasOne(l => l.Item) .WithMany(c => c.Attachments) .HasForeignKey(l => l.ItemId) .OnDelete(DeleteBehavior.Cascade); // Configure TotpCode - Item relationship modelBuilder.Entity() .HasOne(l => l.Item) .WithMany(c => c.TotpCodes) .HasForeignKey(l => l.ItemId) .OnDelete(DeleteBehavior.Cascade); // Configure Passkey - Item relationship modelBuilder.Entity() .HasOne(p => p.Item) .WithMany(c => c.Passkeys) .HasForeignKey(p => p.ItemId) .OnDelete(DeleteBehavior.Cascade); // Configure Passkey indexes modelBuilder.Entity() .HasIndex(e => e.RpId); modelBuilder.Entity() .Property(e => e.RpId) .UseCollation("NOCASE"); // Configure Item - Logo relationship modelBuilder.Entity() .HasOne(i => i.Logo) .WithMany(l => l.Items) .HasForeignKey(i => i.LogoId) .OnDelete(DeleteBehavior.SetNull); // Configure Item - Folder relationship modelBuilder.Entity() .HasOne(i => i.Folder) .WithMany(f => f.Items) .HasForeignKey(i => i.FolderId) .OnDelete(DeleteBehavior.SetNull); // Configure Folder - ParentFolder relationship modelBuilder.Entity() .HasOne(f => f.ParentFolder) .WithMany(f => f.ChildFolders) .HasForeignKey(f => f.ParentFolderId) .OnDelete(DeleteBehavior.Cascade); // Configure Logo unique index on Source modelBuilder.Entity() .HasIndex(l => l.Source) .IsUnique(); // Configure FieldValue - Item relationship modelBuilder.Entity() .HasOne(fv => fv.Item) .WithMany(i => i.FieldValues) .HasForeignKey(fv => fv.ItemId) .OnDelete(DeleteBehavior.Cascade); // Configure FieldValue - FieldDefinition relationship (nullable for system fields) modelBuilder.Entity() .HasOne(fv => fv.FieldDefinition) .WithMany(fd => fd.FieldValues) .HasForeignKey(fv => fv.FieldDefinitionId) .OnDelete(DeleteBehavior.Cascade) .IsRequired(false); // Nullable for system fields // Configure FieldHistory - FieldDefinition relationship modelBuilder.Entity() .HasOne(fh => fh.FieldDefinition) .WithMany(fd => fd.FieldHistories) .HasForeignKey(fh => fh.FieldDefinitionId) .OnDelete(DeleteBehavior.Cascade); // Configure indexes for FieldValue modelBuilder.Entity() .HasIndex(fv => fv.ItemId); modelBuilder.Entity() .HasIndex(fv => fv.FieldDefinitionId); modelBuilder.Entity() .HasIndex(fv => fv.FieldKey); // Index for system field lookups modelBuilder.Entity() .HasIndex(fv => new { fv.ItemId, fv.FieldDefinitionId, fv.Weight }); modelBuilder.Entity() .HasIndex(fv => new { fv.ItemId, fv.FieldKey }); // Composite index for system field queries // Configure indexes for FieldHistory modelBuilder.Entity() .HasIndex(fh => fh.ItemId); modelBuilder.Entity() .HasIndex(fh => fh.FieldDefinitionId); // FieldDefinition indexes (FieldKey removed - custom fields use GUID only) // Configure indexes for Folder modelBuilder.Entity() .HasIndex(f => f.ParentFolderId); // Configure ItemTag - Item relationship modelBuilder.Entity() .HasOne(it => it.Item) .WithMany(i => i.ItemTags) .HasForeignKey(it => it.ItemId) .OnDelete(DeleteBehavior.Cascade); // Configure ItemTag - Tag relationship modelBuilder.Entity() .HasOne(it => it.Tag) .WithMany(t => t.ItemTags) .HasForeignKey(it => it.TagId) .OnDelete(DeleteBehavior.Cascade); // Configure indexes for Tag modelBuilder.Entity() .HasIndex(t => t.Name); // Configure indexes for ItemTag modelBuilder.Entity() .HasIndex(it => it.ItemId); modelBuilder.Entity() .HasIndex(it => it.TagId); // Configure unique index for ItemTag to prevent duplicate tag assignments modelBuilder.Entity() .HasIndex(it => new { it.ItemId, it.TagId }) .IsUnique(); } /// /// Sets up the connection string if it is not already configured. /// /// DbContextOptionsBuilder instance. protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { // If the options are not already configured, use the appsettings.json file. if (!optionsBuilder.IsConfigured) { var configuration = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile("appsettings.json") .Build(); optionsBuilder .UseSqlite(configuration.GetConnectionString("AliasClientDbContext")) .UseLazyLoadingProxies(); // Log queries made as debug output. optionsBuilder.LogTo(Console.WriteLine); } base.OnConfiguring(optionsBuilder); } /// /// Gets the options for the AliasClientDbContext. /// /// The SQLite connection to use to connect to the SQLite database. /// The action to perform for logging. /// The options for the AliasClientDbContext. private static DbContextOptions GetOptions(SqliteConnection connection, Action logAction) { var optionsBuilder = new DbContextOptionsBuilder(); optionsBuilder.UseSqlite(connection); optionsBuilder.LogTo(logAction, new[] { DbLoggerCategory.Database.Command.Name }); return optionsBuilder.Options; } /// /// Converts a DateTime to a string in the standard format: "yyyy-MM-dd HH:mm:ss.fff" (23 characters with milliseconds). /// This format ensures SQLite native support, consistent precision, and proper sorting. /// /// The DateTime to convert. /// The string representation of the DateTime. private static string DateTimeToString(DateTime v) { return v == DateTime.MinValue ? string.Empty : v.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); } /// /// Converts a string to a DateTime. /// /// The string to convert. /// The DateTime representation of the string. private static DateTime StringToDateTime(string v) { if (string.IsNullOrEmpty(v)) { return DateTime.MinValue; } // Try to parse with all known formats first // Standard format is first for performance (most common case) string[] formats = new[] { "yyyy-MM-dd HH:mm:ss.fff", // Standard format with milliseconds (23 chars) "yyyy-MM-dd HH:mm:ss", // Standard format without milliseconds (19 chars) "yyyy-MM-dd'T'HH:mm:ss.fff'Z'", // ISO 8601 with milliseconds and Zulu "yyyy-MM-dd'T'HH:mm:ss'Z'", // ISO 8601 with Zulu "yyyy-MM-dd'T'HH:mm:ss.fff", // ISO 8601 with milliseconds "yyyy-MM-dd'T'HH:mm:ss", // ISO 8601 basic "yyyy-MM-dd", // Date only }; foreach (var format in formats) { if (DateTime.TryParseExact(v, format, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var dt)) { return dt; } } // Fallback: try to parse dynamically (handles most .NET and JS date strings) if (DateTime.TryParse(v, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var dynamicDt)) { return dynamicDt; } // If all parsing fails, return MinValue as a safe fallback return DateTime.MinValue; } }