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");
});
}
}