using MeterVault.Core.Domain; using Microsoft.EntityFrameworkCore; namespace MeterVault.Infrastructure.Persistence; /// /// The EF Core context owning all relational tables. TimescaleDB-specific objects /// (hypertables, compression, continuous aggregates) are created via raw SQL in the /// migrations โ€” they are not expressible through the model builder (SDD ยง5.3). /// Snake_case table/column naming is applied at the options level /// (UseSnakeCaseNamingConvention) so it matches the SDD schema. /// public sealed class MeterVaultDbContext(DbContextOptions options) : DbContext(options) { public DbSet EnergyTypes => Set(); public DbSet Meters => Set(); public DbSet MeterSources => Set(); public DbSet MeterLinks => Set(); public DbSet Readings => Set(); public DbSet Consumption => Set(); public DbSet MeterEvents => Set(); public DbSet Tanks => Set(); public DbSet Tariffs => Set(); public DbSet CostCategories => Set(); public DbSet CostCategoryMembers => Set(); public DbSet ManualCosts => Set(); public DbSet IngestionEndpoints => Set(); public DbSet ImportBatches => Set(); public DbSet AppSettings => Set(); protected override void OnModelCreating(ModelBuilder b) { b.HasPostgresExtension("timescaledb"); b.Entity(e => { e.ToTable("energy_type"); e.HasKey(x => x.Id); e.Property(x => x.Id).UseIdentityByDefaultColumn(); e.Property(x => x.Key).HasMaxLength(64); e.Property(x => x.DisplayName).HasMaxLength(128); e.Property(x => x.BaseUnit).HasMaxLength(16); e.Property(x => x.DefaultMode).HasConversion().HasMaxLength(32); e.Property(x => x.CreatedAt).HasDefaultValueSql("now()"); e.HasIndex(x => x.Key).IsUnique(); }); b.Entity(e => { e.ToTable("meter"); e.HasKey(x => x.Id); e.Property(x => x.Mode).HasConversion().HasMaxLength(32); e.Property(x => x.Meta).HasColumnType("jsonb").HasDefaultValueSql("'{}'::jsonb"); e.Property(x => x.IsActive).HasDefaultValue(true); e.Property(x => x.CreatedAt).HasDefaultValueSql("now()"); e.Property(x => x.UpdatedAt).HasDefaultValueSql("now()"); e.HasOne(x => x.EnergyType).WithMany(x => x.Meters) .HasForeignKey(x => x.EnergyTypeId).OnDelete(DeleteBehavior.Restrict); e.HasIndex(x => new { x.EnergyTypeId, x.IsActive }); }); b.Entity(e => { e.ToTable("meter_source"); e.HasKey(x => x.Id); e.Property(x => x.SourceType).HasConversion().HasMaxLength(32); e.Property(x => x.ValueKind).HasConversion().HasMaxLength(16); e.Property(x => x.Config).HasColumnType("jsonb").HasDefaultValueSql("'{}'::jsonb"); e.Property(x => x.Scale).HasDefaultValue(1d); e.HasOne(x => x.Meter).WithMany(x => x.Sources) .HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.Cascade); e.HasOne(x => x.Endpoint).WithMany() .HasForeignKey(x => x.EndpointId).OnDelete(DeleteBehavior.SetNull); e.HasIndex(x => x.MeterId); }); b.Entity(e => { e.ToTable("meter_link"); e.HasKey(x => x.Id); e.HasOne(x => x.FromMeter).WithMany().HasForeignKey(x => x.FromMeterId).OnDelete(DeleteBehavior.Cascade); e.HasOne(x => x.ToMeter).WithMany().HasForeignKey(x => x.ToMeterId).OnDelete(DeleteBehavior.Cascade); e.HasIndex(x => new { x.FromMeterId, x.ToMeterId }).IsUnique(); e.ToTable(t => t.HasCheckConstraint("ck_meter_link_distinct", "from_meter_id <> to_meter_id")); }); // Hypertable โ€” the (meter_id, time) PK contains the partition column (time), // which Timescale requires. Converted to a hypertable in a raw-SQL migration. b.Entity(e => { e.ToTable("reading"); e.HasKey(x => new { x.MeterId, x.Time }); e.Property(x => x.Quality).HasConversion(); e.Property(x => x.Flags).HasConversion(); e.HasOne().WithMany().HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.Restrict); e.HasIndex(x => x.ImportBatchId); }); // Hypertable โ€” (meter_id, time, kind) PK. b.Entity(e => { e.ToTable("consumption"); e.HasKey(x => new { x.MeterId, x.Time, x.Kind }); e.Property(x => x.Kind).HasConversion(); e.Property(x => x.Quality).HasConversion(); e.HasOne().WithMany().HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.Restrict); e.HasIndex(x => x.ImportBatchId); }); b.Entity(e => { e.ToTable("meter_event"); e.HasKey(x => x.Id); e.Property(x => x.EventType).HasConversion().HasMaxLength(32); e.Property(x => x.Meta).HasColumnType("jsonb").HasDefaultValueSql("'{}'::jsonb"); e.HasOne().WithMany().HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.Cascade); e.HasIndex(x => new { x.MeterId, x.Time }); e.HasIndex(x => x.ImportBatchId); }); b.Entity(e => { e.ToTable("tank"); e.HasKey(x => x.Id); e.Property(x => x.RateMode).HasConversion().HasMaxLength(16); e.Property(x => x.Unit).HasMaxLength(16); e.Property(x => x.Calibration).HasColumnType("jsonb"); e.HasOne(x => x.Meter).WithMany().HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.Cascade); e.HasIndex(x => x.MeterId).IsUnique(); }); b.Entity(e => { e.ToTable("tariff"); e.HasKey(x => x.Id); e.Property(x => x.ScopeType).HasConversion().HasMaxLength(16); e.Property(x => x.Component).HasConversion().HasMaxLength(16); e.Property(x => x.Unit).HasMaxLength(16); e.Property(x => x.Currency).HasMaxLength(8); e.HasIndex(x => new { x.ScopeType, x.ScopeId, x.Component, x.ValidFrom }); }); b.Entity(e => { e.ToTable("cost_category"); e.HasKey(x => x.Id); e.Property(x => x.Name).HasMaxLength(128); }); b.Entity(e => { e.ToTable("cost_category_member"); e.HasKey(x => x.Id); e.HasOne(x => x.Category).WithMany(x => x.Members) .HasForeignKey(x => x.CategoryId).OnDelete(DeleteBehavior.Cascade); e.HasOne().WithMany().HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.Cascade); e.HasOne().WithMany().HasForeignKey(x => x.EnergyTypeId).OnDelete(DeleteBehavior.Cascade); e.ToTable(t => t.HasCheckConstraint( "ck_cost_category_member_target", "meter_id IS NOT NULL OR energy_type_id IS NOT NULL")); }); b.Entity(e => { e.ToTable("manual_cost"); e.HasKey(x => x.Id); e.Property(x => x.Currency).HasMaxLength(8); e.HasOne().WithMany().HasForeignKey(x => x.CategoryId).OnDelete(DeleteBehavior.SetNull); e.HasOne().WithMany().HasForeignKey(x => x.MeterId).OnDelete(DeleteBehavior.SetNull); e.HasIndex(x => x.ImportBatchId); }); b.Entity(e => { e.ToTable("ingestion_endpoint"); e.HasKey(x => x.Id); e.Property(x => x.Type).HasConversion().HasMaxLength(32); e.Property(x => x.Name).HasMaxLength(128); e.Property(x => x.Config).HasColumnType("jsonb").HasDefaultValueSql("'{}'::jsonb"); }); b.Entity(e => { e.ToTable("import_batch"); e.HasKey(x => x.Id); e.Property(x => x.Mapping).HasColumnType("jsonb"); e.Property(x => x.CreatedAt).HasDefaultValueSql("now()"); }); b.Entity(e => { e.ToTable("app_setting"); e.HasKey(x => x.Key); e.Property(x => x.Key).HasMaxLength(128); e.Property(x => x.Value).HasColumnType("jsonb"); }); } }