refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
|
||||
|
||||
public class AuditLogEntryConfiguration : IEntityTypeConfiguration<AuditLogEntry>
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures the EF Core entity mapping for <see cref="AuditLogEntry"/>.
|
||||
/// </summary>
|
||||
/// <param name="builder">The entity type builder for <see cref="AuditLogEntry"/>.</param>
|
||||
public void Configure(EntityTypeBuilder<AuditLogEntry> builder)
|
||||
{
|
||||
builder.HasKey(a => a.Id);
|
||||
|
||||
builder.Property(a => a.User)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(a => a.Action)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property(a => a.EntityType)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(a => a.EntityId)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(a => a.EntityName)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
// Indexes for common query patterns
|
||||
builder.HasIndex(a => a.Timestamp);
|
||||
builder.HasIndex(a => a.User);
|
||||
builder.HasIndex(a => a.EntityType);
|
||||
builder.HasIndex(a => a.EntityId);
|
||||
builder.HasIndex(a => a.Action);
|
||||
builder.HasIndex(a => a.BundleImportId).HasDatabaseName("IX_AuditLogEntries_BundleImportId");
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// Maps the <see cref="AuditEvent"/> record to the central <c>AuditLog</c> table
|
||||
/// described in alog.md §4. Column lengths/types and the five named indexes are
|
||||
/// fixed by that specification — keep this in sync with the doc.
|
||||
/// </summary>
|
||||
public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditEvent>
|
||||
{
|
||||
// SQL Server's datetime2 provider strips the DateTimeKind flag on the wire
|
||||
// (a column hydrated from the database always surfaces as
|
||||
// DateTimeKind.Unspecified). Without a converter, downstream code that
|
||||
// calls .ToLocalTime() / .ToUniversalTime() on an OccurredAtUtc value would
|
||||
// silently re-interpret it as local time. These converters force the Kind
|
||||
// back to Utc on read, and re-stamp Utc on write so a producer that hands
|
||||
// EF a DateTime literal with Kind=Unspecified still lands a UTC-tagged
|
||||
// value in the model cache (CLAUDE.md: "All timestamps are UTC throughout
|
||||
// the system."). Applied to every DateTime property whose name ends in
|
||||
// `Utc`; DateTimeOffset columns already carry their own offset and are NOT
|
||||
// routed through these converters.
|
||||
private static readonly ValueConverter<DateTime, DateTime> UtcConverter = new(
|
||||
v => v.Kind == DateTimeKind.Utc ? v : DateTime.SpecifyKind(v, DateTimeKind.Utc),
|
||||
v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
|
||||
|
||||
private static readonly ValueConverter<DateTime?, DateTime?> NullableUtcConverter = new(
|
||||
v => v.HasValue
|
||||
? (v.Value.Kind == DateTimeKind.Utc ? v.Value : DateTime.SpecifyKind(v.Value, DateTimeKind.Utc))
|
||||
: null,
|
||||
v => v.HasValue ? DateTime.SpecifyKind(v.Value, DateTimeKind.Utc) : null);
|
||||
|
||||
/// <summary>Applies the EF Core type configuration for <see cref="AuditEvent"/> to the model builder.</summary>
|
||||
/// <param name="builder">The entity type builder to configure.</param>
|
||||
public void Configure(EntityTypeBuilder<AuditEvent> builder)
|
||||
{
|
||||
builder.ToTable("AuditLog");
|
||||
|
||||
// Enforce DateTimeKind.Utc on every *Utc-suffixed DateTime column. See
|
||||
// the UtcConverter remarks above for the rationale.
|
||||
builder.Property(e => e.OccurredAtUtc).HasConversion(UtcConverter);
|
||||
builder.Property(e => e.IngestedAtUtc).HasConversion(NullableUtcConverter);
|
||||
|
||||
// Composite PK includes OccurredAtUtc — required by the monthly partition scheme
|
||||
// (ps_AuditLog_Month) so the clustered key is partition-aligned. EventId still
|
||||
// needs to be globally unique for InsertIfNotExistsAsync idempotency, so a
|
||||
// separate unique index is declared on EventId alone.
|
||||
builder.HasKey(e => new { e.EventId, e.OccurredAtUtc });
|
||||
|
||||
builder.HasIndex(e => e.EventId)
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_AuditLog_EventId");
|
||||
|
||||
// Enum-as-string columns: bounded varchar(32) ASCII.
|
||||
builder.Property(e => e.Channel)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(32)
|
||||
.IsUnicode(false)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.Kind)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(32)
|
||||
.IsUnicode(false)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.Status)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(32)
|
||||
.IsUnicode(false)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.ForwardState)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(32)
|
||||
.IsUnicode(false);
|
||||
|
||||
// Ascii identifier columns — never carry user-supplied unicode.
|
||||
builder.Property(e => e.SourceSiteId)
|
||||
.HasMaxLength(64)
|
||||
.IsUnicode(false);
|
||||
|
||||
builder.Property(e => e.SourceInstanceId)
|
||||
.HasMaxLength(128)
|
||||
.IsUnicode(false);
|
||||
|
||||
builder.Property(e => e.SourceScript)
|
||||
.HasMaxLength(128)
|
||||
.IsUnicode(false);
|
||||
|
||||
builder.Property(e => e.Actor)
|
||||
.HasMaxLength(128)
|
||||
.IsUnicode(false);
|
||||
|
||||
builder.Property(e => e.Target)
|
||||
.HasMaxLength(256)
|
||||
.IsUnicode(false);
|
||||
|
||||
// SourceNode (Audit Log #23, SourceNode-stamping): node-local identifier of the
|
||||
// cluster member that produced the row (e.g. "node-a", "central-a"). NULL is
|
||||
// valid for reconciled rows from a retired node and for direct-write rows
|
||||
// produced before this feature shipped. ASCII — varchar(64), no unicode.
|
||||
builder.Property(e => e.SourceNode)
|
||||
.HasColumnType("varchar(64)")
|
||||
.HasMaxLength(64)
|
||||
.IsUnicode(false);
|
||||
|
||||
// Bounded unicode message column.
|
||||
builder.Property(e => e.ErrorMessage)
|
||||
.HasMaxLength(1024);
|
||||
|
||||
// ErrorDetail, RequestSummary, ResponseSummary, Extra: leave as nvarchar(max).
|
||||
|
||||
// Indexes — names locked to alog.md §4 for reconciliation/migration discoverability.
|
||||
builder.HasIndex(e => e.OccurredAtUtc)
|
||||
.IsDescending(true)
|
||||
.HasDatabaseName("IX_AuditLog_OccurredAtUtc");
|
||||
|
||||
builder.HasIndex(e => new { e.SourceSiteId, e.OccurredAtUtc })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("IX_AuditLog_Site_Occurred");
|
||||
|
||||
builder.HasIndex(e => e.CorrelationId)
|
||||
.HasFilter("[CorrelationId] IS NOT NULL")
|
||||
.HasDatabaseName("IX_AuditLog_CorrelationId");
|
||||
|
||||
builder.HasIndex(e => e.ExecutionId)
|
||||
.HasFilter("[ExecutionId] IS NOT NULL")
|
||||
.HasDatabaseName("IX_AuditLog_Execution");
|
||||
|
||||
builder.HasIndex(e => e.ParentExecutionId)
|
||||
.HasFilter("[ParentExecutionId] IS NOT NULL")
|
||||
.HasDatabaseName("IX_AuditLog_ParentExecution");
|
||||
|
||||
// SourceNode composite index (Audit Log #23, SourceNode-stamping): backs
|
||||
// per-node Central UI / health-dashboard queries (e.g. "rows produced by
|
||||
// central-a, newest first"). Created via raw SQL in the migration so it lands
|
||||
// on the ps_AuditLog_Month(OccurredAtUtc) partition scheme like every other
|
||||
// IX_AuditLog_* index — keeps the partition-switch purge path intact.
|
||||
builder.HasIndex(e => new { e.SourceNode, e.OccurredAtUtc })
|
||||
.HasDatabaseName("IX_AuditLog_Node_Occurred");
|
||||
|
||||
builder.HasIndex(e => new { e.Channel, e.Status, e.OccurredAtUtc })
|
||||
.IsDescending(false, false, true)
|
||||
.HasDatabaseName("IX_AuditLog_Channel_Status_Occurred");
|
||||
|
||||
builder.HasIndex(e => new { e.Target, e.OccurredAtUtc })
|
||||
.IsDescending(false, true)
|
||||
.HasFilter("[Target] IS NOT NULL")
|
||||
.HasDatabaseName("IX_AuditLog_Target_Occurred");
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
|
||||
|
||||
public class DeploymentRecordConfiguration : IEntityTypeConfiguration<DeploymentRecord>
|
||||
{
|
||||
/// <summary>Configures the EF Core mapping for <see cref="DeploymentRecord"/>.</summary>
|
||||
/// <param name="builder">The entity type builder.</param>
|
||||
public void Configure(EntityTypeBuilder<DeploymentRecord> builder)
|
||||
{
|
||||
builder.HasKey(d => d.Id);
|
||||
|
||||
builder.Property(d => d.DeploymentId)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property(d => d.RevisionHash)
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property(d => d.DeployedBy)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(d => d.Status)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(50);
|
||||
|
||||
builder.HasOne<Instance>()
|
||||
.WithMany()
|
||||
.HasForeignKey(d => d.InstanceId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// Optimistic concurrency on deployment status records
|
||||
builder.Property<byte[]>("RowVersion")
|
||||
.IsRowVersion();
|
||||
|
||||
builder.HasIndex(d => d.DeploymentId).IsUnique();
|
||||
builder.HasIndex(d => d.InstanceId);
|
||||
builder.HasIndex(d => d.DeployedAt);
|
||||
}
|
||||
}
|
||||
|
||||
public class DeployedConfigSnapshotConfiguration : IEntityTypeConfiguration<DeployedConfigSnapshot>
|
||||
{
|
||||
/// <summary>Configures the EF Core mapping for <see cref="DeployedConfigSnapshot"/>.</summary>
|
||||
/// <param name="builder">The entity type builder.</param>
|
||||
public void Configure(EntityTypeBuilder<DeployedConfigSnapshot> builder)
|
||||
{
|
||||
builder.HasKey(s => s.Id);
|
||||
|
||||
builder.Property(s => s.DeploymentId)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property(s => s.RevisionHash)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property(s => s.ConfigurationJson)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasOne<Instance>()
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.InstanceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(s => s.InstanceId).IsUnique();
|
||||
builder.HasIndex(s => s.DeploymentId);
|
||||
}
|
||||
}
|
||||
|
||||
public class SystemArtifactDeploymentRecordConfiguration : IEntityTypeConfiguration<SystemArtifactDeploymentRecord>
|
||||
{
|
||||
/// <summary>Configures the EF Core mapping for <see cref="SystemArtifactDeploymentRecord"/>.</summary>
|
||||
/// <param name="builder">The entity type builder.</param>
|
||||
public void Configure(EntityTypeBuilder<SystemArtifactDeploymentRecord> builder)
|
||||
{
|
||||
builder.HasKey(d => d.Id);
|
||||
|
||||
builder.Property(d => d.ArtifactType)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property(d => d.DeployedBy)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(d => d.PerSiteStatus)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.HasIndex(d => d.DeployedAt);
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
|
||||
|
||||
public class ExternalSystemDefinitionConfiguration : IEntityTypeConfiguration<ExternalSystemDefinition>
|
||||
{
|
||||
/// <summary>Applies the EF Core entity type configuration for <see cref="ExternalSystemDefinition"/>.</summary>
|
||||
/// <param name="builder">The entity type builder to configure.</param>
|
||||
public void Configure(EntityTypeBuilder<ExternalSystemDefinition> builder)
|
||||
{
|
||||
builder.HasKey(e => e.Id);
|
||||
|
||||
builder.Property(e => e.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(e => e.EndpointUrl)
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000);
|
||||
|
||||
builder.Property(e => e.AuthType)
|
||||
.IsRequired()
|
||||
.HasMaxLength(50);
|
||||
|
||||
// Stored encrypted at rest (EncryptedStringConverter). Ciphertext is larger than
|
||||
// the plaintext, so the column is sized generously to avoid truncation.
|
||||
builder.Property(e => e.AuthConfiguration)
|
||||
.HasMaxLength(8000);
|
||||
|
||||
builder.HasMany<ExternalSystemMethod>()
|
||||
.WithOne()
|
||||
.HasForeignKey(m => m.ExternalSystemDefinitionId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(e => e.Name).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public class ExternalSystemMethodConfiguration : IEntityTypeConfiguration<ExternalSystemMethod>
|
||||
{
|
||||
/// <summary>Applies the EF Core entity type configuration for <see cref="ExternalSystemMethod"/>.</summary>
|
||||
/// <param name="builder">The entity type builder to configure.</param>
|
||||
public void Configure(EntityTypeBuilder<ExternalSystemMethod> builder)
|
||||
{
|
||||
builder.HasKey(m => m.Id);
|
||||
|
||||
builder.Property(m => m.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(m => m.HttpMethod)
|
||||
.IsRequired()
|
||||
.HasMaxLength(10);
|
||||
|
||||
builder.Property(m => m.Path)
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000);
|
||||
|
||||
builder.Property(m => m.ParameterDefinitions)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.Property(m => m.ReturnDefinition)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.HasIndex(m => new { m.ExternalSystemDefinitionId, m.Name }).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public class DatabaseConnectionDefinitionConfiguration : IEntityTypeConfiguration<DatabaseConnectionDefinition>
|
||||
{
|
||||
/// <summary>Applies the EF Core entity type configuration for <see cref="DatabaseConnectionDefinition"/>.</summary>
|
||||
/// <param name="builder">The entity type builder to configure.</param>
|
||||
public void Configure(EntityTypeBuilder<DatabaseConnectionDefinition> builder)
|
||||
{
|
||||
builder.HasKey(d => d.Id);
|
||||
|
||||
builder.Property(d => d.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
// Stored encrypted at rest (EncryptedStringConverter). Ciphertext is larger than
|
||||
// the plaintext, so the column is sized generously to avoid truncation.
|
||||
builder.Property(d => d.ConnectionString)
|
||||
.IsRequired()
|
||||
.HasMaxLength(8000);
|
||||
|
||||
builder.HasIndex(d => d.Name).IsUnique();
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
|
||||
|
||||
public class ApiKeyConfiguration : IEntityTypeConfiguration<ApiKey>
|
||||
{
|
||||
/// <summary>Configures the EF Core mapping for the <see cref="ApiKey"/> entity.</summary>
|
||||
/// <param name="builder">Entity type builder used to apply the configuration.</param>
|
||||
public void Configure(EntityTypeBuilder<ApiKey> builder)
|
||||
{
|
||||
builder.HasKey(k => k.Id);
|
||||
|
||||
builder.Property(k => k.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
// ConfigurationDatabase-012: the bearer credential is persisted only as a
|
||||
// deterministic HMAC-SHA256 hash, never as plaintext. Base64 of a 32-byte
|
||||
// HMAC-SHA256 digest is 44 characters; 256 leaves generous headroom.
|
||||
builder.Property(k => k.KeyHash)
|
||||
.IsRequired()
|
||||
.HasMaxLength(256);
|
||||
|
||||
builder.HasIndex(k => k.Name).IsUnique();
|
||||
builder.HasIndex(k => k.KeyHash).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public class ApiMethodConfiguration : IEntityTypeConfiguration<ApiMethod>
|
||||
{
|
||||
/// <summary>Configures the EF Core mapping for the <see cref="ApiMethod"/> entity.</summary>
|
||||
/// <param name="builder">Entity type builder used to apply the configuration.</param>
|
||||
public void Configure(EntityTypeBuilder<ApiMethod> builder)
|
||||
{
|
||||
builder.HasKey(m => m.Id);
|
||||
|
||||
builder.Property(m => m.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(m => m.Script)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(m => m.ApprovedApiKeyIds)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.Property(m => m.ParameterDefinitions)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.Property(m => m.ReturnDefinition)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.HasIndex(m => m.Name).IsUnique();
|
||||
}
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
|
||||
|
||||
public class InstanceConfiguration : IEntityTypeConfiguration<Instance>
|
||||
{
|
||||
/// <summary>Configures the EF Core mapping for <see cref="Instance"/>.</summary>
|
||||
/// <param name="builder">The entity type builder.</param>
|
||||
public void Configure(EntityTypeBuilder<Instance> builder)
|
||||
{
|
||||
builder.HasKey(i => i.Id);
|
||||
|
||||
builder.Property(i => i.UniqueName)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(i => i.State)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(50);
|
||||
|
||||
builder.HasOne<Template>()
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.TemplateId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasOne<Site>()
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.SiteId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasOne<Area>()
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.AreaId)
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.HasMany(i => i.AttributeOverrides)
|
||||
.WithOne()
|
||||
.HasForeignKey(o => o.InstanceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(i => i.AlarmOverrides)
|
||||
.WithOne()
|
||||
.HasForeignKey(o => o.InstanceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(i => i.ConnectionBindings)
|
||||
.WithOne()
|
||||
.HasForeignKey(b => b.InstanceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(i => new { i.SiteId, i.UniqueName }).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public class InstanceAttributeOverrideConfiguration : IEntityTypeConfiguration<InstanceAttributeOverride>
|
||||
{
|
||||
/// <summary>Configures the EF Core mapping for <see cref="InstanceAttributeOverride"/>.</summary>
|
||||
/// <param name="builder">The entity type builder.</param>
|
||||
public void Configure(EntityTypeBuilder<InstanceAttributeOverride> builder)
|
||||
{
|
||||
builder.HasKey(o => o.Id);
|
||||
|
||||
builder.Property(o => o.AttributeName)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(o => o.OverrideValue)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.HasIndex(o => new { o.InstanceId, o.AttributeName }).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public class InstanceAlarmOverrideConfiguration : IEntityTypeConfiguration<InstanceAlarmOverride>
|
||||
{
|
||||
/// <summary>Configures the EF Core mapping for <see cref="InstanceAlarmOverride"/>.</summary>
|
||||
/// <param name="builder">The entity type builder.</param>
|
||||
public void Configure(EntityTypeBuilder<InstanceAlarmOverride> builder)
|
||||
{
|
||||
builder.HasKey(o => o.Id);
|
||||
|
||||
builder.Property(o => o.AlarmCanonicalName)
|
||||
.IsRequired()
|
||||
.HasMaxLength(400); // Larger than attribute names to fit composed paths.
|
||||
|
||||
builder.Property(o => o.TriggerConfigurationOverride)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.HasIndex(o => new { o.InstanceId, o.AlarmCanonicalName }).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public class InstanceConnectionBindingConfiguration : IEntityTypeConfiguration<InstanceConnectionBinding>
|
||||
{
|
||||
/// <summary>Configures the EF Core mapping for <see cref="InstanceConnectionBinding"/>.</summary>
|
||||
/// <param name="builder">The entity type builder.</param>
|
||||
public void Configure(EntityTypeBuilder<InstanceConnectionBinding> builder)
|
||||
{
|
||||
builder.HasKey(b => b.Id);
|
||||
|
||||
builder.Property(b => b.AttributeName)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.HasOne<DataConnection>()
|
||||
.WithMany()
|
||||
.HasForeignKey(b => b.DataConnectionId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(b => new { b.InstanceId, b.AttributeName }).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public class AreaConfiguration : IEntityTypeConfiguration<Area>
|
||||
{
|
||||
/// <summary>Configures the EF Core mapping for <see cref="Area"/>.</summary>
|
||||
/// <param name="builder">The entity type builder.</param>
|
||||
public void Configure(EntityTypeBuilder<Area> builder)
|
||||
{
|
||||
builder.HasKey(a => a.Id);
|
||||
|
||||
builder.Property(a => a.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.HasOne<Site>()
|
||||
.WithMany()
|
||||
.HasForeignKey(a => a.SiteId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Self-referencing parent area
|
||||
builder.HasOne<Area>()
|
||||
.WithMany(a => a.Children)
|
||||
.HasForeignKey(a => a.ParentAreaId)
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.HasIndex(a => new { a.SiteId, a.ParentAreaId, a.Name }).IsUnique();
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
|
||||
|
||||
public class NotificationListConfiguration : IEntityTypeConfiguration<NotificationList>
|
||||
{
|
||||
/// <summary>Configures the EF Core mapping for <see cref="NotificationList"/>.</summary>
|
||||
/// <param name="builder">The entity type builder.</param>
|
||||
public void Configure(EntityTypeBuilder<NotificationList> builder)
|
||||
{
|
||||
builder.HasKey(n => n.Id);
|
||||
|
||||
builder.Property(n => n.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(n => n.Type)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(32)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasMany(n => n.Recipients)
|
||||
.WithOne()
|
||||
.HasForeignKey(r => r.NotificationListId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(n => n.Name).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public class NotificationRecipientConfiguration : IEntityTypeConfiguration<NotificationRecipient>
|
||||
{
|
||||
/// <summary>Configures the EF Core mapping for <see cref="NotificationRecipient"/>.</summary>
|
||||
/// <param name="builder">The entity type builder.</param>
|
||||
public void Configure(EntityTypeBuilder<NotificationRecipient> builder)
|
||||
{
|
||||
builder.HasKey(r => r.Id);
|
||||
|
||||
builder.Property(r => r.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(r => r.EmailAddress)
|
||||
.IsRequired()
|
||||
.HasMaxLength(500);
|
||||
}
|
||||
}
|
||||
|
||||
public class SmtpConfigurationConfiguration : IEntityTypeConfiguration<SmtpConfiguration>
|
||||
{
|
||||
/// <summary>Configures the EF Core mapping for <see cref="SmtpConfiguration"/>.</summary>
|
||||
/// <param name="builder">The entity type builder.</param>
|
||||
public void Configure(EntityTypeBuilder<SmtpConfiguration> builder)
|
||||
{
|
||||
builder.HasKey(s => s.Id);
|
||||
|
||||
builder.Property(s => s.Host)
|
||||
.IsRequired()
|
||||
.HasMaxLength(500);
|
||||
|
||||
builder.Property(s => s.AuthType)
|
||||
.IsRequired()
|
||||
.HasMaxLength(50);
|
||||
|
||||
// Stored encrypted at rest (EncryptedStringConverter). Ciphertext is larger than
|
||||
// the plaintext, so the column is sized generously to avoid truncation.
|
||||
builder.Property(s => s.Credentials)
|
||||
.HasMaxLength(8000);
|
||||
|
||||
builder.Property(s => s.TlsMode)
|
||||
.HasMaxLength(50);
|
||||
|
||||
builder.Property(s => s.FromAddress)
|
||||
.IsRequired()
|
||||
.HasMaxLength(500);
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core mapping for the central notification outbox entity. <see cref="Notification.TypeData"/>
|
||||
/// and <see cref="Notification.ResolvedTargets"/> are intentionally left unconstrained
|
||||
/// (nullable nvarchar(max)) as they carry variable-length JSON / target snapshots.
|
||||
/// </summary>
|
||||
public class NotificationOutboxConfiguration : IEntityTypeConfiguration<Notification>
|
||||
{
|
||||
/// <summary>Configures the EF Core entity type mapping for <see cref="Notification"/>.</summary>
|
||||
/// <param name="builder">The entity type builder to configure.</param>
|
||||
public void Configure(EntityTypeBuilder<Notification> builder)
|
||||
{
|
||||
builder.HasKey(n => n.NotificationId);
|
||||
|
||||
builder.Property(n => n.NotificationId).HasMaxLength(64);
|
||||
|
||||
builder.Property(n => n.Type)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(32)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(n => n.Status)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(32)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(n => n.ListName)
|
||||
.HasMaxLength(200)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(n => n.Subject)
|
||||
.HasMaxLength(1000)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(n => n.Body).IsRequired();
|
||||
|
||||
builder.Property(n => n.LastError).HasMaxLength(4000);
|
||||
|
||||
builder.Property(n => n.SourceSiteId)
|
||||
.HasMaxLength(100)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(n => n.SourceInstanceId).HasMaxLength(200);
|
||||
|
||||
builder.Property(n => n.SourceScript).HasMaxLength(200);
|
||||
|
||||
// SourceNode (Audit Log #23, SourceNode-stamping): node-local identifier of the
|
||||
// cluster member that produced the notification (e.g. "node-a", "central-a").
|
||||
// NULL is valid for rows that pre-date this feature. ASCII — varchar(64).
|
||||
// No index — KPIs are per-site on this table, not per-node; SourceNode is only
|
||||
// echoed onto NotifyDeliver audit rows (#23) for cross-row correlation.
|
||||
builder.Property(n => n.SourceNode)
|
||||
.HasColumnType("varchar(64)")
|
||||
.HasMaxLength(64)
|
||||
.IsUnicode(false);
|
||||
|
||||
// OriginExecutionId (Audit Log #23): nullable uniqueidentifier carried from the
|
||||
// site so the dispatcher can echo it onto NotifyDeliver audit rows. No index —
|
||||
// it is never a query predicate on this table, only copied onto audit events.
|
||||
|
||||
// OriginParentExecutionId (Audit Log #23): nullable uniqueidentifier carried from
|
||||
// the site — the routed run's parent ExecutionId — so the dispatcher can echo it
|
||||
// onto NotifyDeliver audit rows. No index — same rationale as OriginExecutionId.
|
||||
|
||||
builder.HasIndex(n => new { n.Status, n.NextAttemptAt });
|
||||
|
||||
builder.HasIndex(n => new { n.SourceSiteId, n.CreatedAt });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
|
||||
|
||||
public class SharedScriptConfiguration : IEntityTypeConfiguration<SharedScript>
|
||||
{
|
||||
/// <summary>Configures the EF Core mapping for the <see cref="SharedScript"/> entity.</summary>
|
||||
/// <param name="builder">Entity type builder used to apply the configuration.</param>
|
||||
public void Configure(EntityTypeBuilder<SharedScript> builder)
|
||||
{
|
||||
builder.HasKey(s => s.Id);
|
||||
|
||||
builder.Property(s => s.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(s => s.Code)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(s => s.ParameterDefinitions)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.Property(s => s.ReturnDefinition)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.HasIndex(s => s.Name).IsUnique();
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
|
||||
|
||||
public class LdapGroupMappingConfiguration : IEntityTypeConfiguration<LdapGroupMapping>
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures the EF Core entity type mapping for <see cref="LdapGroupMapping"/>.
|
||||
/// </summary>
|
||||
/// <param name="builder">The entity type builder to configure.</param>
|
||||
public void Configure(EntityTypeBuilder<LdapGroupMapping> builder)
|
||||
{
|
||||
builder.HasKey(m => m.Id);
|
||||
|
||||
builder.Property(m => m.LdapGroupName)
|
||||
.IsRequired()
|
||||
.HasMaxLength(500);
|
||||
|
||||
builder.Property(m => m.Role)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.HasIndex(m => m.LdapGroupName).IsUnique();
|
||||
|
||||
// Seed default group mappings matching GLAuth test users
|
||||
builder.HasData(
|
||||
new LdapGroupMapping("SCADA-Admins", "Admin") { Id = 1 },
|
||||
new LdapGroupMapping("SCADA-Designers", "Design") { Id = 2 },
|
||||
new LdapGroupMapping("SCADA-Deploy-All", "Deployment") { Id = 3 },
|
||||
new LdapGroupMapping("SCADA-Deploy-SiteA", "Deployment") { Id = 4 });
|
||||
}
|
||||
}
|
||||
|
||||
public class SiteScopeRuleConfiguration : IEntityTypeConfiguration<SiteScopeRule>
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures the EF Core entity type mapping for <see cref="SiteScopeRule"/>.
|
||||
/// </summary>
|
||||
/// <param name="builder">The entity type builder to configure.</param>
|
||||
public void Configure(EntityTypeBuilder<SiteScopeRule> builder)
|
||||
{
|
||||
builder.HasKey(r => r.Id);
|
||||
|
||||
builder.HasOne<LdapGroupMapping>()
|
||||
.WithMany()
|
||||
.HasForeignKey(r => r.LdapGroupMappingId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne<Site>()
|
||||
.WithMany()
|
||||
.HasForeignKey(r => r.SiteId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(r => new { r.LdapGroupMappingId, r.SiteId }).IsUnique();
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// Maps the <see cref="SiteCall"/> record to the central <c>SiteCalls</c> table
|
||||
/// (Site Call Audit #22, Audit Log #23 M3 Bundle B). Operational state — NOT audit —
|
||||
/// so the table is non-partitioned, standard <c>[PRIMARY]</c> filegroup, no DB-role
|
||||
/// restriction. Two named indexes back the Central UI's "from this site" and
|
||||
/// "in this status" queries.
|
||||
/// </summary>
|
||||
public class SiteCallEntityTypeConfiguration : IEntityTypeConfiguration<SiteCall>
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures the EF Core entity type mapping for <see cref="SiteCall"/>.
|
||||
/// </summary>
|
||||
/// <param name="builder">The entity type builder to configure.</param>
|
||||
public void Configure(EntityTypeBuilder<SiteCall> builder)
|
||||
{
|
||||
builder.ToTable("SiteCalls");
|
||||
|
||||
// PK is the strong-typed TrackedOperationId. Stored as varchar(36) by converting
|
||||
// through the canonical "D"-format GUID string. Going through the string surface
|
||||
// (rather than uniqueidentifier) keeps the column shape identical to how the id
|
||||
// is serialised on the wire (gRPC strings, SQLite TEXT on the site) — one
|
||||
// consistent format everywhere makes operational debugging far easier than
|
||||
// mixing a uniqueidentifier central column with TEXT site columns.
|
||||
builder.HasKey(s => s.TrackedOperationId);
|
||||
|
||||
builder.Property(s => s.TrackedOperationId)
|
||||
.HasConversion(
|
||||
id => id.Value.ToString("D"),
|
||||
s => new TrackedOperationId(Guid.Parse(s)))
|
||||
.HasMaxLength(36)
|
||||
.IsUnicode(false)
|
||||
.IsRequired();
|
||||
|
||||
// Enum-as-string columns: bounded varchar, ASCII.
|
||||
builder.Property(s => s.Channel)
|
||||
.HasMaxLength(32)
|
||||
.IsUnicode(false)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(s => s.Status)
|
||||
.HasMaxLength(32)
|
||||
.IsUnicode(false)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(s => s.SourceSite)
|
||||
.HasMaxLength(64)
|
||||
.IsUnicode(false)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(s => s.Target)
|
||||
.HasMaxLength(256)
|
||||
.IsUnicode(false)
|
||||
.IsRequired();
|
||||
|
||||
// Bounded unicode message column.
|
||||
builder.Property(s => s.LastError)
|
||||
.HasMaxLength(1024);
|
||||
|
||||
// SourceNode (Audit Log #23, SourceNode-stamping): node-local identifier of the
|
||||
// cluster member that produced the call (e.g. "node-a", "central-a"). NULL is
|
||||
// valid for rows that pre-date this feature and for reconciled rows from a
|
||||
// retired node. ASCII — varchar(64). No index — Site Call Audit KPIs are
|
||||
// per-site, not per-node, on this table.
|
||||
builder.Property(s => s.SourceNode)
|
||||
.HasColumnType("varchar(64)")
|
||||
.HasMaxLength(64)
|
||||
.IsUnicode(false);
|
||||
|
||||
// Indexes — names locked for reconciliation/migration discoverability.
|
||||
// Source_Created backs "calls from this site" (Central UI Site Calls page,
|
||||
// filter by SourceSite, newest first).
|
||||
builder.HasIndex(s => new { s.SourceSite, s.CreatedAtUtc })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("IX_SiteCalls_Source_Created");
|
||||
|
||||
// Status_Updated backs "calls in this status" (e.g. parked rows awaiting
|
||||
// operator action, newest UpdatedAtUtc first).
|
||||
builder.HasIndex(s => new { s.Status, s.UpdatedAtUtc })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("IX_SiteCalls_Status_Updated");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
|
||||
|
||||
public class SiteConfiguration : IEntityTypeConfiguration<Site>
|
||||
{
|
||||
/// <summary>Configures the EF Core mapping for <see cref="Site"/>.</summary>
|
||||
/// <param name="builder">The entity type builder.</param>
|
||||
public void Configure(EntityTypeBuilder<Site> builder)
|
||||
{
|
||||
builder.HasKey(s => s.Id);
|
||||
|
||||
builder.Property(s => s.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(s => s.SiteIdentifier)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property(s => s.Description)
|
||||
.HasMaxLength(2000);
|
||||
|
||||
builder.Property(s => s.NodeAAddress).HasMaxLength(500);
|
||||
builder.Property(s => s.NodeBAddress).HasMaxLength(500);
|
||||
builder.Property(s => s.GrpcNodeAAddress).HasMaxLength(500);
|
||||
builder.Property(s => s.GrpcNodeBAddress).HasMaxLength(500);
|
||||
|
||||
builder.HasIndex(s => s.Name).IsUnique();
|
||||
builder.HasIndex(s => s.SiteIdentifier).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public class DataConnectionConfiguration : IEntityTypeConfiguration<DataConnection>
|
||||
{
|
||||
/// <summary>Configures the EF Core mapping for <see cref="DataConnection"/>.</summary>
|
||||
/// <param name="builder">The entity type builder.</param>
|
||||
public void Configure(EntityTypeBuilder<DataConnection> builder)
|
||||
{
|
||||
builder.HasKey(d => d.Id);
|
||||
|
||||
builder.Property(d => d.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(d => d.Protocol)
|
||||
.IsRequired()
|
||||
.HasMaxLength(50);
|
||||
|
||||
builder.Property(d => d.PrimaryConfiguration)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.Property(d => d.BackupConfiguration)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.Property(d => d.FailoverRetryCount)
|
||||
.IsRequired()
|
||||
.HasDefaultValue(3);
|
||||
|
||||
builder.HasOne<Site>()
|
||||
.WithMany()
|
||||
.HasForeignKey(d => d.SiteId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(d => new { d.SiteId, d.Name }).IsUnique();
|
||||
}
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
|
||||
|
||||
public class TemplateConfiguration : IEntityTypeConfiguration<Template>
|
||||
{
|
||||
/// <summary>Configures the EF Core mapping for <see cref="Template"/>.</summary>
|
||||
/// <param name="builder">The entity type builder.</param>
|
||||
public void Configure(EntityTypeBuilder<Template> builder)
|
||||
{
|
||||
builder.HasKey(t => t.Id);
|
||||
|
||||
builder.Property(t => t.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(t => t.Description)
|
||||
.HasMaxLength(2000);
|
||||
|
||||
// Only base (user-authored) templates are globally unique by name.
|
||||
// Derived templates store their *contained* name (the composition slot's
|
||||
// InstanceName), unique only within the owner — enforced by the
|
||||
// (TemplateId, InstanceName) index on TemplateComposition — so they are
|
||||
// excluded from this index via a filter.
|
||||
builder.HasIndex(t => t.Name).IsUnique().HasFilter("[IsDerived] = 0");
|
||||
|
||||
// Self-referencing parent template (inheritance)
|
||||
builder.HasOne<Template>()
|
||||
.WithMany()
|
||||
.HasForeignKey(t => t.ParentTemplateId)
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.HasOne<TemplateFolder>()
|
||||
.WithMany()
|
||||
.HasForeignKey(t => t.FolderId)
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.HasMany(t => t.Attributes)
|
||||
.WithOne()
|
||||
.HasForeignKey(a => a.TemplateId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(t => t.Alarms)
|
||||
.WithOne()
|
||||
.HasForeignKey(a => a.TemplateId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(t => t.Scripts)
|
||||
.WithOne()
|
||||
.HasForeignKey(s => s.TemplateId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(t => t.Compositions)
|
||||
.WithOne()
|
||||
.HasForeignKey(c => c.TemplateId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
public class TemplateAttributeConfiguration : IEntityTypeConfiguration<TemplateAttribute>
|
||||
{
|
||||
/// <summary>Configures the EF Core mapping for <see cref="TemplateAttribute"/>.</summary>
|
||||
/// <param name="builder">The entity type builder.</param>
|
||||
public void Configure(EntityTypeBuilder<TemplateAttribute> builder)
|
||||
{
|
||||
builder.HasKey(a => a.Id);
|
||||
|
||||
builder.Property(a => a.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(a => a.Value)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.Property(a => a.Description)
|
||||
.HasMaxLength(2000);
|
||||
|
||||
builder.Property(a => a.DataSourceReference)
|
||||
.HasMaxLength(500);
|
||||
|
||||
builder.Property(a => a.DataType)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(50);
|
||||
|
||||
builder.HasIndex(a => new { a.TemplateId, a.Name }).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public class TemplateAlarmConfiguration : IEntityTypeConfiguration<TemplateAlarm>
|
||||
{
|
||||
/// <summary>Configures the EF Core mapping for <see cref="TemplateAlarm"/>.</summary>
|
||||
/// <param name="builder">The entity type builder.</param>
|
||||
public void Configure(EntityTypeBuilder<TemplateAlarm> builder)
|
||||
{
|
||||
builder.HasKey(a => a.Id);
|
||||
|
||||
builder.Property(a => a.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(a => a.Description)
|
||||
.HasMaxLength(2000);
|
||||
|
||||
builder.Property(a => a.TriggerType)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(50);
|
||||
|
||||
builder.Property(a => a.TriggerConfiguration)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.HasIndex(a => new { a.TemplateId, a.Name }).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public class TemplateScriptConfiguration : IEntityTypeConfiguration<TemplateScript>
|
||||
{
|
||||
/// <summary>Configures the EF Core mapping for <see cref="TemplateScript"/>.</summary>
|
||||
/// <param name="builder">The entity type builder.</param>
|
||||
public void Configure(EntityTypeBuilder<TemplateScript> builder)
|
||||
{
|
||||
builder.HasKey(s => s.Id);
|
||||
|
||||
builder.Property(s => s.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(s => s.Code)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(s => s.TriggerType)
|
||||
.HasMaxLength(50);
|
||||
|
||||
builder.Property(s => s.TriggerConfiguration)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.Property(s => s.ParameterDefinitions)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.Property(s => s.ReturnDefinition)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.HasIndex(s => new { s.TemplateId, s.Name }).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public class TemplateCompositionConfiguration : IEntityTypeConfiguration<TemplateComposition>
|
||||
{
|
||||
/// <summary>Configures the EF Core mapping for <see cref="TemplateComposition"/>.</summary>
|
||||
/// <param name="builder">The entity type builder.</param>
|
||||
public void Configure(EntityTypeBuilder<TemplateComposition> builder)
|
||||
{
|
||||
builder.HasKey(c => c.Id);
|
||||
|
||||
builder.Property(c => c.InstanceName)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
// The composed template reference
|
||||
builder.HasOne<Template>()
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.ComposedTemplateId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(c => new { c.TemplateId, c.InstanceName }).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public class TemplateFolderConfiguration : IEntityTypeConfiguration<TemplateFolder>
|
||||
{
|
||||
/// <summary>Configures the EF Core mapping for <see cref="TemplateFolder"/>.</summary>
|
||||
/// <param name="builder">The entity type builder.</param>
|
||||
public void Configure(EntityTypeBuilder<TemplateFolder> builder)
|
||||
{
|
||||
builder.HasKey(f => f.Id);
|
||||
|
||||
builder.Property(f => f.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.HasOne<TemplateFolder>()
|
||||
.WithMany()
|
||||
.HasForeignKey(f => f.ParentFolderId)
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired(false);
|
||||
|
||||
// Unique sibling name (case-insensitive enforced at service layer; this index is for fast lookup)
|
||||
builder.HasIndex(f => new { f.ParentFolderId, f.Name }).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating DbContext instances at design time (used by dotnet ef tooling).
|
||||
/// Resolves the connection string from the Host's appsettings files, or — for environments
|
||||
/// where those files are not present — from the
|
||||
/// <c>SCADALINK_DESIGNTIME_CONNECTIONSTRING</c> environment variable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// There is deliberately no hardcoded fallback connection string. A credential literal in
|
||||
/// source is committed to version control, encourages copy-paste of <c>sa</c> /
|
||||
/// <c>TrustServerCertificate=True</c> into real environments, and can silently point
|
||||
/// <c>dotnet ef</c> tooling at an unintended database. If no connection string can be
|
||||
/// resolved, this factory fails loudly with an actionable message.
|
||||
/// </remarks>
|
||||
public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<ScadaBridgeDbContext>
|
||||
{
|
||||
private const string EnvironmentVariableName = "SCADALINK_DESIGNTIME_CONNECTIONSTRING";
|
||||
private const string ConfigurationKey = "ScadaBridge:Database:ConfigurationDb";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="ScadaBridgeDbContext"/> for design-time EF Core tooling.
|
||||
/// </summary>
|
||||
/// <param name="args">Arguments passed by the EF tooling (unused).</param>
|
||||
public ScadaBridgeDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var configurationBuilder = new ConfigurationBuilder();
|
||||
|
||||
// The Host's appsettings files are an optional source — only wire them up when the
|
||||
// Host directory actually exists, otherwise SetBasePath throws DirectoryNotFoundException
|
||||
// (e.g. when this factory is exercised from a test runner with no sibling Host folder).
|
||||
var hostDirectory = Path.Combine(Directory.GetCurrentDirectory(), "..", "ZB.MOM.WW.ScadaBridge.Host");
|
||||
if (Directory.Exists(hostDirectory))
|
||||
{
|
||||
configurationBuilder
|
||||
.SetBasePath(hostDirectory)
|
||||
.AddJsonFile("appsettings.json", optional: true)
|
||||
.AddJsonFile("appsettings.Central.json", optional: true);
|
||||
}
|
||||
|
||||
var configuration = configurationBuilder.Build();
|
||||
|
||||
var connectionString = configuration[ConfigurationKey]
|
||||
?? Environment.GetEnvironmentVariable(EnvironmentVariableName);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"No design-time database connection string was found. Set the configuration " +
|
||||
$"key '{ConfigurationKey}' in the Host's appsettings file, or set the " +
|
||||
$"'{EnvironmentVariableName}' environment variable, before running dotnet ef tooling.");
|
||||
}
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<ScadaBridgeDbContext>();
|
||||
optionsBuilder.UseSqlServer(connectionString);
|
||||
|
||||
return new ScadaBridgeDbContext(optionsBuilder.Options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core value converter that encrypts a string column at rest using ASP.NET
|
||||
/// Data Protection. Plaintext is protected when written to the database and
|
||||
/// transparently unprotected when read back, so secret-bearing columns
|
||||
/// (SMTP credentials, external-system auth config, database connection strings)
|
||||
/// are never persisted verbatim.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The protector is purpose-scoped so ciphertext from one column cannot be
|
||||
/// unprotected as another. Data Protection keys are persisted to the
|
||||
/// configuration database itself (see <see cref="ScadaBridgeDbContext"/> implementing
|
||||
/// <c>IDataProtectionKeyContext</c>), so all central nodes share the same key ring
|
||||
/// and can decrypt each other's writes.
|
||||
/// </remarks>
|
||||
public sealed class EncryptedStringConverter : ValueConverter<string?, string?>
|
||||
{
|
||||
/// <summary>The Data Protection purpose string shared by all encrypted configuration columns.</summary>
|
||||
public const string ProtectorPurpose = "ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.EncryptedColumn";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the converter with the data protector used to encrypt and decrypt column values.
|
||||
/// </summary>
|
||||
/// <param name="protector">The data protector scoped to the encrypted column purpose.</param>
|
||||
public EncryptedStringConverter(IDataProtector protector)
|
||||
: base(
|
||||
plaintext => plaintext == null ? null : protector.Protect(plaintext),
|
||||
ciphertext => ciphertext == null ? null : Unprotect(protector, ciphertext))
|
||||
{
|
||||
}
|
||||
|
||||
private static string Unprotect(IDataProtector protector, string ciphertext)
|
||||
{
|
||||
// A row that predates encryption (or test fixtures inserting raw text) is not valid
|
||||
// protected payload. Unprotect throws CryptographicException in that case; surface a
|
||||
// clearer message rather than a bare crypto failure.
|
||||
try
|
||||
{
|
||||
return protector.Unprotect(ciphertext);
|
||||
}
|
||||
catch (System.Security.Cryptography.CryptographicException ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Failed to decrypt an encrypted configuration column. The Data Protection key " +
|
||||
"ring may be unavailable, or the stored value was not written by this system.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// EF/SQL-Server implementation of <see cref="IPartitionMaintenance"/> that
|
||||
/// rolls forward <c>pf_AuditLog_Month</c> by issuing
|
||||
/// <c>ALTER PARTITION FUNCTION … SPLIT RANGE</c> for each missing future
|
||||
/// monthly boundary.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The class is scoped (registered alongside the other repositories in
|
||||
/// <c>AddConfigurationDatabase</c>) because it shares <see cref="ScadaBridgeDbContext"/>
|
||||
/// — the hosted service opens a per-tick DI scope, resolves a fresh instance,
|
||||
/// and lets the scope's <c>DbContext</c> dispose with it. The class itself
|
||||
/// holds no state between calls.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Idempotency model.</b> Each tick reads the current max boundary from
|
||||
/// <c>sys.partition_range_values</c> and only issues SPLIT RANGE for
|
||||
/// boundaries that strictly follow it — a boundary already covered is never
|
||||
/// re-issued, so the "boundary already exists" failure (SQL Server msg 7708
|
||||
/// / 7711) is avoided by construction rather than caught. The pre-check is
|
||||
/// cheaper than the alternative TRY/CATCH around every SPLIT call and also
|
||||
/// keeps the returned <c>added</c> list semantically precise.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why "first of next month".</b> The migration seeds boundaries on the
|
||||
/// first-of-month at midnight UTC; we preserve that convention so the
|
||||
/// resulting partition layout is uniform. <see cref="NormalizeToFirstOfMonth"/>
|
||||
/// rounds an arbitrary timestamp up to the next first-of-month boundary
|
||||
/// (e.g. 2026-05-20 → 2026-06-01), and <see cref="NextMonthBoundary"/>
|
||||
/// walks one month at a time from there.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Permissions.</b> The migration's <c>scadabridge_audit_purger</c> role
|
||||
/// already carries <c>ALTER ON SCHEMA::dbo</c>, which is sufficient for
|
||||
/// <c>ALTER PARTITION FUNCTION SPLIT RANGE</c>. No additional grant is
|
||||
/// required.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class AuditLogPartitionMaintenance : IPartitionMaintenance
|
||||
{
|
||||
private const string PartitionFunctionName = "pf_AuditLog_Month";
|
||||
private const string PartitionSchemeName = "ps_AuditLog_Month";
|
||||
private const string TargetFileGroup = "PRIMARY";
|
||||
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
private readonly ILogger<AuditLogPartitionMaintenance> _logger;
|
||||
|
||||
/// <summary>Initializes the maintenance implementation with the database context and optional logger.</summary>
|
||||
/// <param name="context">The EF Core database context used to execute partition-management SQL.</param>
|
||||
/// <param name="logger">Optional logger; defaults to <see cref="NullLogger{T}"/> when null.</param>
|
||||
public AuditLogPartitionMaintenance(
|
||||
ScadaBridgeDbContext context,
|
||||
ILogger<AuditLogPartitionMaintenance>? logger = null)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_logger = logger ?? NullLogger<AuditLogPartitionMaintenance>.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DateTime?> GetMaxBoundaryAsync(CancellationToken ct = default)
|
||||
{
|
||||
// CAST the sql_variant `value` column to datetime2(7) — every boundary in
|
||||
// pf_AuditLog_Month is declared as datetime2(7) by the migration, so the
|
||||
// cast never loses precision.
|
||||
const string sql = @"
|
||||
SELECT MAX(CAST(rv.value AS datetime2(7)))
|
||||
FROM sys.partition_range_values rv
|
||||
INNER JOIN sys.partition_functions pf ON rv.function_id = pf.function_id
|
||||
WHERE pf.name = 'pf_AuditLog_Month';";
|
||||
|
||||
var conn = _context.Database.GetDbConnection();
|
||||
var openedHere = false;
|
||||
if (conn.State != System.Data.ConnectionState.Open)
|
||||
{
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
openedHere = true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
var raw = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||
if (raw is null || raw is DBNull)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// ExecuteScalarAsync materialises datetime2 as DateTime with
|
||||
// DateTimeKind.Unspecified; the boundary values are stored at
|
||||
// UTC midnight by convention (migration seeds with 'T00:00:00'),
|
||||
// so we re-tag the kind so downstream comparisons against
|
||||
// DateTime.UtcNow stay in the same kind space.
|
||||
var dt = (DateTime)raw;
|
||||
return DateTime.SpecifyKind(dt, DateTimeKind.Utc);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (openedHere)
|
||||
{
|
||||
await conn.CloseAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DateTime>> EnsureLookaheadAsync(
|
||||
int lookaheadMonths,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (lookaheadMonths < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(lookaheadMonths),
|
||||
lookaheadMonths,
|
||||
"Lookahead must be at least one month — the partition function would otherwise be allowed to fall behind 'now'.");
|
||||
}
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
// Horizon: the FIRST-OF-MONTH that must be the strictly-greater-than
|
||||
// max boundary after this call. Example: nowUtc = 2026-05-20 and
|
||||
// lookaheadMonths = 1 → horizon = 2026-07-01 (so the partition for
|
||||
// June 2026 is already in place by mid-May).
|
||||
var horizon = NormalizeToFirstOfMonth(nowUtc).AddMonths(lookaheadMonths);
|
||||
|
||||
var max = await GetMaxBoundaryAsync(ct).ConfigureAwait(false);
|
||||
if (max is null)
|
||||
{
|
||||
// No partition function (e.g. migrations not applied) — nothing
|
||||
// we can safely SPLIT against. Log and return; the absence is a
|
||||
// genuine misconfiguration that other parts of the system will
|
||||
// surface louder than we could here.
|
||||
_logger.LogWarning(
|
||||
"EnsureLookaheadAsync: partition function {PartitionFunctionName} not found; skipping.",
|
||||
PartitionFunctionName);
|
||||
return Array.Empty<DateTime>();
|
||||
}
|
||||
|
||||
// Start splitting from the FIRST month strictly after max — if max is
|
||||
// already first-of-month (the common case), that's max + 1 month;
|
||||
// otherwise NormalizeToFirstOfMonth rounds up.
|
||||
var next = NormalizeToFirstOfMonth(max.Value.AddDays(1));
|
||||
|
||||
// Edge case: max already past horizon → no work to do.
|
||||
if (next > horizon)
|
||||
{
|
||||
return Array.Empty<DateTime>();
|
||||
}
|
||||
|
||||
var added = new List<DateTime>();
|
||||
while (next <= horizon)
|
||||
{
|
||||
// Boundary literal must be a deterministic, culture-invariant ISO
|
||||
// string — SQL Server parses it as datetime2 via implicit conversion.
|
||||
// SPLIT RANGE does NOT accept @-parameters; the value is part of the
|
||||
// DDL statement, so we render it directly. The format is
|
||||
// guaranteed (yyyy-MM-ddTHH:mm:ss.fffffff) so there is no injection
|
||||
// surface.
|
||||
var literal = next.ToString("yyyy-MM-ddTHH:mm:ss.fffffff", CultureInfo.InvariantCulture);
|
||||
|
||||
// Before every SPLIT we must (re-)set the NEXT USED filegroup on
|
||||
// ps_AuditLog_Month. Even though the scheme was created with
|
||||
// `ALL TO ([PRIMARY])` (which auto-populates NEXT USED once), SQL
|
||||
// Server consumes that hint on the FIRST split — subsequent splits
|
||||
// raise msg 7707 ("partition scheme … does not have any next used
|
||||
// filegroup") unless NEXT USED is explicitly re-set. Re-issuing it
|
||||
// before every split is idempotent and keeps the loop simple.
|
||||
var sql = $@"
|
||||
ALTER PARTITION SCHEME {PartitionSchemeName} NEXT USED [{TargetFileGroup}];
|
||||
ALTER PARTITION FUNCTION {PartitionFunctionName}() SPLIT RANGE ('{literal}');";
|
||||
|
||||
// ConfigDB-019: the loop pre-reads max-boundary and only issues
|
||||
// SPLITs for strictly-greater months, so msg 7708/7711 ("boundary
|
||||
// already exists") cannot happen by construction. Any OTHER
|
||||
// SqlException (permission revoked on the role, deadlock victim,
|
||||
// log full, filegroup full, transient connection drop) means the
|
||||
// boundary genuinely failed to create. The previous catch-and-
|
||||
// continue silently moved on to the next month, splitting month
|
||||
// N+1 successfully and leaving a permanent partition hole for
|
||||
// month N that blocks partition-switch purge until an operator
|
||||
// notices and rebuilds. Let SqlException propagate so the daily
|
||||
// hosted-service tick logs an Error and the next tick retries
|
||||
// from the same boundary (at-least-once, no holes).
|
||||
await _context.Database.ExecuteSqlRawAsync(sql, ct).ConfigureAwait(false);
|
||||
added.Add(next);
|
||||
|
||||
next = NextMonthBoundary(next);
|
||||
}
|
||||
|
||||
return added;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rounds an arbitrary instant UP to the next first-of-month UTC. Inputs
|
||||
/// that ARE already a first-of-month at midnight are returned as-is so
|
||||
/// callers can compose this freely without double-incrementing.
|
||||
/// </summary>
|
||||
private static DateTime NormalizeToFirstOfMonth(DateTime instant)
|
||||
{
|
||||
var utc = instant.Kind == DateTimeKind.Utc
|
||||
? instant
|
||||
: DateTime.SpecifyKind(instant, DateTimeKind.Utc);
|
||||
|
||||
var firstOfThisMonth = new DateTime(utc.Year, utc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
return utc == firstOfThisMonth ? firstOfThisMonth : firstOfThisMonth.AddMonths(1);
|
||||
}
|
||||
|
||||
private static DateTime NextMonthBoundary(DateTime boundary) =>
|
||||
boundary.AddMonths(1);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
|
||||
/// <summary>
|
||||
/// Provides environment-aware migration behavior for the ScadaBridge configuration database.
|
||||
/// </summary>
|
||||
public static class MigrationHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies pending migrations (development mode) or validates schema version (production mode).
|
||||
/// </summary>
|
||||
/// <param name="dbContext">The database context to migrate or validate.</param>
|
||||
/// <param name="isDevelopment">When true, auto-applies migrations. When false, validates schema version matches.</param>
|
||||
/// <param name="logger">Optional logger for readiness-wait diagnostics.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public static async Task ApplyOrValidateMigrationsAsync(
|
||||
ScadaBridgeDbContext dbContext,
|
||||
bool isDevelopment,
|
||||
ILogger? logger = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Wait for the target database to accept connections before invoking MigrateAsync.
|
||||
// On a fresh MSSQL container, user databases recover asynchronously after the server
|
||||
// starts accepting connections — DB_ID(@dbName) returns null until recovery completes.
|
||||
// Without this wait, MigrateAsync sees the database as missing and falls through to
|
||||
// CREATE DATABASE, which fails for non-privileged app logins.
|
||||
await WaitForDatabaseReadyAsync(dbContext, logger, cancellationToken);
|
||||
|
||||
if (isDevelopment)
|
||||
{
|
||||
await dbContext.Database.MigrateAsync(cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
var pendingMigrations = await dbContext.Database.GetPendingMigrationsAsync(cancellationToken);
|
||||
var pending = pendingMigrations.ToList();
|
||||
if (pending.Count > 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Database schema is out of date. {pending.Count} pending migration(s): {string.Join(", ", pending)}. " +
|
||||
"Apply migrations using 'dotnet ef database update' or the generated SQL scripts before starting in production mode.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitForDatabaseReadyAsync(
|
||||
ScadaBridgeDbContext dbContext,
|
||||
ILogger? logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var timeout = TimeSpan.FromSeconds(60);
|
||||
var pollInterval = TimeSpan.FromSeconds(2);
|
||||
var deadline = DateTimeOffset.UtcNow + timeout;
|
||||
var attempt = 0;
|
||||
Exception? lastException = null;
|
||||
|
||||
while (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
attempt++;
|
||||
try
|
||||
{
|
||||
if (await dbContext.Database.CanConnectAsync(cancellationToken))
|
||||
{
|
||||
if (attempt > 1)
|
||||
{
|
||||
logger?.LogInformation(
|
||||
"Configuration database ready after {Attempt} attempt(s).", attempt);
|
||||
}
|
||||
return;
|
||||
}
|
||||
logger?.LogDebug(
|
||||
"Configuration database not yet reachable (attempt {Attempt}).", attempt);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastException = ex;
|
||||
logger?.LogDebug(ex,
|
||||
"Configuration database not yet reachable (attempt {Attempt}).", attempt);
|
||||
}
|
||||
|
||||
await Task.Delay(pollInterval, cancellationToken);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Configuration database not ready after {timeout.TotalSeconds:N0}s ({attempt} attempts). " +
|
||||
"Verify SQL Server is running and the configuration database exists and is attached.",
|
||||
lastException);
|
||||
}
|
||||
}
|
||||
Generated
+1222
File diff suppressed because it is too large
Load Diff
+943
@@ -0,0 +1,943 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialSchema : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ApiKeys",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
KeyValue = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
|
||||
IsEnabled = table.Column<bool>(type: "bit", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ApiKeys", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ApiMethods",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
Script = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
ApprovedApiKeyIds = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true),
|
||||
ParameterDefinitions = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true),
|
||||
ReturnDefinition = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true),
|
||||
TimeoutSeconds = table.Column<int>(type: "int", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ApiMethods", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AuditLogEntries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
User = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
Action = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
EntityType = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
EntityId = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
EntityName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
AfterStateJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Timestamp = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AuditLogEntries", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DatabaseConnectionDefinitions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
ConnectionString = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: false),
|
||||
MaxRetries = table.Column<int>(type: "int", nullable: false),
|
||||
RetryDelay = table.Column<TimeSpan>(type: "time", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DatabaseConnectionDefinitions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DataConnections",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
Protocol = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
Configuration = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DataConnections", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DataProtectionKeys",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
FriendlyName = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Xml = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DataProtectionKeys", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExternalSystemDefinitions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
EndpointUrl = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: false),
|
||||
AuthType = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
AuthConfiguration = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true),
|
||||
MaxRetries = table.Column<int>(type: "int", nullable: false),
|
||||
RetryDelay = table.Column<TimeSpan>(type: "time", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExternalSystemDefinitions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "LdapGroupMappings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
LdapGroupName = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
|
||||
Role = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_LdapGroupMappings", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "NotificationLists",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_NotificationLists", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SharedScripts",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
Code = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
ParameterDefinitions = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true),
|
||||
ReturnDefinition = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SharedScripts", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Sites",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
SiteIdentifier = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Sites", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SmtpConfigurations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Host = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
|
||||
Port = table.Column<int>(type: "int", nullable: false),
|
||||
AuthType = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
Credentials = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true),
|
||||
TlsMode = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
|
||||
FromAddress = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
|
||||
ConnectionTimeoutSeconds = table.Column<int>(type: "int", nullable: false),
|
||||
MaxConcurrentConnections = table.Column<int>(type: "int", nullable: false),
|
||||
MaxRetries = table.Column<int>(type: "int", nullable: false),
|
||||
RetryDelay = table.Column<TimeSpan>(type: "time", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SmtpConfigurations", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SystemArtifactDeploymentRecords",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
ArtifactType = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
DeployedBy = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
DeployedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
PerSiteStatus = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SystemArtifactDeploymentRecords", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Templates",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
|
||||
ParentTemplateId = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Templates", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Templates_Templates_ParentTemplateId",
|
||||
column: x => x.ParentTemplateId,
|
||||
principalTable: "Templates",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExternalSystemMethods",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
ExternalSystemDefinitionId = table.Column<int>(type: "int", nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
HttpMethod = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: false),
|
||||
Path = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: false),
|
||||
ParameterDefinitions = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true),
|
||||
ReturnDefinition = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExternalSystemMethods", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExternalSystemMethods_ExternalSystemDefinitions_ExternalSystemDefinitionId",
|
||||
column: x => x.ExternalSystemDefinitionId,
|
||||
principalTable: "ExternalSystemDefinitions",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "NotificationRecipients",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
NotificationListId = table.Column<int>(type: "int", nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
EmailAddress = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_NotificationRecipients", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_NotificationRecipients_NotificationLists_NotificationListId",
|
||||
column: x => x.NotificationListId,
|
||||
principalTable: "NotificationLists",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Areas",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
SiteId = table.Column<int>(type: "int", nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
ParentAreaId = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Areas", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Areas_Areas_ParentAreaId",
|
||||
column: x => x.ParentAreaId,
|
||||
principalTable: "Areas",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_Areas_Sites_SiteId",
|
||||
column: x => x.SiteId,
|
||||
principalTable: "Sites",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SiteDataConnectionAssignments",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
SiteId = table.Column<int>(type: "int", nullable: false),
|
||||
DataConnectionId = table.Column<int>(type: "int", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SiteDataConnectionAssignments", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_SiteDataConnectionAssignments_DataConnections_DataConnectionId",
|
||||
column: x => x.DataConnectionId,
|
||||
principalTable: "DataConnections",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_SiteDataConnectionAssignments_Sites_SiteId",
|
||||
column: x => x.SiteId,
|
||||
principalTable: "Sites",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SiteScopeRules",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
LdapGroupMappingId = table.Column<int>(type: "int", nullable: false),
|
||||
SiteId = table.Column<int>(type: "int", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SiteScopeRules", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_SiteScopeRules_LdapGroupMappings_LdapGroupMappingId",
|
||||
column: x => x.LdapGroupMappingId,
|
||||
principalTable: "LdapGroupMappings",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_SiteScopeRules_Sites_SiteId",
|
||||
column: x => x.SiteId,
|
||||
principalTable: "Sites",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TemplateAlarms",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
TemplateId = table.Column<int>(type: "int", nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
|
||||
PriorityLevel = table.Column<int>(type: "int", nullable: false),
|
||||
IsLocked = table.Column<bool>(type: "bit", nullable: false),
|
||||
TriggerType = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
TriggerConfiguration = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true),
|
||||
OnTriggerScriptId = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TemplateAlarms", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_TemplateAlarms_Templates_TemplateId",
|
||||
column: x => x.TemplateId,
|
||||
principalTable: "Templates",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TemplateAttributes",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
TemplateId = table.Column<int>(type: "int", nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
Value = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true),
|
||||
DataType = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
IsLocked = table.Column<bool>(type: "bit", nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
|
||||
DataSourceReference = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TemplateAttributes", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_TemplateAttributes_Templates_TemplateId",
|
||||
column: x => x.TemplateId,
|
||||
principalTable: "Templates",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TemplateCompositions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
TemplateId = table.Column<int>(type: "int", nullable: false),
|
||||
ComposedTemplateId = table.Column<int>(type: "int", nullable: false),
|
||||
InstanceName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TemplateCompositions", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_TemplateCompositions_Templates_ComposedTemplateId",
|
||||
column: x => x.ComposedTemplateId,
|
||||
principalTable: "Templates",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_TemplateCompositions_Templates_TemplateId",
|
||||
column: x => x.TemplateId,
|
||||
principalTable: "Templates",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TemplateScripts",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
TemplateId = table.Column<int>(type: "int", nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
IsLocked = table.Column<bool>(type: "bit", nullable: false),
|
||||
Code = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
TriggerType = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
|
||||
TriggerConfiguration = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true),
|
||||
ParameterDefinitions = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true),
|
||||
ReturnDefinition = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true),
|
||||
MinTimeBetweenRuns = table.Column<TimeSpan>(type: "time", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TemplateScripts", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_TemplateScripts_Templates_TemplateId",
|
||||
column: x => x.TemplateId,
|
||||
principalTable: "Templates",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Instances",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
TemplateId = table.Column<int>(type: "int", nullable: false),
|
||||
SiteId = table.Column<int>(type: "int", nullable: false),
|
||||
AreaId = table.Column<int>(type: "int", nullable: true),
|
||||
UniqueName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
State = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Instances", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Instances_Areas_AreaId",
|
||||
column: x => x.AreaId,
|
||||
principalTable: "Areas",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_Instances_Sites_SiteId",
|
||||
column: x => x.SiteId,
|
||||
principalTable: "Sites",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_Instances_Templates_TemplateId",
|
||||
column: x => x.TemplateId,
|
||||
principalTable: "Templates",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DeployedConfigSnapshots",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
InstanceId = table.Column<int>(type: "int", nullable: false),
|
||||
DeploymentId = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
RevisionHash = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
ConfigurationJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
DeployedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DeployedConfigSnapshots", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_DeployedConfigSnapshots_Instances_InstanceId",
|
||||
column: x => x.InstanceId,
|
||||
principalTable: "Instances",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DeploymentRecords",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
InstanceId = table.Column<int>(type: "int", nullable: false),
|
||||
Status = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
DeploymentId = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
RevisionHash = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||
DeployedBy = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
DeployedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
CompletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
ErrorMessage = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DeploymentRecords", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_DeploymentRecords_Instances_InstanceId",
|
||||
column: x => x.InstanceId,
|
||||
principalTable: "Instances",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "InstanceAttributeOverrides",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
InstanceId = table.Column<int>(type: "int", nullable: false),
|
||||
AttributeName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
OverrideValue = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_InstanceAttributeOverrides", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_InstanceAttributeOverrides_Instances_InstanceId",
|
||||
column: x => x.InstanceId,
|
||||
principalTable: "Instances",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "InstanceConnectionBindings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
InstanceId = table.Column<int>(type: "int", nullable: false),
|
||||
AttributeName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
DataConnectionId = table.Column<int>(type: "int", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_InstanceConnectionBindings", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_InstanceConnectionBindings_DataConnections_DataConnectionId",
|
||||
column: x => x.DataConnectionId,
|
||||
principalTable: "DataConnections",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_InstanceConnectionBindings_Instances_InstanceId",
|
||||
column: x => x.InstanceId,
|
||||
principalTable: "Instances",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "LdapGroupMappings",
|
||||
columns: new[] { "Id", "LdapGroupName", "Role" },
|
||||
values: new object[] { 1, "SCADA-Admins", "Admin" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ApiKeys_KeyValue",
|
||||
table: "ApiKeys",
|
||||
column: "KeyValue",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ApiKeys_Name",
|
||||
table: "ApiKeys",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ApiMethods_Name",
|
||||
table: "ApiMethods",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Areas_ParentAreaId",
|
||||
table: "Areas",
|
||||
column: "ParentAreaId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Areas_SiteId_ParentAreaId_Name",
|
||||
table: "Areas",
|
||||
columns: new[] { "SiteId", "ParentAreaId", "Name" },
|
||||
unique: true,
|
||||
filter: "[ParentAreaId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AuditLogEntries_Action",
|
||||
table: "AuditLogEntries",
|
||||
column: "Action");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AuditLogEntries_EntityId",
|
||||
table: "AuditLogEntries",
|
||||
column: "EntityId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AuditLogEntries_EntityType",
|
||||
table: "AuditLogEntries",
|
||||
column: "EntityType");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AuditLogEntries_Timestamp",
|
||||
table: "AuditLogEntries",
|
||||
column: "Timestamp");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AuditLogEntries_User",
|
||||
table: "AuditLogEntries",
|
||||
column: "User");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DatabaseConnectionDefinitions_Name",
|
||||
table: "DatabaseConnectionDefinitions",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DataConnections_Name",
|
||||
table: "DataConnections",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DeployedConfigSnapshots_DeploymentId",
|
||||
table: "DeployedConfigSnapshots",
|
||||
column: "DeploymentId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DeployedConfigSnapshots_InstanceId",
|
||||
table: "DeployedConfigSnapshots",
|
||||
column: "InstanceId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DeploymentRecords_DeployedAt",
|
||||
table: "DeploymentRecords",
|
||||
column: "DeployedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DeploymentRecords_DeploymentId",
|
||||
table: "DeploymentRecords",
|
||||
column: "DeploymentId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DeploymentRecords_InstanceId",
|
||||
table: "DeploymentRecords",
|
||||
column: "InstanceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExternalSystemDefinitions_Name",
|
||||
table: "ExternalSystemDefinitions",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExternalSystemMethods_ExternalSystemDefinitionId_Name",
|
||||
table: "ExternalSystemMethods",
|
||||
columns: new[] { "ExternalSystemDefinitionId", "Name" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_InstanceAttributeOverrides_InstanceId_AttributeName",
|
||||
table: "InstanceAttributeOverrides",
|
||||
columns: new[] { "InstanceId", "AttributeName" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_InstanceConnectionBindings_DataConnectionId",
|
||||
table: "InstanceConnectionBindings",
|
||||
column: "DataConnectionId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_InstanceConnectionBindings_InstanceId_AttributeName",
|
||||
table: "InstanceConnectionBindings",
|
||||
columns: new[] { "InstanceId", "AttributeName" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Instances_AreaId",
|
||||
table: "Instances",
|
||||
column: "AreaId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Instances_SiteId_UniqueName",
|
||||
table: "Instances",
|
||||
columns: new[] { "SiteId", "UniqueName" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Instances_TemplateId",
|
||||
table: "Instances",
|
||||
column: "TemplateId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LdapGroupMappings_LdapGroupName",
|
||||
table: "LdapGroupMappings",
|
||||
column: "LdapGroupName",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NotificationLists_Name",
|
||||
table: "NotificationLists",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NotificationRecipients_NotificationListId",
|
||||
table: "NotificationRecipients",
|
||||
column: "NotificationListId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SharedScripts_Name",
|
||||
table: "SharedScripts",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SiteDataConnectionAssignments_DataConnectionId",
|
||||
table: "SiteDataConnectionAssignments",
|
||||
column: "DataConnectionId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SiteDataConnectionAssignments_SiteId_DataConnectionId",
|
||||
table: "SiteDataConnectionAssignments",
|
||||
columns: new[] { "SiteId", "DataConnectionId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Sites_Name",
|
||||
table: "Sites",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Sites_SiteIdentifier",
|
||||
table: "Sites",
|
||||
column: "SiteIdentifier",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SiteScopeRules_LdapGroupMappingId_SiteId",
|
||||
table: "SiteScopeRules",
|
||||
columns: new[] { "LdapGroupMappingId", "SiteId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SiteScopeRules_SiteId",
|
||||
table: "SiteScopeRules",
|
||||
column: "SiteId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SystemArtifactDeploymentRecords_DeployedAt",
|
||||
table: "SystemArtifactDeploymentRecords",
|
||||
column: "DeployedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TemplateAlarms_TemplateId_Name",
|
||||
table: "TemplateAlarms",
|
||||
columns: new[] { "TemplateId", "Name" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TemplateAttributes_TemplateId_Name",
|
||||
table: "TemplateAttributes",
|
||||
columns: new[] { "TemplateId", "Name" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TemplateCompositions_ComposedTemplateId",
|
||||
table: "TemplateCompositions",
|
||||
column: "ComposedTemplateId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TemplateCompositions_TemplateId_InstanceName",
|
||||
table: "TemplateCompositions",
|
||||
columns: new[] { "TemplateId", "InstanceName" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Templates_Name",
|
||||
table: "Templates",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Templates_ParentTemplateId",
|
||||
table: "Templates",
|
||||
column: "ParentTemplateId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TemplateScripts_TemplateId_Name",
|
||||
table: "TemplateScripts",
|
||||
columns: new[] { "TemplateId", "Name" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ApiKeys");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ApiMethods");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AuditLogEntries");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DatabaseConnectionDefinitions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DataProtectionKeys");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DeployedConfigSnapshots");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DeploymentRecords");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExternalSystemMethods");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "InstanceAttributeOverrides");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "InstanceConnectionBindings");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "NotificationRecipients");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SharedScripts");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SiteDataConnectionAssignments");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SiteScopeRules");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SmtpConfigurations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SystemArtifactDeploymentRecords");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "TemplateAlarms");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "TemplateAttributes");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "TemplateCompositions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "TemplateScripts");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExternalSystemDefinitions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Instances");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "NotificationLists");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DataConnections");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "LdapGroupMappings");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Areas");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Templates");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Sites");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1248
File diff suppressed because it is too large
Load Diff
+42
@@ -0,0 +1,42 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSiteNodeAddresses : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "NodeAAddress",
|
||||
table: "Sites",
|
||||
type: "nvarchar(500)",
|
||||
maxLength: 500,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "NodeBAddress",
|
||||
table: "Sites",
|
||||
type: "nvarchar(500)",
|
||||
maxLength: 500,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NodeAAddress",
|
||||
table: "Sites");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NodeBAddress",
|
||||
table: "Sites");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1254
File diff suppressed because it is too large
Load Diff
+38
@@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGrpcNodeAddresses : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "GrpcNodeAAddress",
|
||||
table: "Sites",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "GrpcNodeBAddress",
|
||||
table: "Sites",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GrpcNodeAAddress",
|
||||
table: "Sites");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GrpcNodeBAddress",
|
||||
table: "Sites");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1227
File diff suppressed because it is too large
Load Diff
+184
@@ -0,0 +1,184 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSiteIdToDataConnections : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Step 1: Drop old unique index on Name (allows duplicate names across sites)
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_DataConnections_Name",
|
||||
table: "DataConnections");
|
||||
|
||||
// Step 2: Add nullable SiteId column
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SiteId",
|
||||
table: "DataConnections",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
// Step 3: Migrate data from SiteDataConnectionAssignments
|
||||
migrationBuilder.Sql(@"
|
||||
-- Phase A: Assign the first site to each existing DataConnection
|
||||
UPDATE dc
|
||||
SET dc.SiteId = a.SiteId
|
||||
FROM DataConnections dc
|
||||
INNER JOIN (
|
||||
SELECT DataConnectionId, MIN(SiteId) AS SiteId
|
||||
FROM SiteDataConnectionAssignments
|
||||
GROUP BY DataConnectionId
|
||||
) a ON dc.Id = a.DataConnectionId
|
||||
WHERE dc.SiteId IS NULL;
|
||||
|
||||
-- Phase B: For connections assigned to additional sites, create copies
|
||||
-- and update InstanceConnectionBindings to point to the new copy
|
||||
DECLARE @AssignSiteId INT, @AssignConnId INT, @NewConnId INT;
|
||||
DECLARE @OrigName NVARCHAR(200), @OrigProtocol NVARCHAR(50), @OrigConfig NVARCHAR(4000);
|
||||
|
||||
DECLARE assignment_cursor CURSOR FOR
|
||||
SELECT a.SiteId, a.DataConnectionId
|
||||
FROM SiteDataConnectionAssignments a
|
||||
INNER JOIN DataConnections dc ON a.DataConnectionId = dc.Id
|
||||
WHERE dc.SiteId <> a.SiteId;
|
||||
|
||||
OPEN assignment_cursor;
|
||||
FETCH NEXT FROM assignment_cursor INTO @AssignSiteId, @AssignConnId;
|
||||
|
||||
WHILE @@FETCH_STATUS = 0
|
||||
BEGIN
|
||||
SELECT @OrigName = Name, @OrigProtocol = Protocol, @OrigConfig = Configuration
|
||||
FROM DataConnections WHERE Id = @AssignConnId;
|
||||
|
||||
INSERT INTO DataConnections (SiteId, Name, Protocol, Configuration)
|
||||
VALUES (@AssignSiteId, @OrigName, @OrigProtocol, @OrigConfig);
|
||||
|
||||
SET @NewConnId = SCOPE_IDENTITY();
|
||||
|
||||
-- Update bindings for instances on this site to point to the new connection
|
||||
UPDATE icb
|
||||
SET icb.DataConnectionId = @NewConnId
|
||||
FROM InstanceConnectionBindings icb
|
||||
INNER JOIN Instances i ON icb.InstanceId = i.Id
|
||||
WHERE icb.DataConnectionId = @AssignConnId
|
||||
AND i.SiteId = @AssignSiteId;
|
||||
|
||||
FETCH NEXT FROM assignment_cursor INTO @AssignSiteId, @AssignConnId;
|
||||
END
|
||||
|
||||
CLOSE assignment_cursor;
|
||||
DEALLOCATE assignment_cursor;
|
||||
|
||||
-- Phase C: Handle any DataConnections not assigned to any site
|
||||
-- (assign to the first site as a fallback)
|
||||
UPDATE dc
|
||||
SET dc.SiteId = (SELECT TOP 1 Id FROM Sites ORDER BY Id)
|
||||
FROM DataConnections dc
|
||||
WHERE dc.SiteId IS NULL;
|
||||
");
|
||||
|
||||
// Step 4: Make SiteId non-nullable
|
||||
migrationBuilder.AlterColumn<int>(
|
||||
name: "SiteId",
|
||||
table: "DataConnections",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0,
|
||||
oldClrType: typeof(int),
|
||||
oldType: "int",
|
||||
oldNullable: true);
|
||||
|
||||
// Step 5: Add composite unique index and FK
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DataConnections_SiteId_Name",
|
||||
table: "DataConnections",
|
||||
columns: new[] { "SiteId", "Name" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_DataConnections_Sites_SiteId",
|
||||
table: "DataConnections",
|
||||
column: "SiteId",
|
||||
principalTable: "Sites",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
// Step 6: Drop SiteDataConnectionAssignments table
|
||||
migrationBuilder.DropTable(
|
||||
name: "SiteDataConnectionAssignments");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Recreate SiteDataConnectionAssignments table
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SiteDataConnectionAssignments",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
DataConnectionId = table.Column<int>(type: "int", nullable: false),
|
||||
SiteId = table.Column<int>(type: "int", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SiteDataConnectionAssignments", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_SiteDataConnectionAssignments_DataConnections_DataConnectionId",
|
||||
column: x => x.DataConnectionId,
|
||||
principalTable: "DataConnections",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_SiteDataConnectionAssignments_Sites_SiteId",
|
||||
column: x => x.SiteId,
|
||||
principalTable: "Sites",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SiteDataConnectionAssignments_DataConnectionId",
|
||||
table: "SiteDataConnectionAssignments",
|
||||
column: "DataConnectionId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SiteDataConnectionAssignments_SiteId_DataConnectionId",
|
||||
table: "SiteDataConnectionAssignments",
|
||||
columns: new[] { "SiteId", "DataConnectionId" },
|
||||
unique: true);
|
||||
|
||||
// Migrate data back
|
||||
migrationBuilder.Sql(@"
|
||||
INSERT INTO SiteDataConnectionAssignments (SiteId, DataConnectionId)
|
||||
SELECT SiteId, Id FROM DataConnections;
|
||||
");
|
||||
|
||||
// Remove FK and composite index
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_DataConnections_Sites_SiteId",
|
||||
table: "DataConnections");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_DataConnections_SiteId_Name",
|
||||
table: "DataConnections");
|
||||
|
||||
// Restore unique index on Name
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DataConnections_Name",
|
||||
table: "DataConnections",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
// Drop SiteId column
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SiteId",
|
||||
table: "DataConnections");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1236
File diff suppressed because it is too large
Load Diff
+50
@@ -0,0 +1,50 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPrimaryBackupDataConnections : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "Configuration",
|
||||
table: "DataConnections",
|
||||
newName: "PrimaryConfiguration");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BackupConfiguration",
|
||||
table: "DataConnections",
|
||||
type: "nvarchar(4000)",
|
||||
maxLength: 4000,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "FailoverRetryCount",
|
||||
table: "DataConnections",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 3);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BackupConfiguration",
|
||||
table: "DataConnections");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FailoverRetryCount",
|
||||
table: "DataConnections");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "PrimaryConfiguration",
|
||||
table: "DataConnections",
|
||||
newName: "Configuration");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1282
File diff suppressed because it is too large
Load Diff
+80
@@ -0,0 +1,80 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTemplateFolders : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "FolderId",
|
||||
table: "Templates",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TemplateFolders",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
ParentFolderId = table.Column<int>(type: "int", nullable: true),
|
||||
SortOrder = table.Column<int>(type: "int", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TemplateFolders", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_TemplateFolders_TemplateFolders_ParentFolderId",
|
||||
column: x => x.ParentFolderId,
|
||||
principalTable: "TemplateFolders",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Templates_FolderId",
|
||||
table: "Templates",
|
||||
column: "FolderId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TemplateFolders_ParentFolderId_Name",
|
||||
table: "TemplateFolders",
|
||||
columns: new[] { "ParentFolderId", "Name" },
|
||||
unique: true,
|
||||
filter: "[ParentFolderId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Templates_TemplateFolders_FolderId",
|
||||
table: "Templates",
|
||||
column: "FolderId",
|
||||
principalTable: "TemplateFolders",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Templates_TemplateFolders_FolderId",
|
||||
table: "Templates");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "TemplateFolders");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Templates_FolderId",
|
||||
table: "Templates");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FolderId",
|
||||
table: "Templates");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1300
File diff suppressed because it is too large
Load Diff
+83
@@ -0,0 +1,83 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDerivedTemplateFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsInherited",
|
||||
table: "TemplateScripts",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "LockedInDerived",
|
||||
table: "TemplateScripts",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsDerived",
|
||||
table: "Templates",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "OwnerCompositionId",
|
||||
table: "Templates",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsInherited",
|
||||
table: "TemplateAttributes",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "LockedInDerived",
|
||||
table: "TemplateAttributes",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsInherited",
|
||||
table: "TemplateScripts");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LockedInDerived",
|
||||
table: "TemplateScripts");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsDerived",
|
||||
table: "Templates");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "OwnerCompositionId",
|
||||
table: "Templates");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsInherited",
|
||||
table: "TemplateAttributes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LockedInDerived",
|
||||
table: "TemplateAttributes");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1300
File diff suppressed because it is too large
Load Diff
+117
@@ -0,0 +1,117 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class MigrateCompositionsToDerived : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Re-shape every pre-Phase-2 TemplateComposition so it points at a
|
||||
// newly created derived template ("<parent>.<slot>") that inherits
|
||||
// from the original base. Attribute and script rows are copied with
|
||||
// IsInherited=1; the composition's ComposedTemplateId is repointed.
|
||||
//
|
||||
// Idempotent: only rows whose target is still IsDerived=0 are touched.
|
||||
// Aborts the migration if any derived name would collide with an
|
||||
// existing template, so the operator can resolve manually.
|
||||
migrationBuilder.Sql(@"
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @collisions NVARCHAR(MAX) = (
|
||||
SELECT STRING_AGG(owner.Name + N'.' + c.InstanceName, N', ')
|
||||
FROM TemplateCompositions c
|
||||
INNER JOIN Templates base_t ON base_t.Id = c.ComposedTemplateId
|
||||
INNER JOIN Templates owner ON owner.Id = c.TemplateId
|
||||
INNER JOIN Templates existing ON existing.Name = owner.Name + N'.' + c.InstanceName
|
||||
WHERE base_t.IsDerived = 0
|
||||
);
|
||||
IF @collisions IS NOT NULL
|
||||
BEGIN
|
||||
DECLARE @msg NVARCHAR(MAX) =
|
||||
N'MigrateCompositionsToDerived: cannot create derived templates — these names already exist: '
|
||||
+ @collisions
|
||||
+ N'. Rename the conflicting templates and retry the migration.';
|
||||
THROW 50000, @msg, 1;
|
||||
END
|
||||
|
||||
DECLARE @CompId INT, @BaseId INT, @OwnerName NVARCHAR(200), @SlotName NVARCHAR(200);
|
||||
DECLARE @NewId INT, @NewName NVARCHAR(200);
|
||||
|
||||
DECLARE map_cursor CURSOR FAST_FORWARD FOR
|
||||
SELECT c.Id, c.ComposedTemplateId, owner.Name, c.InstanceName
|
||||
FROM TemplateCompositions c
|
||||
INNER JOIN Templates base_t ON base_t.Id = c.ComposedTemplateId
|
||||
INNER JOIN Templates owner ON owner.Id = c.TemplateId
|
||||
WHERE base_t.IsDerived = 0;
|
||||
|
||||
OPEN map_cursor;
|
||||
FETCH NEXT FROM map_cursor INTO @CompId, @BaseId, @OwnerName, @SlotName;
|
||||
|
||||
WHILE @@FETCH_STATUS = 0
|
||||
BEGIN
|
||||
SET @NewName = @OwnerName + N'.' + @SlotName;
|
||||
|
||||
INSERT INTO Templates (Name, Description, ParentTemplateId, FolderId, IsDerived, OwnerCompositionId)
|
||||
SELECT @NewName, b.Description, b.Id, NULL, 1, @CompId
|
||||
FROM Templates b
|
||||
WHERE b.Id = @BaseId;
|
||||
|
||||
SET @NewId = SCOPE_IDENTITY();
|
||||
|
||||
INSERT INTO TemplateAttributes
|
||||
(TemplateId, Name, Value, DataType, IsLocked, Description, DataSourceReference, IsInherited, LockedInDerived)
|
||||
SELECT @NewId, a.Name, a.Value, a.DataType, a.IsLocked, a.Description, a.DataSourceReference, 1, 0
|
||||
FROM TemplateAttributes a
|
||||
WHERE a.TemplateId = @BaseId;
|
||||
|
||||
INSERT INTO TemplateScripts
|
||||
(TemplateId, Name, Code, IsLocked, TriggerType, TriggerConfiguration, ParameterDefinitions, ReturnDefinition, MinTimeBetweenRuns, IsInherited, LockedInDerived)
|
||||
SELECT @NewId, s.Name, s.Code, s.IsLocked, s.TriggerType, s.TriggerConfiguration, s.ParameterDefinitions, s.ReturnDefinition, s.MinTimeBetweenRuns, 1, 0
|
||||
FROM TemplateScripts s
|
||||
WHERE s.TemplateId = @BaseId;
|
||||
|
||||
UPDATE TemplateCompositions
|
||||
SET ComposedTemplateId = @NewId
|
||||
WHERE Id = @CompId;
|
||||
|
||||
FETCH NEXT FROM map_cursor INTO @CompId, @BaseId, @OwnerName, @SlotName;
|
||||
END
|
||||
|
||||
CLOSE map_cursor;
|
||||
DEALLOCATE map_cursor;
|
||||
");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Reverse: repoint each composition back to the derived template's
|
||||
// base, then drop the derived templates (with their copied rows).
|
||||
migrationBuilder.Sql(@"
|
||||
SET NOCOUNT ON;
|
||||
|
||||
UPDATE c
|
||||
SET c.ComposedTemplateId = d.ParentTemplateId
|
||||
FROM TemplateCompositions c
|
||||
INNER JOIN Templates d ON d.Id = c.ComposedTemplateId
|
||||
WHERE d.IsDerived = 1
|
||||
AND d.OwnerCompositionId = c.Id
|
||||
AND d.ParentTemplateId IS NOT NULL;
|
||||
|
||||
DELETE a FROM TemplateAttributes a
|
||||
INNER JOIN Templates t ON t.Id = a.TemplateId
|
||||
WHERE t.IsDerived = 1;
|
||||
|
||||
DELETE s FROM TemplateScripts s
|
||||
INNER JOIN Templates t ON t.Id = s.TemplateId
|
||||
WHERE t.IsDerived = 1;
|
||||
|
||||
DELETE FROM Templates WHERE IsDerived = 1;
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1300
File diff suppressed because it is too large
Load Diff
+196
@@ -0,0 +1,196 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class MigrateParametersToJsonSchema : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Convert legacy flat-shape parameter / return JSON in TemplateScripts,
|
||||
// SharedScripts, and ApiMethods to JSON Schema.
|
||||
//
|
||||
// Parameters [{name,type,required,itemType?}]
|
||||
// → {"type":"object","properties":{<name>:{"type":<jsType>}},"required":[...]}
|
||||
//
|
||||
// Return {type,itemType?}
|
||||
// → {"type":<jsType>} or {"type":"array","items":{"type":<inner>}}
|
||||
//
|
||||
// Idempotent: only rows whose value starts with '[' (parameters) or that
|
||||
// contain the legacy 'List' sentinel (return) are touched. Already-converted
|
||||
// rows are skipped.
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
IF OBJECT_ID('dbo.fn_LegacyTypeToJsonSchemaType', 'FN') IS NOT NULL
|
||||
DROP FUNCTION dbo.fn_LegacyTypeToJsonSchemaType;
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE FUNCTION dbo.fn_LegacyTypeToJsonSchemaType(@legacy NVARCHAR(50))
|
||||
RETURNS NVARCHAR(50)
|
||||
AS
|
||||
BEGIN
|
||||
RETURN
|
||||
CASE LOWER(ISNULL(@legacy, 'string'))
|
||||
WHEN 'boolean' THEN 'boolean'
|
||||
WHEN 'bool' THEN 'boolean'
|
||||
WHEN 'integer' THEN 'integer'
|
||||
WHEN 'int' THEN 'integer'
|
||||
WHEN 'int32' THEN 'integer'
|
||||
WHEN 'int64' THEN 'integer'
|
||||
WHEN 'float' THEN 'number'
|
||||
WHEN 'double' THEN 'number'
|
||||
WHEN 'decimal' THEN 'number'
|
||||
WHEN 'number' THEN 'number'
|
||||
WHEN 'string' THEN 'string'
|
||||
WHEN 'datetime' THEN 'string'
|
||||
WHEN 'object' THEN 'object'
|
||||
WHEN 'list' THEN 'array'
|
||||
WHEN 'array' THEN 'array'
|
||||
ELSE 'string'
|
||||
END;
|
||||
END;
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
IF OBJECT_ID('dbo.fn_LegacyParametersToJsonSchema', 'FN') IS NOT NULL
|
||||
DROP FUNCTION dbo.fn_LegacyParametersToJsonSchema;
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE FUNCTION dbo.fn_LegacyParametersToJsonSchema(@legacy NVARCHAR(MAX))
|
||||
RETURNS NVARCHAR(MAX)
|
||||
AS
|
||||
BEGIN
|
||||
IF @legacy IS NULL OR LTRIM(@legacy) = '' RETURN NULL;
|
||||
IF LEFT(LTRIM(@legacy), 1) <> '[' RETURN @legacy; -- already schema-shaped
|
||||
|
||||
DECLARE @props NVARCHAR(MAX) = (
|
||||
SELECT STRING_AGG(
|
||||
CONCAT(
|
||||
'""',
|
||||
STRING_ESCAPE(JSON_VALUE(p.value, '$.name'), 'json'),
|
||||
'"":',
|
||||
CASE
|
||||
WHEN LOWER(ISNULL(JSON_VALUE(p.value, '$.type'), 'string')) IN ('list', 'array')
|
||||
THEN CONCAT(
|
||||
'{""type"":""array"",""items"":{""type"":""',
|
||||
dbo.fn_LegacyTypeToJsonSchemaType(JSON_VALUE(p.value, '$.itemType')),
|
||||
'""}}')
|
||||
ELSE CONCAT(
|
||||
'{""type"":""',
|
||||
dbo.fn_LegacyTypeToJsonSchemaType(JSON_VALUE(p.value, '$.type')),
|
||||
'""}')
|
||||
END),
|
||||
',')
|
||||
WITHIN GROUP (ORDER BY p.[key])
|
||||
FROM OPENJSON(@legacy) p
|
||||
WHERE JSON_VALUE(p.value, '$.name') IS NOT NULL
|
||||
AND JSON_VALUE(p.value, '$.name') <> ''
|
||||
);
|
||||
|
||||
DECLARE @required NVARCHAR(MAX) = (
|
||||
SELECT STRING_AGG(
|
||||
CONCAT('""', STRING_ESCAPE(JSON_VALUE(p.value, '$.name'), 'json'), '""'),
|
||||
',')
|
||||
WITHIN GROUP (ORDER BY p.[key])
|
||||
FROM OPENJSON(@legacy) p
|
||||
WHERE JSON_VALUE(p.value, '$.name') IS NOT NULL
|
||||
AND JSON_VALUE(p.value, '$.name') <> ''
|
||||
AND LOWER(ISNULL(JSON_VALUE(p.value, '$.required'), 'true')) <> 'false'
|
||||
);
|
||||
|
||||
RETURN
|
||||
'{""type"":""object"",""properties"":{' + ISNULL(@props, '') + '}'
|
||||
+ CASE WHEN @required IS NULL OR @required = '' THEN ''
|
||||
ELSE ',""required"":[' + @required + ']'
|
||||
END
|
||||
+ '}';
|
||||
END;
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
IF OBJECT_ID('dbo.fn_LegacyReturnToJsonSchema', 'FN') IS NOT NULL
|
||||
DROP FUNCTION dbo.fn_LegacyReturnToJsonSchema;
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE FUNCTION dbo.fn_LegacyReturnToJsonSchema(@legacy NVARCHAR(MAX))
|
||||
RETURNS NVARCHAR(MAX)
|
||||
AS
|
||||
BEGIN
|
||||
IF @legacy IS NULL OR LTRIM(@legacy) = '' RETURN NULL;
|
||||
IF LEFT(LTRIM(@legacy), 1) <> '{' RETURN @legacy;
|
||||
|
||||
DECLARE @legacyType NVARCHAR(50) = JSON_VALUE(@legacy, '$.type');
|
||||
IF @legacyType IS NULL RETURN @legacy;
|
||||
|
||||
-- Already JSON Schema (lowercase types, no itemType legacy sentinel): leave it.
|
||||
IF @legacyType IN ('boolean','integer','number','string','object','array')
|
||||
AND JSON_VALUE(@legacy, '$.itemType') IS NULL
|
||||
RETURN @legacy;
|
||||
|
||||
IF LOWER(@legacyType) = 'list'
|
||||
BEGIN
|
||||
DECLARE @inner NVARCHAR(50) =
|
||||
dbo.fn_LegacyTypeToJsonSchemaType(JSON_VALUE(@legacy, '$.itemType'));
|
||||
RETURN CONCAT('{""type"":""array"",""items"":{""type"":""', @inner, '""}}');
|
||||
END;
|
||||
|
||||
RETURN CONCAT('{""type"":""', dbo.fn_LegacyTypeToJsonSchemaType(@legacyType), '""}');
|
||||
END;
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE TemplateScripts
|
||||
SET ParameterDefinitions = dbo.fn_LegacyParametersToJsonSchema(ParameterDefinitions)
|
||||
WHERE ParameterDefinitions IS NOT NULL
|
||||
AND LEFT(LTRIM(ParameterDefinitions), 1) = '[';
|
||||
|
||||
UPDATE TemplateScripts
|
||||
SET ReturnDefinition = dbo.fn_LegacyReturnToJsonSchema(ReturnDefinition)
|
||||
WHERE ReturnDefinition IS NOT NULL
|
||||
AND LEFT(LTRIM(ReturnDefinition), 1) = '{';
|
||||
|
||||
UPDATE SharedScripts
|
||||
SET ParameterDefinitions = dbo.fn_LegacyParametersToJsonSchema(ParameterDefinitions)
|
||||
WHERE ParameterDefinitions IS NOT NULL
|
||||
AND LEFT(LTRIM(ParameterDefinitions), 1) = '[';
|
||||
|
||||
UPDATE SharedScripts
|
||||
SET ReturnDefinition = dbo.fn_LegacyReturnToJsonSchema(ReturnDefinition)
|
||||
WHERE ReturnDefinition IS NOT NULL
|
||||
AND LEFT(LTRIM(ReturnDefinition), 1) = '{';
|
||||
|
||||
UPDATE ApiMethods
|
||||
SET ParameterDefinitions = dbo.fn_LegacyParametersToJsonSchema(ParameterDefinitions)
|
||||
WHERE ParameterDefinitions IS NOT NULL
|
||||
AND LEFT(LTRIM(ParameterDefinitions), 1) = '[';
|
||||
|
||||
UPDATE ApiMethods
|
||||
SET ReturnDefinition = dbo.fn_LegacyReturnToJsonSchema(ReturnDefinition)
|
||||
WHERE ReturnDefinition IS NOT NULL
|
||||
AND LEFT(LTRIM(ReturnDefinition), 1) = '{';
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
DROP FUNCTION IF EXISTS dbo.fn_LegacyParametersToJsonSchema;
|
||||
DROP FUNCTION IF EXISTS dbo.fn_LegacyReturnToJsonSchema;
|
||||
DROP FUNCTION IF EXISTS dbo.fn_LegacyTypeToJsonSchemaType;
|
||||
");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Lossy: JSON Schema can express fields (descriptions, defaults, enums,
|
||||
// nested objects) that the legacy flat shape cannot represent. Reverse
|
||||
// migration is not supported.
|
||||
throw new System.NotSupportedException(
|
||||
"Reverse migration from JSON Schema to legacy flat shape is not supported because the conversion is lossy.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1342
File diff suppressed because it is too large
Load Diff
+49
@@ -0,0 +1,49 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddInstanceAlarmOverrides : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "InstanceAlarmOverrides",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
InstanceId = table.Column<int>(type: "int", nullable: false),
|
||||
AlarmCanonicalName = table.Column<string>(type: "nvarchar(400)", maxLength: 400, nullable: false),
|
||||
TriggerConfigurationOverride = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true),
|
||||
PriorityLevelOverride = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_InstanceAlarmOverrides", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_InstanceAlarmOverrides_Instances_InstanceId",
|
||||
column: x => x.InstanceId,
|
||||
principalTable: "Instances",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_InstanceAlarmOverrides_InstanceId_AlarmCanonicalName",
|
||||
table: "InstanceAlarmOverrides",
|
||||
columns: new[] { "InstanceId", "AlarmCanonicalName" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "InstanceAlarmOverrides");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1348
File diff suppressed because it is too large
Load Diff
+40
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDerivedAlarmFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsInherited",
|
||||
table: "TemplateAlarms",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "LockedInDerived",
|
||||
table: "TemplateAlarms",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsInherited",
|
||||
table: "TemplateAlarms");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LockedInDerived",
|
||||
table: "TemplateAlarms");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1348
File diff suppressed because it is too large
Load Diff
+82
@@ -0,0 +1,82 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class EncryptSecretColumns : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Credentials",
|
||||
table: "SmtpConfigurations",
|
||||
type: "nvarchar(max)",
|
||||
maxLength: 8000,
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(4000)",
|
||||
oldMaxLength: 4000,
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "AuthConfiguration",
|
||||
table: "ExternalSystemDefinitions",
|
||||
type: "nvarchar(max)",
|
||||
maxLength: 8000,
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(4000)",
|
||||
oldMaxLength: 4000,
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "ConnectionString",
|
||||
table: "DatabaseConnectionDefinitions",
|
||||
type: "nvarchar(max)",
|
||||
maxLength: 8000,
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(4000)",
|
||||
oldMaxLength: 4000);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Credentials",
|
||||
table: "SmtpConfigurations",
|
||||
type: "nvarchar(4000)",
|
||||
maxLength: 4000,
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(max)",
|
||||
oldMaxLength: 8000,
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "AuthConfiguration",
|
||||
table: "ExternalSystemDefinitions",
|
||||
type: "nvarchar(4000)",
|
||||
maxLength: 4000,
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(max)",
|
||||
oldMaxLength: 8000,
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "ConnectionString",
|
||||
table: "DatabaseConnectionDefinitions",
|
||||
type: "nvarchar(4000)",
|
||||
maxLength: 4000,
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(max)",
|
||||
oldMaxLength: 8000);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1350
File diff suppressed because it is too large
Load Diff
+58
@@ -0,0 +1,58 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class BoundGrpcNodeAddressLength : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "GrpcNodeBAddress",
|
||||
table: "Sites",
|
||||
type: "nvarchar(500)",
|
||||
maxLength: 500,
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(max)",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "GrpcNodeAAddress",
|
||||
table: "Sites",
|
||||
type: "nvarchar(500)",
|
||||
maxLength: 500,
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(max)",
|
||||
oldNullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "GrpcNodeBAddress",
|
||||
table: "Sites",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(500)",
|
||||
oldMaxLength: 500,
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "GrpcNodeAAddress",
|
||||
table: "Sites",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(500)",
|
||||
oldMaxLength: 500,
|
||||
oldNullable: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1350
File diff suppressed because it is too large
Load Diff
+77
@@ -0,0 +1,77 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// ConfigurationDatabase-012: replaces the plaintext <c>KeyValue</c> column with a
|
||||
/// <c>KeyHash</c> column holding a deterministic HMAC-SHA256 hash of the key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A hash is one-way: existing plaintext keys cannot be converted to hashes
|
||||
/// without the originals. This migration therefore deletes all existing API-key
|
||||
/// rows. <strong>Every existing API key must be re-issued</strong> after this
|
||||
/// migration is applied — create new keys via the CLI / Management API / Central
|
||||
/// UI, distribute the one-time plaintext to callers, and approve them on methods.
|
||||
/// </remarks>
|
||||
public partial class HashApiKeyValue : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Existing keys hold only plaintext, which cannot be hashed back. They
|
||||
// must be re-issued, so remove them before the column change to keep the
|
||||
// new unique KeyHash index satisfiable.
|
||||
migrationBuilder.Sql("DELETE FROM ApiKeys;");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ApiKeys_KeyValue",
|
||||
table: "ApiKeys");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "KeyValue",
|
||||
table: "ApiKeys");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "KeyHash",
|
||||
table: "ApiKeys",
|
||||
type: "nvarchar(256)",
|
||||
maxLength: 256,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ApiKeys_KeyHash",
|
||||
table: "ApiKeys",
|
||||
column: "KeyHash",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ApiKeys_KeyHash",
|
||||
table: "ApiKeys");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "KeyHash",
|
||||
table: "ApiKeys");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "KeyValue",
|
||||
table: "ApiKeys",
|
||||
type: "nvarchar(500)",
|
||||
maxLength: 500,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ApiKeys_KeyValue",
|
||||
table: "ApiKeys",
|
||||
column: "KeyValue",
|
||||
unique: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1351
File diff suppressed because it is too large
Load Diff
+81
@@ -0,0 +1,81 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Moves composition-derived templates to AVEVA-style contained names: a
|
||||
/// derived template stores only its slot name (e.g. <c>Pump</c>), not the
|
||||
/// dotted qualified path (<c>Motor Controller.Pump</c>). The qualified name
|
||||
/// is computed on read by walking the OwnerComposition chain. The unique
|
||||
/// index on Template.Name becomes filtered to base templates only —
|
||||
/// derived templates' uniqueness is the (TemplateId, InstanceName) index on
|
||||
/// TemplateComposition.
|
||||
/// </summary>
|
||||
public partial class ContainedDerivedTemplateNames : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Drop the global unique index first: derived rows are about to be
|
||||
// renamed to contained names that may duplicate one another or a
|
||||
// base template.
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Templates_Name",
|
||||
table: "Templates");
|
||||
|
||||
// Collapse every derived template's dotted name to its contained
|
||||
// name — the owning composition slot's InstanceName.
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE t
|
||||
SET t.Name = c.InstanceName
|
||||
FROM Templates t
|
||||
INNER JOIN TemplateCompositions c ON c.Id = t.OwnerCompositionId
|
||||
WHERE t.IsDerived = 1;");
|
||||
|
||||
// Recreate the uniqueness guarantee for base templates only.
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Templates_Name",
|
||||
table: "Templates",
|
||||
column: "Name",
|
||||
unique: true,
|
||||
filter: "[IsDerived] = 0");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Templates_Name",
|
||||
table: "Templates");
|
||||
|
||||
// Rebuild the dotted qualified names so the global unique index can
|
||||
// be restored — derived templates' contained names are not globally
|
||||
// unique. The recursive CTE walks the OwnerComposition chain down
|
||||
// from each base template.
|
||||
migrationBuilder.Sql(@"
|
||||
WITH q AS (
|
||||
SELECT t.Id, CAST(t.Name AS NVARCHAR(MAX)) AS Qualified
|
||||
FROM Templates t
|
||||
WHERE t.IsDerived = 0
|
||||
UNION ALL
|
||||
SELECT t.Id, CAST(q.Qualified + N'.' + c.InstanceName AS NVARCHAR(MAX))
|
||||
FROM Templates t
|
||||
INNER JOIN TemplateCompositions c ON c.Id = t.OwnerCompositionId
|
||||
INNER JOIN q ON q.Id = c.TemplateId
|
||||
)
|
||||
UPDATE t
|
||||
SET t.Name = q.Qualified
|
||||
FROM Templates t
|
||||
INNER JOIN q ON q.Id = t.Id
|
||||
WHERE t.IsDerived = 1;");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Templates_Name",
|
||||
table: "Templates",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1436
File diff suppressed because it is too large
Load Diff
+72
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddNotificationsTable : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Type",
|
||||
table: "NotificationLists",
|
||||
type: "nvarchar(32)",
|
||||
maxLength: 32,
|
||||
nullable: false,
|
||||
defaultValue: "Email");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Notifications",
|
||||
columns: table => new
|
||||
{
|
||||
NotificationId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Type = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||
ListName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
Subject = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
|
||||
Body = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
TypeData = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Status = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||
RetryCount = table.Column<int>(type: "int", nullable: false),
|
||||
LastError = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true),
|
||||
ResolvedTargets = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
SourceSiteId = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
SourceInstanceId = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
SourceScript = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
SiteEnqueuedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
LastAttemptAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
NextAttemptAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
DeliveredAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Notifications", x => x.NotificationId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Notifications_SourceSiteId_CreatedAt",
|
||||
table: "Notifications",
|
||||
columns: new[] { "SourceSiteId", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Notifications_Status_NextAttemptAt",
|
||||
table: "Notifications",
|
||||
columns: new[] { "Status", "NextAttemptAt" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Notifications");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Type",
|
||||
table: "NotificationLists");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1553
File diff suppressed because it is too large
Load Diff
+201
@@ -0,0 +1,201 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Bundle C (#23 M1): creates the centralized AuditLog table with monthly
|
||||
/// partitioning and the two access-control roles documented in alog.md §4.
|
||||
///
|
||||
/// Structure:
|
||||
/// 1. Partition function <c>pf_AuditLog_Month</c> (RANGE RIGHT) with 24
|
||||
/// monthly boundaries covering 2026-01-01 through 2027-12-01 UTC.
|
||||
/// 2. Partition scheme <c>ps_AuditLog_Month</c> mapping every partition to
|
||||
/// [PRIMARY] (dev/test parity; production may relocate via filegroups).
|
||||
/// 3. <c>AuditLog</c> table created via raw SQL so it is created directly
|
||||
/// on the partition scheme. The clustered PK is composite
|
||||
/// {EventId, OccurredAtUtc} — required because partition-aligned PKs
|
||||
/// must include the partition column.
|
||||
/// 4. Five reconciliation/query indexes from alog.md §4, plus the
|
||||
/// UX_AuditLog_EventId unique index that preserves single-column
|
||||
/// EventId uniqueness for InsertIfNotExistsAsync (M1-T8). All
|
||||
/// non-clustered indexes are partition-aligned on
|
||||
/// <c>ps_AuditLog_Month(OccurredAtUtc)</c>.
|
||||
/// 5. Two database roles:
|
||||
/// - <c>scadabridge_audit_writer</c>: INSERT + SELECT on AuditLog, with
|
||||
/// explicit DENY on UPDATE and DELETE so additive role membership
|
||||
/// (e.g. later db_datawriter) cannot accidentally re-enable mutation.
|
||||
/// - <c>scadabridge_audit_purger</c>: SELECT on AuditLog and ALTER on
|
||||
/// SCHEMA::dbo so the purger can run ALTER PARTITION FUNCTION SWITCH
|
||||
/// and SWITCH PARTITION when sliding the retention window.
|
||||
/// </summary>
|
||||
public partial class AddAuditLogTable : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// 1) Partition function (monthly boundaries Jan 2026 – Dec 2027 UTC).
|
||||
// RANGE RIGHT — the boundary value belongs to the right-hand partition,
|
||||
// matching the convention used by SQL Server partition-switch tooling.
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE PARTITION FUNCTION pf_AuditLog_Month (datetime2(7))
|
||||
AS RANGE RIGHT FOR VALUES (
|
||||
'2026-01-01T00:00:00', '2026-02-01T00:00:00', '2026-03-01T00:00:00', '2026-04-01T00:00:00',
|
||||
'2026-05-01T00:00:00', '2026-06-01T00:00:00', '2026-07-01T00:00:00', '2026-08-01T00:00:00',
|
||||
'2026-09-01T00:00:00', '2026-10-01T00:00:00', '2026-11-01T00:00:00', '2026-12-01T00:00:00',
|
||||
'2027-01-01T00:00:00', '2027-02-01T00:00:00', '2027-03-01T00:00:00', '2027-04-01T00:00:00',
|
||||
'2027-05-01T00:00:00', '2027-06-01T00:00:00', '2027-07-01T00:00:00', '2027-08-01T00:00:00',
|
||||
'2027-09-01T00:00:00', '2027-10-01T00:00:00', '2027-11-01T00:00:00', '2027-12-01T00:00:00'
|
||||
);");
|
||||
|
||||
// 2) Partition scheme mapping every partition to [PRIMARY].
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE PARTITION SCHEME ps_AuditLog_Month
|
||||
AS PARTITION pf_AuditLog_Month ALL TO ([PRIMARY]);");
|
||||
|
||||
// 3) Create the table directly on the partition scheme. Column shapes
|
||||
// are copied from AuditLogEntityTypeConfiguration so the live schema
|
||||
// matches the EF model exactly. The clustered PK is composite to
|
||||
// satisfy SQL Server's rule that partition-aligned clustered indexes
|
||||
// must include the partition column.
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE TABLE dbo.AuditLog (
|
||||
EventId uniqueidentifier NOT NULL,
|
||||
OccurredAtUtc datetime2(7) NOT NULL,
|
||||
IngestedAtUtc datetime2(7) NULL,
|
||||
Channel varchar(32) NOT NULL,
|
||||
Kind varchar(32) NOT NULL,
|
||||
CorrelationId uniqueidentifier NULL,
|
||||
SourceSiteId varchar(64) NULL,
|
||||
SourceInstanceId varchar(128) NULL,
|
||||
SourceScript varchar(128) NULL,
|
||||
Actor varchar(128) NULL,
|
||||
Target varchar(256) NULL,
|
||||
Status varchar(32) NOT NULL,
|
||||
HttpStatus int NULL,
|
||||
DurationMs int NULL,
|
||||
ErrorMessage nvarchar(1024) NULL,
|
||||
ErrorDetail nvarchar(max) NULL,
|
||||
RequestSummary nvarchar(max) NULL,
|
||||
ResponseSummary nvarchar(max) NULL,
|
||||
PayloadTruncated bit NOT NULL,
|
||||
Extra nvarchar(max) NULL,
|
||||
ForwardState varchar(32) NULL,
|
||||
CONSTRAINT PK_AuditLog PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc)
|
||||
ON ps_AuditLog_Month(OccurredAtUtc)
|
||||
) ON ps_AuditLog_Month(OccurredAtUtc);");
|
||||
|
||||
// 4) Reconciliation/query indexes from alog.md §4. All non-clustered
|
||||
// indexes are partition-aligned on ps_AuditLog_Month(OccurredAtUtc)
|
||||
// so partition-switch operations only touch a single partition. The
|
||||
// filtered indexes carry their NOT NULL predicates as documented.
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE NONCLUSTERED INDEX IX_AuditLog_OccurredAtUtc
|
||||
ON dbo.AuditLog (OccurredAtUtc DESC)
|
||||
ON ps_AuditLog_Month(OccurredAtUtc);");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE NONCLUSTERED INDEX IX_AuditLog_Site_Occurred
|
||||
ON dbo.AuditLog (SourceSiteId ASC, OccurredAtUtc DESC)
|
||||
ON ps_AuditLog_Month(OccurredAtUtc);");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE NONCLUSTERED INDEX IX_AuditLog_CorrelationId
|
||||
ON dbo.AuditLog (CorrelationId)
|
||||
WHERE CorrelationId IS NOT NULL
|
||||
ON ps_AuditLog_Month(OccurredAtUtc);");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE NONCLUSTERED INDEX IX_AuditLog_Channel_Status_Occurred
|
||||
ON dbo.AuditLog (Channel ASC, Status ASC, OccurredAtUtc DESC)
|
||||
ON ps_AuditLog_Month(OccurredAtUtc);");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE NONCLUSTERED INDEX IX_AuditLog_Target_Occurred
|
||||
ON dbo.AuditLog (Target ASC, OccurredAtUtc DESC)
|
||||
WHERE Target IS NOT NULL
|
||||
ON ps_AuditLog_Month(OccurredAtUtc);");
|
||||
|
||||
// The EventId uniqueness index supports InsertIfNotExistsAsync
|
||||
// (M1-T8). It is INTENTIONALLY non-aligned (placed on [PRIMARY]
|
||||
// rather than ps_AuditLog_Month).
|
||||
//
|
||||
// SQL Server's rule for unique partition-aligned indexes is that the
|
||||
// partition column must be a SUBSET of the index key. Including
|
||||
// OccurredAtUtc in the key would change the uniqueness semantics
|
||||
// from "EventId is globally unique" to "(EventId, OccurredAtUtc)
|
||||
// is unique", which is the same guarantee the composite PK already
|
||||
// provides — it would not give us single-column EventId uniqueness.
|
||||
//
|
||||
// Trade-off: a non-aligned index disables ALTER TABLE … SWITCH
|
||||
// PARTITION on AuditLog. The M1 purge story (M2/M3) uses an
|
||||
// explicit rebuild path that drops and re-creates this index
|
||||
// around the switch, so the aligned-indexes pattern is preserved
|
||||
// for partition switching at purge time.
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE UNIQUE NONCLUSTERED INDEX UX_AuditLog_EventId
|
||||
ON dbo.AuditLog (EventId)
|
||||
ON [PRIMARY];");
|
||||
|
||||
// 5) DB roles. Both definitions are idempotent so the migration is
|
||||
// safe to re-apply against a database that already has the role.
|
||||
// The DENY UPDATE / DENY DELETE on the writer role is deliberate —
|
||||
// a future db_datawriter membership cannot quietly re-enable
|
||||
// mutation because DENY outranks GRANT.
|
||||
migrationBuilder.Sql(@"
|
||||
IF DATABASE_PRINCIPAL_ID('scadabridge_audit_writer') IS NULL
|
||||
EXEC sp_executesql N'CREATE ROLE scadabridge_audit_writer';
|
||||
GRANT INSERT ON dbo.AuditLog TO scadabridge_audit_writer;
|
||||
GRANT SELECT ON dbo.AuditLog TO scadabridge_audit_writer;
|
||||
DENY UPDATE ON dbo.AuditLog TO scadabridge_audit_writer;
|
||||
DENY DELETE ON dbo.AuditLog TO scadabridge_audit_writer;");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
IF DATABASE_PRINCIPAL_ID('scadabridge_audit_purger') IS NULL
|
||||
EXEC sp_executesql N'CREATE ROLE scadabridge_audit_purger';
|
||||
GRANT SELECT ON dbo.AuditLog TO scadabridge_audit_purger;
|
||||
GRANT ALTER ON SCHEMA::dbo TO scadabridge_audit_purger;");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Drop in reverse dependency order so each statement's prerequisites
|
||||
// still exist when it runs. Each DROP is guarded so a partial Up()
|
||||
// (or a re-applied Down()) cannot fail on missing objects.
|
||||
migrationBuilder.Sql(@"
|
||||
IF DATABASE_PRINCIPAL_ID('scadabridge_audit_purger') IS NOT NULL
|
||||
EXEC sp_executesql N'DROP ROLE scadabridge_audit_purger';
|
||||
IF DATABASE_PRINCIPAL_ID('scadabridge_audit_writer') IS NOT NULL
|
||||
EXEC sp_executesql N'DROP ROLE scadabridge_audit_writer';");
|
||||
|
||||
// Indexes are dropped implicitly when the table goes away, but
|
||||
// dropping them explicitly first keeps the Down() statement self-
|
||||
// describing and mirrors the Up() shape.
|
||||
migrationBuilder.Sql(@"
|
||||
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog'))
|
||||
DROP INDEX UX_AuditLog_EventId ON dbo.AuditLog;
|
||||
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_Target_Occurred' AND object_id = OBJECT_ID('dbo.AuditLog'))
|
||||
DROP INDEX IX_AuditLog_Target_Occurred ON dbo.AuditLog;
|
||||
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_Channel_Status_Occurred' AND object_id = OBJECT_ID('dbo.AuditLog'))
|
||||
DROP INDEX IX_AuditLog_Channel_Status_Occurred ON dbo.AuditLog;
|
||||
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_CorrelationId' AND object_id = OBJECT_ID('dbo.AuditLog'))
|
||||
DROP INDEX IX_AuditLog_CorrelationId ON dbo.AuditLog;
|
||||
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_Site_Occurred' AND object_id = OBJECT_ID('dbo.AuditLog'))
|
||||
DROP INDEX IX_AuditLog_Site_Occurred ON dbo.AuditLog;
|
||||
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_OccurredAtUtc' AND object_id = OBJECT_ID('dbo.AuditLog'))
|
||||
DROP INDEX IX_AuditLog_OccurredAtUtc ON dbo.AuditLog;");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
IF OBJECT_ID('dbo.AuditLog', 'U') IS NOT NULL
|
||||
DROP TABLE dbo.AuditLog;");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
IF EXISTS (SELECT 1 FROM sys.partition_schemes WHERE name = 'ps_AuditLog_Month')
|
||||
DROP PARTITION SCHEME ps_AuditLog_Month;
|
||||
IF EXISTS (SELECT 1 FROM sys.partition_functions WHERE name = 'pf_AuditLog_Month')
|
||||
DROP PARTITION FUNCTION pf_AuditLog_Month;");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1619
File diff suppressed because it is too large
Load Diff
+56
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSiteCallsTable : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SiteCalls",
|
||||
columns: table => new
|
||||
{
|
||||
TrackedOperationId = table.Column<string>(type: "varchar(36)", unicode: false, maxLength: 36, nullable: false),
|
||||
Channel = table.Column<string>(type: "varchar(32)", unicode: false, maxLength: 32, nullable: false),
|
||||
Target = table.Column<string>(type: "varchar(256)", unicode: false, maxLength: 256, nullable: false),
|
||||
SourceSite = table.Column<string>(type: "varchar(64)", unicode: false, maxLength: 64, nullable: false),
|
||||
Status = table.Column<string>(type: "varchar(32)", unicode: false, maxLength: 32, nullable: false),
|
||||
RetryCount = table.Column<int>(type: "int", nullable: false),
|
||||
LastError = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
|
||||
HttpStatus = table.Column<int>(type: "int", nullable: true),
|
||||
CreatedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
TerminalAtUtc = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
IngestedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SiteCalls", x => x.TrackedOperationId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SiteCalls_Source_Created",
|
||||
table: "SiteCalls",
|
||||
columns: new[] { "SourceSite", "CreatedAtUtc" },
|
||||
descending: new[] { false, true });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SiteCalls_Status_Updated",
|
||||
table: "SiteCalls",
|
||||
columns: new[] { "Status", "UpdatedAtUtc" },
|
||||
descending: new[] { false, true });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "SiteCalls");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1626
File diff suppressed because it is too large
Load Diff
+57
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the universal <c>ExecutionId</c> correlation column to the centralized
|
||||
/// <c>AuditLog</c> table (#23). <c>ExecutionId</c> identifies the originating
|
||||
/// script execution / inbound request and is distinct from the per-operation
|
||||
/// <c>CorrelationId</c>.
|
||||
///
|
||||
/// The change is purely additive:
|
||||
/// 1. <c>ExecutionId uniqueidentifier NULL</c> is added with no default, so the
|
||||
/// operation is a metadata-only <c>ALTER TABLE … ADD</c> — it does NOT
|
||||
/// rewrite the monthly-partitioned <c>AuditLog</c> table, and historical
|
||||
/// rows stay <c>NULL</c> (no backfill).
|
||||
/// 2. <c>IX_AuditLog_Execution</c> is created via raw SQL so it lands on the
|
||||
/// <c>ps_AuditLog_Month(OccurredAtUtc)</c> partition scheme, matching every
|
||||
/// other <c>IX_AuditLog_*</c> index. Keeping it partition-aligned preserves
|
||||
/// the partition-switch purge path (see AuditLogRepository.SwitchOutPartitionAsync).
|
||||
/// </summary>
|
||||
public partial class AddAuditLogExecutionId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "ExecutionId",
|
||||
table: "AuditLog",
|
||||
type: "uniqueidentifier",
|
||||
nullable: true);
|
||||
|
||||
// Raw SQL so the index is created on the partition scheme — EF's
|
||||
// CreateIndex cannot express the ON ps_AuditLog_Month(OccurredAtUtc)
|
||||
// clause. Mirrors IX_AuditLog_CorrelationId (filtered, aligned).
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE NONCLUSTERED INDEX IX_AuditLog_Execution
|
||||
ON dbo.AuditLog (ExecutionId)
|
||||
WHERE ExecutionId IS NOT NULL
|
||||
ON ps_AuditLog_Month(OccurredAtUtc);");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"
|
||||
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_Execution' AND object_id = OBJECT_ID('dbo.AuditLog'))
|
||||
DROP INDEX IX_AuditLog_Execution ON dbo.AuditLog;");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ExecutionId",
|
||||
table: "AuditLog");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1629
File diff suppressed because it is too large
Load Diff
+41
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the <c>OriginExecutionId</c> correlation column to the central
|
||||
/// <c>Notifications</c> table (#21). It carries the originating script execution's
|
||||
/// <c>ExecutionId</c> from the site so the dispatcher can echo it onto the
|
||||
/// <c>NotifyDeliver</c> audit rows (#23), linking them to the site's <c>NotifySend</c>
|
||||
/// row for the same run.
|
||||
///
|
||||
/// The change is purely additive: <c>OriginExecutionId uniqueidentifier NULL</c> is
|
||||
/// added with no default, so the operation is a metadata-only <c>ALTER TABLE … ADD</c>.
|
||||
/// Unlike <c>AuditLog</c>, the <c>Notifications</c> table is NOT partitioned, so a
|
||||
/// plain <c>ADD</c> is fine. No index is created — the column is never a query
|
||||
/// predicate, only copied onto audit events. Historical rows stay <c>NULL</c>.
|
||||
/// </summary>
|
||||
public partial class AddNotificationOriginExecutionId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "OriginExecutionId",
|
||||
table: "Notifications",
|
||||
type: "uniqueidentifier",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "OriginExecutionId",
|
||||
table: "Notifications");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1636
File diff suppressed because it is too large
Load Diff
+59
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the <c>ParentExecutionId</c> correlation column to the centralized
|
||||
/// <c>AuditLog</c> table (#23). <c>ParentExecutionId</c> carries the
|
||||
/// <c>ExecutionId</c> of the execution that spawned this run, letting a
|
||||
/// spawned execution point back at its spawner — a sibling to the universal
|
||||
/// per-run <c>ExecutionId</c>.
|
||||
///
|
||||
/// The change is purely additive:
|
||||
/// 1. <c>ParentExecutionId uniqueidentifier NULL</c> is added with no default,
|
||||
/// so the operation is a metadata-only <c>ALTER TABLE … ADD</c> — it does
|
||||
/// NOT rewrite the monthly-partitioned <c>AuditLog</c> table, and
|
||||
/// historical rows stay <c>NULL</c> (no backfill).
|
||||
/// 2. <c>IX_AuditLog_ParentExecution</c> is created via raw SQL so it lands on
|
||||
/// the <c>ps_AuditLog_Month(OccurredAtUtc)</c> partition scheme, matching
|
||||
/// every other <c>IX_AuditLog_*</c> index. Keeping it partition-aligned
|
||||
/// preserves the partition-switch purge path (see
|
||||
/// AuditLogRepository.SwitchOutPartitionAsync).
|
||||
/// </summary>
|
||||
public partial class AddAuditLogParentExecutionId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "ParentExecutionId",
|
||||
table: "AuditLog",
|
||||
type: "uniqueidentifier",
|
||||
nullable: true);
|
||||
|
||||
// Raw SQL so the index is created on the partition scheme — EF's
|
||||
// CreateIndex cannot express the ON ps_AuditLog_Month(OccurredAtUtc)
|
||||
// clause. Mirrors IX_AuditLog_Execution (filtered, aligned).
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE NONCLUSTERED INDEX IX_AuditLog_ParentExecution
|
||||
ON dbo.AuditLog (ParentExecutionId)
|
||||
WHERE ParentExecutionId IS NOT NULL
|
||||
ON ps_AuditLog_Month(OccurredAtUtc);");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"
|
||||
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_ParentExecution' AND object_id = OBJECT_ID('dbo.AuditLog'))
|
||||
DROP INDEX IX_AuditLog_ParentExecution ON dbo.AuditLog;");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ParentExecutionId",
|
||||
table: "AuditLog");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1639
File diff suppressed because it is too large
Load Diff
+42
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the <c>OriginParentExecutionId</c> correlation column to the central
|
||||
/// <c>Notifications</c> table (#21). It carries the originating routed script
|
||||
/// execution's <c>ParentExecutionId</c> from the site so the dispatcher can echo it
|
||||
/// onto the <c>NotifyDeliver</c> audit rows (#23), linking them to the routed run's
|
||||
/// parent. Sibling of <c>OriginExecutionId</c>.
|
||||
///
|
||||
/// The change is purely additive: <c>OriginParentExecutionId uniqueidentifier NULL</c>
|
||||
/// is added with no default, so the operation is a metadata-only
|
||||
/// <c>ALTER TABLE … ADD</c>. Unlike <c>AuditLog</c>, the <c>Notifications</c> table is
|
||||
/// NOT partitioned, so a plain <c>ADD</c> is fine. No index is created — the column is
|
||||
/// never a query predicate, only copied onto audit events. Historical rows stay
|
||||
/// <c>NULL</c>.
|
||||
/// </summary>
|
||||
public partial class AddNotificationOriginParentExecutionId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "OriginParentExecutionId",
|
||||
table: "Notifications",
|
||||
type: "uniqueidentifier",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "OriginParentExecutionId",
|
||||
table: "Notifications");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1647
File diff suppressed because it is too large
Load Diff
+60
@@ -0,0 +1,60 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the <c>SourceNode</c> column to the centralized <c>AuditLog</c> table (#23,
|
||||
/// SourceNode-stamping). <c>SourceNode</c> identifies the cluster node that produced the
|
||||
/// audit row (e.g. <c>node-a</c>, <c>central-a</c>) — ASCII-only, so <c>varchar(64)</c>
|
||||
/// not <c>nvarchar</c>. <c>NULL</c> is valid (reconciled rows from a retired node,
|
||||
/// central direct-write rows pre-this-feature).
|
||||
///
|
||||
/// The change is purely additive:
|
||||
/// 1. <c>SourceNode varchar(64) NULL</c> is added with no default, so the operation
|
||||
/// is a metadata-only <c>ALTER TABLE … ADD</c> — it does NOT rewrite the
|
||||
/// monthly-partitioned <c>AuditLog</c> table, and historical rows stay <c>NULL</c>.
|
||||
/// 2. <c>IX_AuditLog_Node_Occurred (SourceNode, OccurredAtUtc)</c> is created via raw
|
||||
/// SQL so it lands on the <c>ps_AuditLog_Month(OccurredAtUtc)</c> partition scheme,
|
||||
/// matching every other <c>IX_AuditLog_*</c> index. Keeping it partition-aligned
|
||||
/// preserves the partition-switch purge path (see
|
||||
/// AuditLogRepository.SwitchOutPartitionAsync).
|
||||
/// </summary>
|
||||
public partial class AddAuditLogSourceNode : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SourceNode",
|
||||
table: "AuditLog",
|
||||
type: "varchar(64)",
|
||||
unicode: false,
|
||||
maxLength: 64,
|
||||
nullable: true);
|
||||
|
||||
// Raw SQL so the index is created on the partition scheme — EF's
|
||||
// CreateIndex cannot express the ON ps_AuditLog_Month(OccurredAtUtc)
|
||||
// clause. Mirrors IX_AuditLog_ParentExecution (aligned, unfiltered here:
|
||||
// NULL SourceNode is a legitimate query target, e.g. "rows produced
|
||||
// before stamping shipped" — no HasFilter on this index).
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE NONCLUSTERED INDEX IX_AuditLog_Node_Occurred
|
||||
ON dbo.AuditLog (SourceNode, OccurredAtUtc)
|
||||
ON ps_AuditLog_Month(OccurredAtUtc);");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"
|
||||
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_Node_Occurred' AND object_id = OBJECT_ID('dbo.AuditLog'))
|
||||
DROP INDEX IX_AuditLog_Node_Occurred ON dbo.AuditLog;");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SourceNode",
|
||||
table: "AuditLog");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1652
File diff suppressed because it is too large
Load Diff
+42
@@ -0,0 +1,42 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the <c>SourceNode</c> column to the central <c>Notifications</c> table (#21,
|
||||
/// SourceNode-stamping). <c>SourceNode</c> identifies the cluster node that produced the
|
||||
/// notification (e.g. <c>node-a</c>, <c>central-a</c>) — ASCII-only, so <c>varchar(64)</c>
|
||||
/// not <c>nvarchar</c>. <c>NULL</c> is valid for rows that pre-date this feature.
|
||||
///
|
||||
/// The change is purely additive: <c>SourceNode varchar(64) NULL</c> is added with no
|
||||
/// default, so the operation is a metadata-only <c>ALTER TABLE … ADD</c>. Unlike
|
||||
/// <c>AuditLog</c>, the <c>Notifications</c> table is NOT partitioned, so a plain
|
||||
/// <c>ADD</c> is fine. No index — Notification Outbox KPIs are per-site, not per-node,
|
||||
/// on this table; <c>SourceNode</c> is only echoed onto <c>NotifyDeliver</c> audit rows
|
||||
/// (#23) for cross-row correlation. Historical rows stay <c>NULL</c>.
|
||||
/// </summary>
|
||||
public partial class AddNotificationSourceNode : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SourceNode",
|
||||
table: "Notifications",
|
||||
type: "varchar(64)",
|
||||
unicode: false,
|
||||
maxLength: 64,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SourceNode",
|
||||
table: "Notifications");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1657
File diff suppressed because it is too large
Load Diff
+43
@@ -0,0 +1,43 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the <c>SourceNode</c> column to the central <c>SiteCalls</c> table (#22,
|
||||
/// SourceNode-stamping). <c>SourceNode</c> identifies the cluster node that produced the
|
||||
/// row (e.g. <c>node-a</c>, <c>central-a</c>) — ASCII-only, so <c>varchar(64)</c> not
|
||||
/// <c>nvarchar</c>. <c>NULL</c> is valid for rows that pre-date this feature and for
|
||||
/// reconciled rows from a retired node.
|
||||
///
|
||||
/// The change is purely additive: <c>SourceNode varchar(64) NULL</c> is added with no
|
||||
/// default, so the operation is a metadata-only <c>ALTER TABLE … ADD</c>. The
|
||||
/// <c>SiteCalls</c> table is NOT partitioned (operational state, not audit), so a plain
|
||||
/// <c>ADD</c> is fine. No index — Site Call Audit KPIs are per-site, not per-node, on
|
||||
/// this table; <c>SourceNode</c> is operational metadata, never a query predicate here.
|
||||
/// Historical rows stay <c>NULL</c>.
|
||||
/// </summary>
|
||||
public partial class AddSiteCallSourceNode : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SourceNode",
|
||||
table: "SiteCalls",
|
||||
type: "varchar(64)",
|
||||
unicode: false,
|
||||
maxLength: 64,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SourceNode",
|
||||
table: "SiteCalls");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1663
File diff suppressed because it is too large
Load Diff
+38
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBundleImportIdToAuditLog : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "BundleImportId",
|
||||
table: "AuditLogEntries",
|
||||
type: "uniqueidentifier",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AuditLogEntries_BundleImportId",
|
||||
table: "AuditLogEntries",
|
||||
column: "BundleImportId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_AuditLogEntries_BundleImportId",
|
||||
table: "AuditLogEntries");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BundleImportId",
|
||||
table: "AuditLogEntries");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1660
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,704 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core implementation of <see cref="IAuditLogRepository"/>. See the interface
|
||||
/// for the append-only contract; this class only adds notes on the data-access
|
||||
/// strategy used by each method.
|
||||
/// </summary>
|
||||
public class AuditLogRepository : IAuditLogRepository
|
||||
{
|
||||
// SQL Server error numbers for duplicate-key violations on
|
||||
// UX_AuditLog_EventId. 2601 is a unique-index violation; 2627 is a
|
||||
// primary-key/unique-constraint violation. The IF NOT EXISTS … INSERT
|
||||
// pattern has a check-then-act race window — two sessions can both pass
|
||||
// the EXISTS check and then both attempt the INSERT — and the loser
|
||||
// surfaces as one of these errors. Idempotency demands we swallow them.
|
||||
private const int SqlErrorUniqueIndexViolation = 2601;
|
||||
private const int SqlErrorPrimaryKeyViolation = 2627;
|
||||
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
private readonly ILogger<AuditLogRepository> _logger;
|
||||
|
||||
/// <summary>Initializes a new instance of the AuditLogRepository class.</summary>
|
||||
/// <param name="context">The database context.</param>
|
||||
/// <param name="logger">Optional logger instance.</param>
|
||||
public AuditLogRepository(ScadaBridgeDbContext context, ILogger<AuditLogRepository>? logger = null)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_logger = logger ?? NullLogger<AuditLogRepository>.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
if (evt is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(evt));
|
||||
}
|
||||
|
||||
// Enum columns are stored as varchar(32) (HasConversion<string>()), so do
|
||||
// the conversion in C# rather than relying on parameter type inference —
|
||||
// SqlClient would otherwise bind enums as int by default.
|
||||
var channel = evt.Channel.ToString();
|
||||
var kind = evt.Kind.ToString();
|
||||
var status = evt.Status.ToString();
|
||||
var forwardState = evt.ForwardState?.ToString();
|
||||
|
||||
// FormattableString interpolation parameterises every value (no concatenation),
|
||||
// so this is safe against injection even for the string columns.
|
||||
try
|
||||
{
|
||||
await _context.Database.ExecuteSqlInterpolatedAsync(
|
||||
$@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId})
|
||||
INSERT INTO dbo.AuditLog
|
||||
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, ExecutionId, ParentExecutionId,
|
||||
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, Status,
|
||||
HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary,
|
||||
ResponseSummary, PayloadTruncated, Extra, ForwardState)
|
||||
VALUES
|
||||
({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId}, {evt.ExecutionId}, {evt.ParentExecutionId},
|
||||
{evt.SourceSiteId}, {evt.SourceNode}, {evt.SourceInstanceId}, {evt.SourceScript}, {evt.Actor}, {evt.Target}, {status},
|
||||
{evt.HttpStatus}, {evt.DurationMs}, {evt.ErrorMessage}, {evt.ErrorDetail}, {evt.RequestSummary},
|
||||
{evt.ResponseSummary}, {evt.PayloadTruncated}, {evt.Extra}, {forwardState});",
|
||||
ct);
|
||||
}
|
||||
catch (SqlException ex) when (
|
||||
ex.Number == SqlErrorUniqueIndexViolation
|
||||
|| ex.Number == SqlErrorPrimaryKeyViolation)
|
||||
{
|
||||
// Two concurrent sessions both passed the IF NOT EXISTS check and
|
||||
// both attempted the INSERT — the loser raises 2601/2627 against
|
||||
// UX_AuditLog_EventId. First-write-wins idempotency is already the
|
||||
// documented contract for this method, so the race outcome is
|
||||
// semantically a no-op. Swallow at Debug; other SqlExceptions
|
||||
// bubble.
|
||||
_logger.LogDebug(
|
||||
ex,
|
||||
"InsertIfNotExistsAsync swallowed duplicate-key violation (error {SqlErrorNumber}) for EventId {EventId}; treating as no-op.",
|
||||
ex.Number,
|
||||
evt.EventId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default)
|
||||
{
|
||||
if (filter is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(filter));
|
||||
}
|
||||
|
||||
if (paging is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(paging));
|
||||
}
|
||||
|
||||
var query = _context.Set<AuditEvent>().AsNoTracking();
|
||||
|
||||
// Multi-value dimensions: a null OR empty list means "no constraint"
|
||||
// (the { Count: > 0 } guard prevents an empty list collapsing to a
|
||||
// WHERE 1=0). A non-empty list translates to a SQL IN (…) via EF Core's
|
||||
// IReadOnlyList<T>.Contains support — server-side, no client-eval.
|
||||
if (filter.Channels is { Count: > 0 } channels)
|
||||
{
|
||||
query = query.Where(e => channels.Contains(e.Channel));
|
||||
}
|
||||
|
||||
if (filter.Kinds is { Count: > 0 } kinds)
|
||||
{
|
||||
query = query.Where(e => kinds.Contains(e.Kind));
|
||||
}
|
||||
|
||||
if (filter.Statuses is { Count: > 0 } statuses)
|
||||
{
|
||||
query = query.Where(e => statuses.Contains(e.Status));
|
||||
}
|
||||
|
||||
if (filter.SourceSiteIds is { Count: > 0 } sourceSiteIds)
|
||||
{
|
||||
query = query.Where(e => e.SourceSiteId != null && sourceSiteIds.Contains(e.SourceSiteId));
|
||||
}
|
||||
|
||||
// SourceNode filter mirrors SourceSiteIds: a non-empty list translates to
|
||||
// SQL IN (…); NULL SourceNode rows are excluded when the filter is set.
|
||||
if (filter.SourceNodes is { Count: > 0 } sourceNodes)
|
||||
{
|
||||
query = query.Where(e => e.SourceNode != null && sourceNodes.Contains(e.SourceNode));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(filter.Target))
|
||||
{
|
||||
var target = filter.Target;
|
||||
query = query.Where(e => e.Target == target);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(filter.Actor))
|
||||
{
|
||||
var actor = filter.Actor;
|
||||
query = query.Where(e => e.Actor == actor);
|
||||
}
|
||||
|
||||
if (filter.CorrelationId is { } correlationId)
|
||||
{
|
||||
query = query.Where(e => e.CorrelationId == correlationId);
|
||||
}
|
||||
|
||||
if (filter.ExecutionId is { } executionId)
|
||||
{
|
||||
query = query.Where(e => e.ExecutionId == executionId);
|
||||
}
|
||||
|
||||
if (filter.ParentExecutionId is { } parentExecutionId)
|
||||
{
|
||||
query = query.Where(e => e.ParentExecutionId == parentExecutionId);
|
||||
}
|
||||
|
||||
if (filter.FromUtc is { } fromUtc)
|
||||
{
|
||||
query = query.Where(e => e.OccurredAtUtc >= fromUtc);
|
||||
}
|
||||
|
||||
if (filter.ToUtc is { } toUtc)
|
||||
{
|
||||
query = query.Where(e => e.OccurredAtUtc <= toUtc);
|
||||
}
|
||||
|
||||
// Keyset cursor on (OccurredAtUtc desc, EventId desc).
|
||||
if (paging.AfterOccurredAtUtc is { } afterOccurred && paging.AfterEventId is { } afterEventId)
|
||||
{
|
||||
query = query.Where(e =>
|
||||
e.OccurredAtUtc < afterOccurred
|
||||
|| (e.OccurredAtUtc == afterOccurred && e.EventId.CompareTo(afterEventId) < 0));
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderByDescending(e => e.OccurredAtUtc)
|
||||
.ThenByDescending(e => e.EventId)
|
||||
.Take(paging.PageSize)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default)
|
||||
{
|
||||
// GUID-suffixed staging name: prevents collision with any concurrent
|
||||
// purge attempt and avoids polluting the AuditLog object namespace with
|
||||
// a predictable identifier.
|
||||
var stagingTableName = $"AuditLog_Staging_{Guid.NewGuid():N}";
|
||||
|
||||
// ISO 8601 in UTC — SQL Server's datetime2 literal parser accepts this
|
||||
// unambiguously and the value is round-trip-safe across SET DATEFORMAT
|
||||
// settings. CD-021: use datetime2(7) precision (.fffffff) so a future
|
||||
// non-midnight or sub-second boundary doesn't silently round to the
|
||||
// wrong partition (today the migration only seeds at T00:00:00 exactly,
|
||||
// but the format string is on the boundary value's own contract — match
|
||||
// it to the column precision rather than to the current seed pattern).
|
||||
var monthBoundaryStr = monthBoundary.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff");
|
||||
|
||||
// Two-statement batch: the first SELECT samples the per-partition row
|
||||
// count BEFORE the dance so we can report it back to the purge actor;
|
||||
// the second batch performs the drop-and-rebuild. We use OUTPUT-style
|
||||
// variables wired through @@ROWCOUNT after the SWITCH is not viable
|
||||
// because SWITCH is a metadata-only operation that doesn't move rows in
|
||||
// a way @@ROWCOUNT can observe.
|
||||
var sampleSql = $@"
|
||||
SELECT COUNT_BIG(*) FROM dbo.AuditLog
|
||||
WHERE $PARTITION.pf_AuditLog_Month(OccurredAtUtc) =
|
||||
$partition.pf_AuditLog_Month('{monthBoundaryStr}');";
|
||||
|
||||
var sql = $@"
|
||||
BEGIN TRY
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
-- 1. Drop the non-aligned unique index. ALTER TABLE SWITCH refuses
|
||||
-- to run while it exists.
|
||||
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog'))
|
||||
DROP INDEX UX_AuditLog_EventId ON dbo.AuditLog;
|
||||
|
||||
-- 2. Staging table on [PRIMARY] (non-partitioned) with column shapes
|
||||
-- byte-identical to dbo.AuditLog. Any drift here causes SWITCH to
|
||||
-- reject the operation with msg 4904/4915.
|
||||
CREATE TABLE dbo.[{stagingTableName}] (
|
||||
EventId uniqueidentifier NOT NULL,
|
||||
OccurredAtUtc datetime2(7) NOT NULL,
|
||||
IngestedAtUtc datetime2(7) NULL,
|
||||
Channel varchar(32) NOT NULL,
|
||||
Kind varchar(32) NOT NULL,
|
||||
CorrelationId uniqueidentifier NULL,
|
||||
SourceSiteId varchar(64) NULL,
|
||||
SourceInstanceId varchar(128) NULL,
|
||||
SourceScript varchar(128) NULL,
|
||||
Actor varchar(128) NULL,
|
||||
Target varchar(256) NULL,
|
||||
Status varchar(32) NOT NULL,
|
||||
HttpStatus int NULL,
|
||||
DurationMs int NULL,
|
||||
ErrorMessage nvarchar(1024) NULL,
|
||||
ErrorDetail nvarchar(max) NULL,
|
||||
RequestSummary nvarchar(max) NULL,
|
||||
ResponseSummary nvarchar(max) NULL,
|
||||
PayloadTruncated bit NOT NULL,
|
||||
Extra nvarchar(max) NULL,
|
||||
ForwardState varchar(32) NULL,
|
||||
-- ExecutionId, ParentExecutionId, and SourceNode are last (in this
|
||||
-- ordinal order) because each was added to the live AuditLog table
|
||||
-- by a later ALTER TABLE ADD migration; the staging table must
|
||||
-- match the live table column shape ordinal-for-ordinal or
|
||||
-- ALTER TABLE ... SWITCH PARTITION fails (msg 4904/4915).
|
||||
ExecutionId uniqueidentifier NULL,
|
||||
ParentExecutionId uniqueidentifier NULL,
|
||||
SourceNode varchar(64) NULL,
|
||||
CONSTRAINT PK_{stagingTableName} PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc)
|
||||
) ON [PRIMARY];
|
||||
|
||||
-- 3. Switch the partition out. $partition.pf_AuditLog_Month returns
|
||||
-- the partition number that contains the supplied boundary value;
|
||||
-- SWITCH PARTITION N moves that partition's pages to the staging
|
||||
-- table (metadata-only, no row copying).
|
||||
DECLARE @partitionNumber int = $partition.pf_AuditLog_Month('{monthBoundaryStr}');
|
||||
DECLARE @sql nvarchar(max) = 'ALTER TABLE dbo.AuditLog SWITCH PARTITION ' + CAST(@partitionNumber AS nvarchar(10)) + ' TO dbo.[{stagingTableName}];';
|
||||
EXEC sp_executesql @sql;
|
||||
|
||||
-- 4. Drop staging — the rows are discarded here. This is the purge.
|
||||
DROP TABLE dbo.[{stagingTableName}];
|
||||
|
||||
-- 5. Rebuild the non-aligned unique index. Live traffic that hit the
|
||||
-- table during steps 1-4 saw composite-PK uniqueness only; from
|
||||
-- here on, single-column EventId uniqueness is restored.
|
||||
CREATE UNIQUE NONCLUSTERED INDEX UX_AuditLog_EventId ON dbo.AuditLog (EventId) ON [PRIMARY];
|
||||
|
||||
COMMIT TRANSACTION;
|
||||
END TRY
|
||||
BEGIN CATCH
|
||||
IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;
|
||||
|
||||
-- Best-effort staging cleanup. The DROP INDEX in step 1 is now
|
||||
-- rolled back (so the index is back), but the staging table from
|
||||
-- step 2 may or may not survive the rollback depending on the
|
||||
-- failure point. Guard the DROP so a missing staging table doesn't
|
||||
-- mask the original error.
|
||||
IF OBJECT_ID('dbo.[{stagingTableName}]', 'U') IS NOT NULL DROP TABLE dbo.[{stagingTableName}];
|
||||
|
||||
-- Idempotent index rebuild — covers the niche case where ROLLBACK
|
||||
-- failed to restore UX_AuditLog_EventId (or the failure happened
|
||||
-- AFTER the COMMIT, which shouldn't be possible inside this TRY
|
||||
-- but is cheap insurance). Without this, a failed switch could
|
||||
-- leave the live table without its idempotency-supporting index.
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog'))
|
||||
CREATE UNIQUE NONCLUSTERED INDEX UX_AuditLog_EventId ON dbo.AuditLog (EventId) ON [PRIMARY];
|
||||
|
||||
-- Surface the original error to the caller — the purge actor logs
|
||||
-- and continues with the next boundary.
|
||||
THROW;
|
||||
END CATCH;";
|
||||
|
||||
// Sample the row count before the switch. The sample is best-effort
|
||||
// (no transaction wrapping the sample-then-switch pair) because the
|
||||
// central singleton is the only writer to this RPC and a daily-purge
|
||||
// tick doesn't compete with concurrent SwitchOut callers. A
|
||||
// concurrent INSERT racing the sample under-reports by at most a
|
||||
// few rows, which is acceptable for an "approximate" purged-row
|
||||
// count surfaced via AuditLogPurgedEvent.
|
||||
long rowsDeleted = 0;
|
||||
var conn = _context.Database.GetDbConnection();
|
||||
var openedHere = false;
|
||||
if (conn.State != System.Data.ConnectionState.Open)
|
||||
{
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
openedHere = true;
|
||||
}
|
||||
try
|
||||
{
|
||||
await using (var sampleCmd = conn.CreateCommand())
|
||||
{
|
||||
sampleCmd.CommandText = sampleSql;
|
||||
var sampleResult = await sampleCmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||
if (sampleResult is not null && sampleResult is not DBNull)
|
||||
{
|
||||
rowsDeleted = Convert.ToInt64(sampleResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (openedHere)
|
||||
{
|
||||
await conn.CloseAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
await _context.Database.ExecuteSqlRawAsync(sql, ct);
|
||||
return rowsDeleted;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var thresholdUtc = threshold.ToUniversalTime();
|
||||
var thresholdStr = thresholdUtc.ToString("yyyy-MM-dd HH:mm:ss.fffffff");
|
||||
|
||||
// Per-partition MAX over the live table. We materialise the boundary
|
||||
// list first (24 rows) then LEFT JOIN to the MAX aggregate so empty
|
||||
// partitions surface as NULL and get filtered out by the WHERE clause.
|
||||
var sql = $@"
|
||||
WITH Boundaries AS (
|
||||
SELECT CAST(rv.value AS datetime2(7)) AS BoundaryValue,
|
||||
rv.boundary_id AS BoundaryId
|
||||
FROM sys.partition_range_values rv
|
||||
INNER JOIN sys.partition_functions pf ON rv.function_id = pf.function_id
|
||||
WHERE pf.name = 'pf_AuditLog_Month'
|
||||
)
|
||||
SELECT b.BoundaryValue
|
||||
FROM Boundaries b
|
||||
CROSS APPLY (
|
||||
SELECT MAX(a.OccurredAtUtc) AS MaxOccurredAt
|
||||
FROM dbo.AuditLog a
|
||||
WHERE $PARTITION.pf_AuditLog_Month(a.OccurredAtUtc) = b.BoundaryId + 1
|
||||
) x
|
||||
WHERE x.MaxOccurredAt IS NOT NULL
|
||||
AND x.MaxOccurredAt < CAST('{thresholdStr}' AS datetime2(7))
|
||||
ORDER BY b.BoundaryValue;";
|
||||
|
||||
var conn = _context.Database.GetDbConnection();
|
||||
var openedHere = false;
|
||||
if (conn.State != System.Data.ConnectionState.Open)
|
||||
{
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
openedHere = true;
|
||||
}
|
||||
|
||||
var results = new List<DateTime>();
|
||||
try
|
||||
{
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
// SQL Server's datetime2 surfaces as DateTimeKind.Unspecified
|
||||
// through ADO.NET (the column type carries no offset/kind).
|
||||
// Boundary values are stored in UTC, so re-tag the kind here —
|
||||
// matches the explicit defence in
|
||||
// AuditLogPartitionMaintenance.GetMaxBoundaryAsync and prevents
|
||||
// downstream .ToLocalTime()/.ToUniversalTime() conversions
|
||||
// from silently treating the value as local time.
|
||||
results.Add(DateTime.SpecifyKind(reader.GetDateTime(0), DateTimeKind.Utc));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (openedHere)
|
||||
{
|
||||
await conn.CloseAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||
TimeSpan window,
|
||||
DateTime? nowUtc = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var anchorUtc = (nowUtc ?? DateTime.UtcNow).ToUniversalTime();
|
||||
var thresholdUtc = anchorUtc - window;
|
||||
|
||||
// ExecuteSqlInterpolated parameterises every interpolation — the enum
|
||||
// discriminators are passed as varchar parameters that match the
|
||||
// varchar(32) Status column (HasConversion<string>()).
|
||||
var failedStr = nameof(Commons.Types.Enums.AuditStatus.Failed);
|
||||
var parkedStr = nameof(Commons.Types.Enums.AuditStatus.Parked);
|
||||
var discardedStr = nameof(Commons.Types.Enums.AuditStatus.Discarded);
|
||||
|
||||
long total = 0;
|
||||
long errors = 0;
|
||||
|
||||
var conn = _context.Database.GetDbConnection();
|
||||
var openedHere = false;
|
||||
if (conn.State != System.Data.ConnectionState.Open)
|
||||
{
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
openedHere = true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var cmd = conn.CreateCommand();
|
||||
// Named parameters keep the prepared statement cache stable across
|
||||
// calls — only the values change. COUNT_BIG returns a bigint so
|
||||
// we read into long even when the running total fits in int.
|
||||
cmd.CommandText = @"
|
||||
SELECT
|
||||
COUNT_BIG(*) AS Total,
|
||||
SUM(CASE WHEN Status IN (@failed, @parked, @discarded) THEN 1 ELSE 0 END) AS Errors
|
||||
FROM dbo.AuditLog
|
||||
WHERE OccurredAtUtc >= @threshold
|
||||
AND OccurredAtUtc <= @anchor;";
|
||||
|
||||
var pThreshold = cmd.CreateParameter();
|
||||
pThreshold.ParameterName = "@threshold";
|
||||
pThreshold.Value = thresholdUtc;
|
||||
cmd.Parameters.Add(pThreshold);
|
||||
|
||||
var pAnchor = cmd.CreateParameter();
|
||||
pAnchor.ParameterName = "@anchor";
|
||||
pAnchor.Value = anchorUtc;
|
||||
cmd.Parameters.Add(pAnchor);
|
||||
|
||||
var pFailed = cmd.CreateParameter();
|
||||
pFailed.ParameterName = "@failed";
|
||||
pFailed.Value = failedStr;
|
||||
cmd.Parameters.Add(pFailed);
|
||||
|
||||
var pParked = cmd.CreateParameter();
|
||||
pParked.ParameterName = "@parked";
|
||||
pParked.Value = parkedStr;
|
||||
cmd.Parameters.Add(pParked);
|
||||
|
||||
var pDiscarded = cmd.CreateParameter();
|
||||
pDiscarded.ParameterName = "@discarded";
|
||||
pDiscarded.Value = discardedStr;
|
||||
cmd.Parameters.Add(pDiscarded);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
if (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
// SUM over an empty set is NULL; COUNT_BIG over an empty set is 0.
|
||||
total = reader.IsDBNull(0) ? 0L : reader.GetInt64(0);
|
||||
errors = reader.IsDBNull(1) ? 0L : Convert.ToInt64(reader.GetValue(1));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (openedHere)
|
||||
{
|
||||
await conn.CloseAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return new AuditLogKpiSnapshot(
|
||||
TotalEventsLastHour: total,
|
||||
ErrorEventsLastHour: errors,
|
||||
BacklogTotal: 0L,
|
||||
AsOfUtc: anchorUtc);
|
||||
}
|
||||
|
||||
// Hard ceiling on chain depth for both the upward walk and the downward
|
||||
// recursive CTE. The ParentExecutionId graph is a tree (acyclic by
|
||||
// construction — each execution is minted fresh, its parent always
|
||||
// pre-exists), so this is purely a guard against corrupt/pathological data:
|
||||
// a cycle must surface as a bounded error rather than hang the server.
|
||||
// Chains are shallow (1-2 levels typical) so the guard is never reached in
|
||||
// practice.
|
||||
private const int ExecutionChainMaxDepth = 32;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||
Guid executionId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var conn = _context.Database.GetDbConnection();
|
||||
var openedHere = false;
|
||||
if (conn.State != System.Data.ConnectionState.Open)
|
||||
{
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
openedHere = true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// --- Phase 1: walk up to the root ---------------------------------
|
||||
// Climb ParentExecutionId until a node has no parent (root) or the
|
||||
// parent execution has no rows of its own (purged/stub — the climb
|
||||
// cannot continue past a row-less node). The depth cap guards
|
||||
// against a cycle in corrupt data; a tree never reaches it.
|
||||
var rootExecutionId = executionId;
|
||||
for (var depth = 0; depth < ExecutionChainMaxDepth; depth++)
|
||||
{
|
||||
Guid? parent;
|
||||
await using (var upCmd = conn.CreateCommand())
|
||||
{
|
||||
upCmd.CommandText =
|
||||
"SELECT TOP 1 ParentExecutionId FROM dbo.AuditLog " +
|
||||
"WHERE ExecutionId = @cur AND ParentExecutionId IS NOT NULL;";
|
||||
var pCur = upCmd.CreateParameter();
|
||||
pCur.ParameterName = "@cur";
|
||||
pCur.Value = rootExecutionId;
|
||||
upCmd.Parameters.Add(pCur);
|
||||
|
||||
var result = await upCmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||
parent = result is null or DBNull ? null : (Guid)result;
|
||||
}
|
||||
|
||||
if (parent is null)
|
||||
{
|
||||
// No parent row for the current node — it is the root (or a
|
||||
// row-less stub at the top of what survives). Stop climbing.
|
||||
break;
|
||||
}
|
||||
|
||||
rootExecutionId = parent.Value;
|
||||
}
|
||||
|
||||
// --- Phase 2: walk down from the root via a recursive CTE ---------
|
||||
// Edges : a non-recursive, DISTINCT (ExecutionId, ParentExecutionId)
|
||||
// edge set distilled from AuditLog. Recursing over edges
|
||||
// instead of raw rows means an execution with N audit rows
|
||||
// contributes ONE recursion path, not N — MAXRECURSION
|
||||
// bounds depth, not per-level width, so the raw-row form
|
||||
// could fan out badly. One edge per execution because all
|
||||
// rows of an execution share a single ParentExecutionId
|
||||
// (see the MIN(...) note on the final projection).
|
||||
// Chain : seeded at the root edge, recursively joins each edge whose
|
||||
// ParentExecutionId is an ExecutionId already in the chain.
|
||||
// Each edge carries its own ParentExecutionId, so the chain
|
||||
// of edges already surfaces every execution id in the tree
|
||||
// — including a row-less stub parent, which appears as the
|
||||
// ParentExecutionId of its child's edge. No separate
|
||||
// union-back CTE is needed.
|
||||
// Final : collect every distinct execution id reachable from the
|
||||
// chain (each edge's ExecutionId plus its non-null
|
||||
// ParentExecutionId), LEFT JOIN back to AuditLog and
|
||||
// GROUP BY so a stub parent — which owns no edge of its own
|
||||
// because it emitted no rows — still surfaces as a node with
|
||||
// RowCount 0 and NULL aggregates.
|
||||
var nodes = new List<ExecutionTreeNode>();
|
||||
await using (var downCmd = conn.CreateCommand())
|
||||
{
|
||||
downCmd.CommandText = $@"
|
||||
WITH Edges AS (
|
||||
SELECT DISTINCT ExecutionId, ParentExecutionId
|
||||
FROM dbo.AuditLog
|
||||
WHERE ExecutionId IS NOT NULL
|
||||
),
|
||||
Chain AS (
|
||||
-- Anchor: the root execution id, seeded as a literal so
|
||||
-- it is present even when the root is a row-less stub
|
||||
-- (a purged/no-action parent owns no edge of its own).
|
||||
-- The root is parentless by construction — the upward
|
||||
-- walk stopped there — so its ParentExecutionId is NULL.
|
||||
SELECT CAST(@root AS uniqueidentifier) AS ExecutionId,
|
||||
CAST(NULL AS uniqueidentifier) AS ParentExecutionId
|
||||
UNION ALL
|
||||
SELECT e.ExecutionId, e.ParentExecutionId
|
||||
FROM Edges e
|
||||
INNER JOIN Chain c ON e.ParentExecutionId = c.ExecutionId
|
||||
),
|
||||
ChainIds AS (
|
||||
SELECT ExecutionId FROM Chain
|
||||
UNION
|
||||
SELECT ParentExecutionId FROM Chain
|
||||
WHERE ParentExecutionId IS NOT NULL
|
||||
)
|
||||
-- ParentExecutionId / SourceSiteId / SourceInstanceId are
|
||||
-- derived via MIN: every audit row of one execution carries
|
||||
-- the SAME ParentExecutionId (and source identity) — it is
|
||||
-- stamped once per script run / inbound request — so MIN
|
||||
-- simply picks that one shared value, it is not collapsing a
|
||||
-- genuine disagreement across rows.
|
||||
SELECT
|
||||
ids.ExecutionId AS [ExecutionId],
|
||||
MIN(a.ParentExecutionId) AS [ParentExecutionId],
|
||||
COUNT(a.EventId) AS [RowCount],
|
||||
(SELECT STRING_AGG(d.Channel, ',')
|
||||
FROM (SELECT DISTINCT a2.Channel FROM dbo.AuditLog a2
|
||||
WHERE a2.ExecutionId = ids.ExecutionId) d) AS [Channels],
|
||||
(SELECT STRING_AGG(d.Status, ',')
|
||||
FROM (SELECT DISTINCT a2.Status FROM dbo.AuditLog a2
|
||||
WHERE a2.ExecutionId = ids.ExecutionId) d) AS [Statuses],
|
||||
MIN(a.SourceSiteId) AS [SourceSiteId],
|
||||
MIN(a.SourceInstanceId) AS [SourceInstanceId],
|
||||
MIN(a.OccurredAtUtc) AS [FirstOccurredAtUtc],
|
||||
MAX(a.OccurredAtUtc) AS [LastOccurredAtUtc]
|
||||
FROM ChainIds ids
|
||||
LEFT JOIN dbo.AuditLog a ON a.ExecutionId = ids.ExecutionId
|
||||
GROUP BY ids.ExecutionId
|
||||
OPTION (MAXRECURSION {ExecutionChainMaxDepth});";
|
||||
|
||||
var pRoot = downCmd.CreateParameter();
|
||||
pRoot.ParameterName = "@root";
|
||||
pRoot.Value = rootExecutionId;
|
||||
downCmd.Parameters.Add(pRoot);
|
||||
|
||||
await using var reader = await downCmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
var nodeExecutionId = reader.GetGuid(0);
|
||||
Guid? parentExecutionId = reader.IsDBNull(1) ? null : reader.GetGuid(1);
|
||||
var rowCount = reader.GetInt32(2);
|
||||
var channels = SplitAggregate(reader.IsDBNull(3) ? null : reader.GetString(3));
|
||||
var statuses = SplitAggregate(reader.IsDBNull(4) ? null : reader.GetString(4));
|
||||
var sourceSiteId = reader.IsDBNull(5) ? null : reader.GetString(5);
|
||||
var sourceInstanceId = reader.IsDBNull(6) ? null : reader.GetString(6);
|
||||
DateTime? firstOccurred = reader.IsDBNull(7) ? null : reader.GetDateTime(7);
|
||||
DateTime? lastOccurred = reader.IsDBNull(8) ? null : reader.GetDateTime(8);
|
||||
|
||||
nodes.Add(new ExecutionTreeNode(
|
||||
ExecutionId: nodeExecutionId,
|
||||
ParentExecutionId: parentExecutionId,
|
||||
RowCount: rowCount,
|
||||
Channels: channels,
|
||||
Statuses: statuses,
|
||||
SourceSiteId: sourceSiteId,
|
||||
SourceInstanceId: sourceInstanceId,
|
||||
FirstOccurredAtUtc: firstOccurred,
|
||||
LastOccurredAtUtc: lastOccurred));
|
||||
}
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (openedHere)
|
||||
{
|
||||
await conn.CloseAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default)
|
||||
{
|
||||
return await _context.Set<AuditEvent>()
|
||||
.AsNoTracking()
|
||||
.Where(e => e.SourceNode != null)
|
||||
.Select(e => e.SourceNode!)
|
||||
.Distinct()
|
||||
.OrderBy(n => n)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a <c>STRING_AGG</c> comma-joined value into a distinct, ordered
|
||||
/// list. A null/empty aggregate (a stub node with no rows) yields an empty
|
||||
/// list rather than a single empty string.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<string> SplitAggregate(string? aggregate)
|
||||
{
|
||||
if (string.IsNullOrEmpty(aggregate))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return aggregate
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(v => v, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
|
||||
public class CentralUiRepository : ICentralUiRepository
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CentralUiRepository"/> class.
|
||||
/// </summary>
|
||||
/// <param name="context">The EF Core database context.</param>
|
||||
public CentralUiRepository(ScadaBridgeDbContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Site>> GetAllSitesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Sites
|
||||
.AsNoTracking()
|
||||
.OrderBy(s => s.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.DataConnections
|
||||
.AsNoTracking()
|
||||
.Where(d => d.SiteId == siteId)
|
||||
.OrderBy(d => d.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DataConnection>> GetAllDataConnectionsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.DataConnections
|
||||
.AsNoTracking()
|
||||
.OrderBy(d => d.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Template>> GetTemplateTreeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Templates
|
||||
.AsNoTracking()
|
||||
.Include(t => t.Attributes)
|
||||
.Include(t => t.Alarms)
|
||||
.Include(t => t.Scripts)
|
||||
.Include(t => t.Compositions)
|
||||
.AsSplitQuery()
|
||||
.OrderBy(t => t.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Instance>> GetInstancesFilteredAsync(
|
||||
int? siteId = null,
|
||||
int? templateId = null,
|
||||
string? searchTerm = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _context.Instances.AsNoTracking().AsQueryable();
|
||||
|
||||
if (siteId.HasValue)
|
||||
query = query.Where(i => i.SiteId == siteId.Value);
|
||||
|
||||
if (templateId.HasValue)
|
||||
query = query.Where(i => i.TemplateId == templateId.Value);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
query = query.Where(i => i.UniqueName.Contains(searchTerm));
|
||||
|
||||
return await query
|
||||
.OrderBy(i => i.UniqueName)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DeploymentRecord>> GetRecentDeploymentsAsync(int count, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.DeploymentRecords
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(d => d.DeployedAt)
|
||||
.Take(count)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Area>> GetAreaTreeBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Areas
|
||||
.AsNoTracking()
|
||||
.Where(a => a.SiteId == siteId)
|
||||
.Include(a => a.Children)
|
||||
.OrderBy(a => a.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(IReadOnlyList<AuditLogEntry> Entries, int TotalCount)> GetAuditLogEntriesAsync(
|
||||
string? user = null,
|
||||
string? entityType = null,
|
||||
string? action = null,
|
||||
DateTimeOffset? from = null,
|
||||
DateTimeOffset? to = null,
|
||||
string? entityId = null,
|
||||
string? entityName = null,
|
||||
Guid? bundleImportId = null,
|
||||
int page = 1,
|
||||
int pageSize = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _context.AuditLogEntries.AsNoTracking().AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(user))
|
||||
query = query.Where(a => a.User == user);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entityType))
|
||||
query = query.Where(a => a.EntityType == entityType);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(action))
|
||||
query = query.Where(a => a.Action == action);
|
||||
|
||||
if (from.HasValue)
|
||||
query = query.Where(a => a.Timestamp >= from.Value);
|
||||
|
||||
if (to.HasValue)
|
||||
query = query.Where(a => a.Timestamp <= to.Value);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entityId))
|
||||
query = query.Where(a => a.EntityId == entityId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entityName))
|
||||
query = query.Where(a => a.EntityName.Contains(entityName));
|
||||
|
||||
if (bundleImportId is Guid bundleId)
|
||||
query = query.Where(a => a.BundleImportId == bundleId);
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
var entries = await query
|
||||
.OrderByDescending(a => a.Timestamp)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (entries, totalCount);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core implementation of <see cref="IDeploymentManagerRepository"/> covering
|
||||
/// the deployment pipeline's persistence surface: <c>DeploymentRecord</c> CRUD
|
||||
/// (with optimistic concurrency via <c>DeploymentRecord.RowVersion</c>),
|
||||
/// <c>SystemArtifactDeploymentRecord</c> CRUD, <c>DeployedConfigSnapshot</c> CRUD,
|
||||
/// and a Restrict-FK-aware <see cref="DeleteInstanceAsync"/> that explicitly
|
||||
/// clears dependent deployment-record rows before removing an instance.
|
||||
/// </summary>
|
||||
public class DeploymentManagerRepository : IDeploymentManagerRepository
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _dbContext;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the DeploymentManagerRepository class.
|
||||
/// </summary>
|
||||
/// <param name="dbContext">The database context for accessing deployment data.</param>
|
||||
public DeploymentManagerRepository(ScadaBridgeDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||
}
|
||||
|
||||
// --- DeploymentRecord ---
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DeploymentRecord?> GetDeploymentRecordByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.DeploymentRecords.FindAsync([id], cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DeploymentRecord>> GetAllDeploymentRecordsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.DeploymentRecords
|
||||
.OrderByDescending(d => d.DeployedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DeploymentRecord>> GetDeploymentsByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.DeploymentRecords
|
||||
.Where(d => d.InstanceId == instanceId)
|
||||
.OrderByDescending(d => d.DeployedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DeploymentRecord?> GetCurrentDeploymentStatusAsync(int instanceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.DeploymentRecords
|
||||
.Where(d => d.InstanceId == instanceId)
|
||||
.OrderByDescending(d => d.DeployedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DeploymentRecord?> GetDeploymentByDeploymentIdAsync(string deploymentId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.DeploymentRecords
|
||||
.FirstOrDefaultAsync(d => d.DeploymentId == deploymentId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddDeploymentRecordAsync(DeploymentRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _dbContext.DeploymentRecords.AddAsync(record, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateDeploymentRecordAsync(DeploymentRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_dbContext.DeploymentRecords.Update(record);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteDeploymentRecordAsync(int id, byte[] expectedRowVersion, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(expectedRowVersion);
|
||||
|
||||
// CD-017: DeploymentRecord carries a SQL Server rowversion concurrency token.
|
||||
// The stub-attach delete path must seed EF's OriginalValues["RowVersion"] with
|
||||
// the caller's last-observed value so the generated SQL becomes
|
||||
// `DELETE ... WHERE Id = @id AND RowVersion = @prior`. Without this seeding a
|
||||
// concurrent edit is silently overwritten; with it, EF raises
|
||||
// DbUpdateConcurrencyException on SaveChangesAsync — the documented
|
||||
// optimistic-concurrency contract on deployment status records.
|
||||
var record = _dbContext.DeploymentRecords.Local.FirstOrDefault(d => d.Id == id);
|
||||
if (record != null)
|
||||
{
|
||||
var entry = _dbContext.Entry(record);
|
||||
entry.OriginalValues["RowVersion"] = expectedRowVersion;
|
||||
_dbContext.DeploymentRecords.Remove(record);
|
||||
}
|
||||
else
|
||||
{
|
||||
var stub = new DeploymentRecord("stub", "stub") { Id = id };
|
||||
_dbContext.DeploymentRecords.Attach(stub);
|
||||
var entry = _dbContext.Entry(stub);
|
||||
entry.OriginalValues["RowVersion"] = expectedRowVersion;
|
||||
_dbContext.DeploymentRecords.Remove(stub);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// --- SystemArtifactDeploymentRecord ---
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SystemArtifactDeploymentRecord?> GetSystemArtifactDeploymentByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.SystemArtifactDeploymentRecords.FindAsync([id], cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SystemArtifactDeploymentRecord>> GetAllSystemArtifactDeploymentsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.SystemArtifactDeploymentRecords
|
||||
.OrderByDescending(d => d.DeployedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddSystemArtifactDeploymentAsync(SystemArtifactDeploymentRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _dbContext.SystemArtifactDeploymentRecords.AddAsync(record, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateSystemArtifactDeploymentAsync(SystemArtifactDeploymentRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_dbContext.SystemArtifactDeploymentRecords.Update(record);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteSystemArtifactDeploymentAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var record = _dbContext.SystemArtifactDeploymentRecords.Local.FirstOrDefault(d => d.Id == id);
|
||||
if (record != null)
|
||||
{
|
||||
_dbContext.SystemArtifactDeploymentRecords.Remove(record);
|
||||
}
|
||||
else
|
||||
{
|
||||
var stub = new SystemArtifactDeploymentRecord("stub", "stub") { Id = id };
|
||||
_dbContext.SystemArtifactDeploymentRecords.Attach(stub);
|
||||
_dbContext.SystemArtifactDeploymentRecords.Remove(stub);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// --- WP-8: DeployedConfigSnapshot ---
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DeployedConfigSnapshot?> GetDeployedSnapshotByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.Set<DeployedConfigSnapshot>()
|
||||
.FirstOrDefaultAsync(s => s.InstanceId == instanceId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddDeployedSnapshotAsync(DeployedConfigSnapshot snapshot, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _dbContext.Set<DeployedConfigSnapshot>().AddAsync(snapshot, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateDeployedSnapshotAsync(DeployedConfigSnapshot snapshot, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_dbContext.Set<DeployedConfigSnapshot>().Update(snapshot);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteDeployedSnapshotAsync(int instanceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var snapshot = await _dbContext.Set<DeployedConfigSnapshot>()
|
||||
.FirstOrDefaultAsync(s => s.InstanceId == instanceId, cancellationToken);
|
||||
if (snapshot != null)
|
||||
{
|
||||
_dbContext.Set<DeployedConfigSnapshot>().Remove(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Instance lookups for deployment pipeline ---
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Instance?> GetInstanceByIdAsync(int instanceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.Set<Instance>()
|
||||
.Include(i => i.AttributeOverrides)
|
||||
.Include(i => i.AlarmOverrides)
|
||||
.Include(i => i.ConnectionBindings)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(i => i.Id == instanceId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Instance?> GetInstanceByUniqueNameAsync(string uniqueName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.Set<Instance>()
|
||||
.Include(i => i.AttributeOverrides)
|
||||
.Include(i => i.AlarmOverrides)
|
||||
.Include(i => i.ConnectionBindings)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(i => i.UniqueName == uniqueName, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateInstanceAsync(Instance instance, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_dbContext.Set<Instance>().Update(instance);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteInstanceAsync(int instanceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// DeploymentRecords have a Restrict FK to Instance — remove them
|
||||
// explicitly first. The snapshot, overrides, and connection bindings
|
||||
// are configured with cascade delete and go with the instance.
|
||||
var records = await _dbContext.DeploymentRecords
|
||||
.Where(d => d.InstanceId == instanceId)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (records.Count > 0)
|
||||
{
|
||||
_dbContext.DeploymentRecords.RemoveRange(records);
|
||||
}
|
||||
|
||||
var instance = await _dbContext.Set<Instance>()
|
||||
.FirstOrDefaultAsync(i => i.Id == instanceId, cancellationToken);
|
||||
if (instance != null)
|
||||
{
|
||||
_dbContext.Set<Instance>().Remove(instance);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
|
||||
public class ExternalSystemRepository : IExternalSystemRepository
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the ExternalSystemRepository class.
|
||||
/// </summary>
|
||||
/// <param name="context">The database context for accessing external system data.</param>
|
||||
public ExternalSystemRepository(ScadaBridgeDbContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ExternalSystemDefinition?> GetExternalSystemByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ExternalSystemDefinition>().FindAsync(new object[] { id }, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
// ExternalSystemGateway-011: genuine name-keyed query (server-side WHERE) so the
|
||||
// gateway's hot-path resolution does not fetch every system and filter in memory.
|
||||
public async Task<ExternalSystemDefinition?> GetExternalSystemByNameAsync(string name, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ExternalSystemDefinition>()
|
||||
.FirstOrDefaultAsync(s => s.Name == name, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ExternalSystemDefinition>> GetAllExternalSystemsAsync(CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ExternalSystemDefinition>().ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddExternalSystemAsync(ExternalSystemDefinition definition, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ExternalSystemDefinition>().AddAsync(definition, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateExternalSystemAsync(ExternalSystemDefinition definition, CancellationToken cancellationToken = default)
|
||||
{ _context.Set<ExternalSystemDefinition>().Update(definition); return Task.CompletedTask; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteExternalSystemAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await GetExternalSystemByIdAsync(id, cancellationToken);
|
||||
if (entity != null) _context.Set<ExternalSystemDefinition>().Remove(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ExternalSystemMethod?> GetExternalSystemMethodByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ExternalSystemMethod>().FindAsync(new object[] { id }, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
// ExternalSystemGateway-011: genuine name-keyed query scoped to the parent system.
|
||||
public async Task<ExternalSystemMethod?> GetMethodByNameAsync(int externalSystemId, string methodName, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ExternalSystemMethod>()
|
||||
.FirstOrDefaultAsync(
|
||||
m => m.ExternalSystemDefinitionId == externalSystemId && m.Name == methodName,
|
||||
cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ExternalSystemMethod>> GetMethodsByExternalSystemIdAsync(int externalSystemId, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ExternalSystemMethod>().Where(m => m.ExternalSystemDefinitionId == externalSystemId).ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddExternalSystemMethodAsync(ExternalSystemMethod method, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ExternalSystemMethod>().AddAsync(method, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateExternalSystemMethodAsync(ExternalSystemMethod method, CancellationToken cancellationToken = default)
|
||||
{ _context.Set<ExternalSystemMethod>().Update(method); return Task.CompletedTask; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteExternalSystemMethodAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await GetExternalSystemMethodByIdAsync(id, cancellationToken);
|
||||
if (entity != null) _context.Set<ExternalSystemMethod>().Remove(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DatabaseConnectionDefinition?> GetDatabaseConnectionByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<DatabaseConnectionDefinition>().FindAsync(new object[] { id }, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
// ExternalSystemGateway-011: genuine name-keyed query (server-side WHERE).
|
||||
public async Task<DatabaseConnectionDefinition?> GetDatabaseConnectionByNameAsync(string name, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<DatabaseConnectionDefinition>()
|
||||
.FirstOrDefaultAsync(c => c.Name == name, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DatabaseConnectionDefinition>> GetAllDatabaseConnectionsAsync(CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<DatabaseConnectionDefinition>().ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddDatabaseConnectionAsync(DatabaseConnectionDefinition definition, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<DatabaseConnectionDefinition>().AddAsync(definition, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateDatabaseConnectionAsync(DatabaseConnectionDefinition definition, CancellationToken cancellationToken = default)
|
||||
{ _context.Set<DatabaseConnectionDefinition>().Update(definition); return Task.CompletedTask; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteDatabaseConnectionAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await GetDatabaseConnectionByIdAsync(id, cancellationToken);
|
||||
if (entity != null) _context.Set<DatabaseConnectionDefinition>().Remove(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
|
||||
public class InboundApiRepository : IInboundApiRepository
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
// CD-016: lazily resolved so the InboundAPI ApiKeyHasher factory (which throws
|
||||
// when no pepper is configured) is only invoked if GetApiKeyByValueAsync is
|
||||
// actually called — Central/Host startup composition roots that never call
|
||||
// this method (the production ApiKeyValidator deliberately doesn't) get to
|
||||
// bring InboundApiRepository up without forcing every test to wire a
|
||||
// throw-away pepper into InboundApiOptions.
|
||||
private readonly Func<IApiKeyHasher> _hasherAccessor;
|
||||
private readonly ILogger<InboundApiRepository> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the InboundApiRepository class.
|
||||
/// </summary>
|
||||
/// <param name="context">The database context for accessing inbound API data.</param>
|
||||
/// <param name="hasherAccessor">
|
||||
/// CD-016: factory that returns the API-key hasher used to digest a candidate
|
||||
/// plaintext for the peppered <see cref="GetApiKeyByValueAsync"/> lookup.
|
||||
/// Resolution is deferred to first call so a composition root that doesn't
|
||||
/// register <see cref="IApiKeyHasher"/> (or whose factory would throw because
|
||||
/// no pepper is configured) can still bring up the repository for callers that
|
||||
/// don't touch the value-lookup path. Defaults to a factory returning
|
||||
/// <see cref="ApiKeyHasher.Default"/>; production wires
|
||||
/// <c>sp => sp.GetRequiredService<IApiKeyHasher>()</c> via DI so the
|
||||
/// lookup uses the same peppered digest as the production write path.
|
||||
/// </param>
|
||||
/// <param name="logger">Optional logger instance for warnings and diagnostics.</param>
|
||||
public InboundApiRepository(
|
||||
ScadaBridgeDbContext context,
|
||||
Func<IApiKeyHasher>? hasherAccessor = null,
|
||||
ILogger<InboundApiRepository>? logger = null)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_hasherAccessor = hasherAccessor ?? (() => ApiKeyHasher.Default);
|
||||
_logger = logger ?? NullLogger<InboundApiRepository>.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApiKey?> GetApiKeyByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ApiKey>().FindAsync(new object[] { id }, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ApiKey>> GetAllApiKeysAsync(CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ApiKey>().ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApiKey?> GetApiKeyByValueAsync(string keyValue, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// CD-016: hash the candidate with the DI-provided peppered hasher so this
|
||||
// lookup matches keys whose stored KeyHash was produced by the production
|
||||
// ApiKeyHasher(pepper). The pre-fix call to ApiKeyHasher.Default would
|
||||
// silently return null for every real key on any peppered deployment.
|
||||
// Resolution is deferred until this method is actually called so the
|
||||
// pepper-validating factory doesn't fire during startup composition.
|
||||
var keyHash = _hasherAccessor().Hash(keyValue);
|
||||
return await _context.Set<ApiKey>().FirstOrDefaultAsync(k => k.KeyHash == keyHash, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddApiKeyAsync(ApiKey apiKey, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ApiKey>().AddAsync(apiKey, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateApiKeyAsync(ApiKey apiKey, CancellationToken cancellationToken = default)
|
||||
{ _context.Set<ApiKey>().Update(apiKey); return Task.CompletedTask; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteApiKeyAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await GetApiKeyByIdAsync(id, cancellationToken);
|
||||
if (entity != null) _context.Set<ApiKey>().Remove(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApiMethod?> GetApiMethodByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ApiMethod>().FindAsync(new object[] { id }, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ApiMethod>> GetAllApiMethodsAsync(CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ApiMethod>().ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApiMethod?> GetMethodByNameAsync(string name, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ApiMethod>().FirstOrDefaultAsync(m => m.Name == name, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ApiKey>> GetApprovedKeysForMethodAsync(int methodId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var method = await _context.Set<ApiMethod>().FindAsync(new object[] { methodId }, cancellationToken);
|
||||
if (method?.ApprovedApiKeyIds == null)
|
||||
return new List<ApiKey>();
|
||||
|
||||
// ApprovedApiKeyIds is a comma-separated string of integer ApiKey ids. A token that
|
||||
// fails to parse indicates a corrupt value: it is dropped (it cannot identify a key),
|
||||
// but the corruption is logged as a warning so it is observable rather than silent.
|
||||
// A corrupt list would otherwise quietly approve fewer keys than intended.
|
||||
var keyIds = new List<int>();
|
||||
foreach (var token in method.ApprovedApiKeyIds.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var trimmed = token.Trim();
|
||||
if (int.TryParse(trimmed, out var id) && id > 0)
|
||||
{
|
||||
keyIds.Add(id);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"ApiMethod {MethodId} has a malformed approved-API-key id token '{Token}' " +
|
||||
"in ApprovedApiKeyIds; it was dropped. The method may approve fewer keys than expected.",
|
||||
methodId, trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
return await _context.Set<ApiKey>().Where(k => keyIds.Contains(k.Id)).ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddApiMethodAsync(ApiMethod method, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ApiMethod>().AddAsync(method, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateApiMethodAsync(ApiMethod method, CancellationToken cancellationToken = default)
|
||||
{ _context.Set<ApiMethod>().Update(method); return Task.CompletedTask; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteApiMethodAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await GetApiMethodByIdAsync(id, cancellationToken);
|
||||
if (entity != null) _context.Set<ApiMethod>().Remove(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core data access for the central notification outbox. See
|
||||
/// <see cref="INotificationOutboxRepository"/> for the behaviour contract.
|
||||
/// </summary>
|
||||
public class NotificationOutboxRepository : INotificationOutboxRepository
|
||||
{
|
||||
// SQL Server duplicate-key error numbers, matching the AuditLogRepository
|
||||
// and SiteCallAuditRepository race-fixes. 2601 is a unique-index violation;
|
||||
// 2627 is a primary-key/unique-constraint violation. The IF NOT EXISTS …
|
||||
// INSERT pattern has a check-then-act race window — two sessions can both
|
||||
// pass the EXISTS check and then both attempt the INSERT — and the loser
|
||||
// surfaces as one of these. The site→central handoff is documented
|
||||
// at-least-once with insert-if-not-exists, so the collision IS the expected
|
||||
// contention mode; idempotency demands we swallow them rather than let the
|
||||
// site retry the same NotificationId forever.
|
||||
private const int SqlErrorUniqueIndexViolation = 2601;
|
||||
private const int SqlErrorPrimaryKeyViolation = 2627;
|
||||
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
private readonly ILogger<NotificationOutboxRepository> _logger;
|
||||
|
||||
// Statuses that represent a finished notification lifecycle. Non-terminal is the complement.
|
||||
private static readonly NotificationStatus[] TerminalStatuses =
|
||||
{
|
||||
NotificationStatus.Delivered,
|
||||
NotificationStatus.Parked,
|
||||
NotificationStatus.Discarded,
|
||||
};
|
||||
|
||||
/// <summary>Initializes a new instance of <see cref="NotificationOutboxRepository"/> with the given EF Core context.</summary>
|
||||
/// <param name="context">The EF Core database context.</param>
|
||||
/// <param name="logger">Optional logger instance.</param>
|
||||
public NotificationOutboxRepository(ScadaBridgeDbContext context, ILogger<NotificationOutboxRepository>? logger = null)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_logger = logger ?? NullLogger<NotificationOutboxRepository>.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> InsertIfNotExistsAsync(Notification n, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (n is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(n));
|
||||
}
|
||||
|
||||
// Enum columns are stored as varchar(32) (HasConversion<string>()); convert
|
||||
// in C# rather than relying on parameter type inference (SqlClient would
|
||||
// otherwise bind enums as int by default and break the column conversion).
|
||||
var type = n.Type.ToString();
|
||||
var status = n.Status.ToString();
|
||||
|
||||
// FormattableString interpolation parameterises every value (no concatenation),
|
||||
// so this is safe against injection even for the string columns.
|
||||
try
|
||||
{
|
||||
var rowsAffected = await _context.Database.ExecuteSqlInterpolatedAsync(
|
||||
$@"IF NOT EXISTS (SELECT 1 FROM dbo.Notifications WHERE NotificationId = {n.NotificationId})
|
||||
INSERT INTO dbo.Notifications
|
||||
(NotificationId, Type, ListName, Subject, Body, TypeData, Status, RetryCount, LastError,
|
||||
ResolvedTargets, SourceSiteId, SourceNode, SourceInstanceId, SourceScript,
|
||||
OriginExecutionId, OriginParentExecutionId,
|
||||
SiteEnqueuedAt, CreatedAt, LastAttemptAt, NextAttemptAt, DeliveredAt)
|
||||
VALUES
|
||||
({n.NotificationId}, {type}, {n.ListName}, {n.Subject}, {n.Body}, {n.TypeData}, {status}, {n.RetryCount}, {n.LastError},
|
||||
{n.ResolvedTargets}, {n.SourceSiteId}, {n.SourceNode}, {n.SourceInstanceId}, {n.SourceScript},
|
||||
{n.OriginExecutionId}, {n.OriginParentExecutionId},
|
||||
{n.SiteEnqueuedAt}, {n.CreatedAt}, {n.LastAttemptAt}, {n.NextAttemptAt}, {n.DeliveredAt});",
|
||||
cancellationToken);
|
||||
|
||||
// rowsAffected == 1 -> we inserted; 0 -> a prior row was already there
|
||||
// (IF NOT EXISTS short-circuited the INSERT).
|
||||
return rowsAffected == 1;
|
||||
}
|
||||
catch (SqlException ex) when (
|
||||
ex.Number == SqlErrorUniqueIndexViolation
|
||||
|| ex.Number == SqlErrorPrimaryKeyViolation)
|
||||
{
|
||||
// Two concurrent sessions both passed IF NOT EXISTS and both
|
||||
// attempted the INSERT — the loser raises 2601/2627 against the
|
||||
// NotificationId primary key. First-write-wins idempotency is the
|
||||
// documented contract (the site→central handoff is at-least-once,
|
||||
// and the actor discards the return value), so the race outcome is
|
||||
// semantically a no-op. Returning false here matches the
|
||||
// "row already existed" branch of the success path.
|
||||
_logger.LogDebug(
|
||||
ex,
|
||||
"InsertIfNotExistsAsync swallowed duplicate-key violation (error {SqlErrorNumber}) for NotificationId {NotificationId}; treating as no-op.",
|
||||
ex.Number,
|
||||
n.NotificationId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Notification>> GetDueAsync(
|
||||
DateTimeOffset now, int batchSize, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Notifications
|
||||
.Where(n => n.Status == NotificationStatus.Pending
|
||||
|| (n.Status == NotificationStatus.Retrying
|
||||
&& n.NextAttemptAt != null
|
||||
&& n.NextAttemptAt <= now))
|
||||
.OrderBy(n => n.CreatedAt)
|
||||
.Take(batchSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Notification?> GetByIdAsync(string notificationId, CancellationToken cancellationToken = default)
|
||||
=> await _context.Notifications.FindAsync(new object[] { notificationId }, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpdateAsync(Notification n, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.Notifications.Update(n);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(IReadOnlyList<Notification> Rows, int TotalCount)> QueryAsync(
|
||||
NotificationOutboxFilter filter, int pageNumber, int pageSize, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _context.Notifications.AsQueryable();
|
||||
|
||||
if (filter.Status is { } status)
|
||||
{
|
||||
query = query.Where(n => n.Status == status);
|
||||
}
|
||||
|
||||
if (filter.Type is { } type)
|
||||
{
|
||||
query = query.Where(n => n.Type == type);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(filter.SourceSiteId))
|
||||
{
|
||||
query = query.Where(n => n.SourceSiteId == filter.SourceSiteId);
|
||||
}
|
||||
|
||||
// Task 16: SourceNode is exact-match like SourceSiteId. Rows with NULL
|
||||
// SourceNode (legacy / unconfigured) are excluded when the filter is set.
|
||||
if (!string.IsNullOrEmpty(filter.SourceNode))
|
||||
{
|
||||
query = query.Where(n => n.SourceNode == filter.SourceNode);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(filter.ListName))
|
||||
{
|
||||
query = query.Where(n => n.ListName == filter.ListName);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(filter.SubjectKeyword))
|
||||
{
|
||||
query = query.Where(n => n.Subject.Contains(filter.SubjectKeyword));
|
||||
}
|
||||
|
||||
if (filter.StuckOnly && filter.StuckCutoff is { } stuckCutoff)
|
||||
{
|
||||
query = query.Where(n =>
|
||||
(n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying)
|
||||
&& n.CreatedAt < stuckCutoff);
|
||||
}
|
||||
|
||||
if (filter.From is { } from)
|
||||
{
|
||||
query = query.Where(n => n.CreatedAt >= from);
|
||||
}
|
||||
|
||||
if (filter.To is { } to)
|
||||
{
|
||||
query = query.Where(n => n.CreatedAt <= to);
|
||||
}
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
var rows = await query
|
||||
.OrderByDescending(n => n.CreatedAt)
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (rows, totalCount);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> DeleteTerminalOlderThanAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Notifications
|
||||
.Where(n => TerminalStatuses.Contains(n.Status) && n.CreatedAt < cutoff)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<NotificationKpiSnapshot> ComputeKpisAsync(
|
||||
DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var queueDepth = await _context.Notifications
|
||||
.CountAsync(n => n.Status == NotificationStatus.Pending
|
||||
|| n.Status == NotificationStatus.Retrying, cancellationToken);
|
||||
|
||||
var stuckCount = await _context.Notifications
|
||||
.CountAsync(n => (n.Status == NotificationStatus.Pending
|
||||
|| n.Status == NotificationStatus.Retrying)
|
||||
&& n.CreatedAt < stuckCutoff, cancellationToken);
|
||||
|
||||
var parkedCount = await _context.Notifications
|
||||
.CountAsync(n => n.Status == NotificationStatus.Parked, cancellationToken);
|
||||
|
||||
var deliveredLastInterval = await _context.Notifications
|
||||
.CountAsync(n => n.Status == NotificationStatus.Delivered
|
||||
&& n.DeliveredAt != null
|
||||
&& n.DeliveredAt >= deliveredSince, cancellationToken);
|
||||
|
||||
// Oldest non-terminal CreatedAt. The DateTimeOffset value converter makes a SQL
|
||||
// Min aggregate awkward, so order ascending and take the first instead.
|
||||
var nonTerminal = _context.Notifications
|
||||
.Where(n => n.Status == NotificationStatus.Pending
|
||||
|| n.Status == NotificationStatus.Retrying);
|
||||
|
||||
TimeSpan? oldestPendingAge = null;
|
||||
if (await nonTerminal.AnyAsync(cancellationToken))
|
||||
{
|
||||
var oldestCreatedAt = await nonTerminal
|
||||
.OrderBy(n => n.CreatedAt)
|
||||
.Select(n => n.CreatedAt)
|
||||
.FirstAsync(cancellationToken);
|
||||
oldestPendingAge = now - oldestCreatedAt;
|
||||
}
|
||||
|
||||
return new NotificationKpiSnapshot(
|
||||
QueueDepth: queueDepth,
|
||||
StuckCount: stuckCount,
|
||||
ParkedCount: parkedCount,
|
||||
DeliveredLastInterval: deliveredLastInterval,
|
||||
OldestPendingAge: oldestPendingAge);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SiteNotificationKpiSnapshot>> ComputePerSiteKpisAsync(
|
||||
DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var queueDepth = await CountBySiteAsync(
|
||||
n => n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying,
|
||||
cancellationToken);
|
||||
|
||||
var stuck = await CountBySiteAsync(
|
||||
n => (n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying)
|
||||
&& n.CreatedAt < stuckCutoff,
|
||||
cancellationToken);
|
||||
|
||||
var parked = await CountBySiteAsync(
|
||||
n => n.Status == NotificationStatus.Parked, cancellationToken);
|
||||
|
||||
var delivered = await CountBySiteAsync(
|
||||
n => n.Status == NotificationStatus.Delivered
|
||||
&& n.DeliveredAt != null && n.DeliveredAt >= deliveredSince,
|
||||
cancellationToken);
|
||||
|
||||
// Oldest non-terminal CreatedAt per site. A SQL Min over the DateTimeOffset
|
||||
// converter is awkward (see ComputeKpisAsync), so project the non-terminal
|
||||
// (site, created) pairs — the live queue, which stays bounded — and reduce
|
||||
// in memory.
|
||||
var oldest = (await _context.Notifications
|
||||
.Where(n => n.Status == NotificationStatus.Pending
|
||||
|| n.Status == NotificationStatus.Retrying)
|
||||
.Select(n => new { n.SourceSiteId, n.CreatedAt })
|
||||
.ToListAsync(cancellationToken))
|
||||
.GroupBy(x => x.SourceSiteId)
|
||||
.ToDictionary(g => g.Key, g => g.Min(x => x.CreatedAt));
|
||||
|
||||
var siteIds = queueDepth.Keys
|
||||
.Concat(stuck.Keys).Concat(parked.Keys).Concat(delivered.Keys)
|
||||
.Distinct()
|
||||
.OrderBy(s => s, StringComparer.Ordinal);
|
||||
|
||||
return siteIds.Select(site => new SiteNotificationKpiSnapshot(
|
||||
SourceSiteId: site,
|
||||
QueueDepth: queueDepth.GetValueOrDefault(site),
|
||||
StuckCount: stuck.GetValueOrDefault(site),
|
||||
ParkedCount: parked.GetValueOrDefault(site),
|
||||
DeliveredLastInterval: delivered.GetValueOrDefault(site),
|
||||
OldestPendingAge: oldest.TryGetValue(site, out var createdAt)
|
||||
? now - createdAt
|
||||
: null)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Counts notification rows matching <paramref name="predicate"/>, grouped by source site.</summary>
|
||||
private async Task<Dictionary<string, int>> CountBySiteAsync(
|
||||
System.Linq.Expressions.Expression<Func<Notification, bool>> predicate,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Notifications
|
||||
.Where(predicate)
|
||||
.GroupBy(n => n.SourceSiteId)
|
||||
.Select(g => new { Site = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.Site, x => x.Count, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
|
||||
public class NotificationRepository : INotificationRepository
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
|
||||
/// <summary>Initializes a new instance of the NotificationRepository class.</summary>
|
||||
/// <param name="context">The database context.</param>
|
||||
public NotificationRepository(ScadaBridgeDbContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<NotificationList?> GetNotificationListByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<NotificationList>().FindAsync(new object[] { id }, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<NotificationList>> GetAllNotificationListsAsync(CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<NotificationList>().Include(n => n.Recipients).ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<NotificationList?> GetListByNameAsync(string name, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<NotificationList>().FirstOrDefaultAsync(l => l.Name == name, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddNotificationListAsync(NotificationList list, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<NotificationList>().AddAsync(list, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateNotificationListAsync(NotificationList list, CancellationToken cancellationToken = default)
|
||||
{ _context.Set<NotificationList>().Update(list); return Task.CompletedTask; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteNotificationListAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await GetNotificationListByIdAsync(id, cancellationToken);
|
||||
if (entity != null) _context.Set<NotificationList>().Remove(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<NotificationRecipient?> GetRecipientByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<NotificationRecipient>().FindAsync(new object[] { id }, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<NotificationRecipient>> GetRecipientsByListIdAsync(int notificationListId, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<NotificationRecipient>().Where(r => r.NotificationListId == notificationListId).ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddRecipientAsync(NotificationRecipient recipient, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<NotificationRecipient>().AddAsync(recipient, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateRecipientAsync(NotificationRecipient recipient, CancellationToken cancellationToken = default)
|
||||
{ _context.Set<NotificationRecipient>().Update(recipient); return Task.CompletedTask; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteRecipientAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await GetRecipientByIdAsync(id, cancellationToken);
|
||||
if (entity != null) _context.Set<NotificationRecipient>().Remove(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SmtpConfiguration?> GetSmtpConfigurationByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<SmtpConfiguration>().FindAsync(new object[] { id }, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SmtpConfiguration>> GetAllSmtpConfigurationsAsync(CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<SmtpConfiguration>().ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddSmtpConfigurationAsync(SmtpConfiguration configuration, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<SmtpConfiguration>().AddAsync(configuration, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateSmtpConfigurationAsync(SmtpConfiguration configuration, CancellationToken cancellationToken = default)
|
||||
{ _context.Set<SmtpConfiguration>().Update(configuration); return Task.CompletedTask; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteSmtpConfigurationAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await GetSmtpConfigurationByIdAsync(id, cancellationToken);
|
||||
if (entity != null) _context.Set<SmtpConfiguration>().Remove(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
|
||||
public class SecurityRepository : ISecurityRepository
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the SecurityRepository.
|
||||
/// </summary>
|
||||
/// <param name="context">The database context.</param>
|
||||
public SecurityRepository(ScadaBridgeDbContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
// LdapGroupMapping
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LdapGroupMapping?> GetMappingByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.LdapGroupMappings.FindAsync(new object[] { id }, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<LdapGroupMapping>> GetAllMappingsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.LdapGroupMappings.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<LdapGroupMapping>> GetMappingsByRoleAsync(string role, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.LdapGroupMappings
|
||||
.Where(m => m.Role == role)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddMappingAsync(LdapGroupMapping mapping, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.LdapGroupMappings.AddAsync(mapping, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateMappingAsync(LdapGroupMapping mapping, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.LdapGroupMappings.Update(mapping);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteMappingAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var mapping = await _context.LdapGroupMappings.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (mapping != null)
|
||||
{
|
||||
_context.LdapGroupMappings.Remove(mapping);
|
||||
}
|
||||
}
|
||||
|
||||
// SiteScopeRule
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SiteScopeRule?> GetScopeRuleByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.SiteScopeRules.FindAsync(new object[] { id }, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SiteScopeRule>> GetScopeRulesForMappingAsync(int ldapGroupMappingId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.SiteScopeRules
|
||||
.Where(r => r.LdapGroupMappingId == ldapGroupMappingId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddScopeRuleAsync(SiteScopeRule rule, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.SiteScopeRules.AddAsync(rule, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateScopeRuleAsync(SiteScopeRule rule, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.SiteScopeRules.Update(rule);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteScopeRuleAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var rule = await _context.SiteScopeRules.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (rule != null)
|
||||
{
|
||||
_context.SiteScopeRules.Remove(rule);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core implementation of <see cref="ISiteCallAuditRepository"/>. See the
|
||||
/// interface for the monotonic-upsert contract; this class adds notes on the
|
||||
/// data-access strategy used by each method.
|
||||
/// </summary>
|
||||
public class SiteCallAuditRepository : ISiteCallAuditRepository
|
||||
{
|
||||
// SQL Server duplicate-key error numbers, identical to the AuditLogRepository
|
||||
// race-fix: 2601 = unique-index violation, 2627 = PK/unique-constraint
|
||||
// violation. The IF NOT EXISTS … INSERT pattern has a check-then-act window
|
||||
// and the loser surfaces as one of these; monotonic-upsert semantics demand
|
||||
// we swallow them.
|
||||
private const int SqlErrorUniqueIndexViolation = 2601;
|
||||
private const int SqlErrorPrimaryKeyViolation = 2627;
|
||||
|
||||
// Monotonic status ordering. Lower rank wins on tie (same-rank upserts are
|
||||
// no-ops, including terminal-over-terminal). Spec from Bundle B3 plan:
|
||||
// Submitted < Forwarded < Attempted == Skipped < Delivered == Failed == Parked == Discarded.
|
||||
private static readonly Dictionary<string, int> StatusRank = new(StringComparer.Ordinal)
|
||||
{
|
||||
["Submitted"] = 0,
|
||||
["Forwarded"] = 1,
|
||||
["Attempted"] = 2,
|
||||
["Skipped"] = 2,
|
||||
["Delivered"] = 3,
|
||||
["Failed"] = 3,
|
||||
["Parked"] = 3,
|
||||
["Discarded"] = 3,
|
||||
};
|
||||
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
private readonly ILogger<SiteCallAuditRepository> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SiteCallAuditRepository"/> class.
|
||||
/// </summary>
|
||||
/// <param name="context">The EF Core database context.</param>
|
||||
/// <param name="logger">Optional logger for diagnostic information.</param>
|
||||
public SiteCallAuditRepository(ScadaBridgeDbContext context, ILogger<SiteCallAuditRepository>? logger = null)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_logger = logger ?? NullLogger<SiteCallAuditRepository>.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default)
|
||||
{
|
||||
if (siteCall is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(siteCall));
|
||||
}
|
||||
|
||||
var idText = siteCall.TrackedOperationId.Value.ToString("D");
|
||||
var incomingRank = GetRankOrThrow(siteCall.Status);
|
||||
|
||||
// Step 1: insert-if-not-exists. Like AuditLogRepository.InsertIfNotExistsAsync
|
||||
// this is check-then-act so a duplicate-key violation may surface under
|
||||
// concurrent inserts on the same id — caught + logged at Debug.
|
||||
//
|
||||
// SourceNode-stamping (Task 14): the column is included in the INSERT
|
||||
// column list / VALUES so a fresh row carries the originating node
|
||||
// name (node-a/node-b for site rows). A null SourceNode (legacy hosts
|
||||
// / unstamped reconciled rows) writes NULL straight through.
|
||||
try
|
||||
{
|
||||
await _context.Database.ExecuteSqlInterpolatedAsync(
|
||||
$@"IF NOT EXISTS (SELECT 1 FROM dbo.SiteCalls WHERE TrackedOperationId = {idText})
|
||||
INSERT INTO dbo.SiteCalls
|
||||
(TrackedOperationId, Channel, Target, SourceSite, SourceNode, Status, RetryCount,
|
||||
LastError, HttpStatus, CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc, IngestedAtUtc)
|
||||
VALUES
|
||||
({idText}, {siteCall.Channel}, {siteCall.Target}, {siteCall.SourceSite}, {siteCall.SourceNode}, {siteCall.Status}, {siteCall.RetryCount},
|
||||
{siteCall.LastError}, {siteCall.HttpStatus}, {siteCall.CreatedAtUtc}, {siteCall.UpdatedAtUtc}, {siteCall.TerminalAtUtc}, {siteCall.IngestedAtUtc});",
|
||||
ct);
|
||||
}
|
||||
catch (SqlException ex) when (
|
||||
ex.Number == SqlErrorUniqueIndexViolation
|
||||
|| ex.Number == SqlErrorPrimaryKeyViolation)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
ex,
|
||||
"SiteCallAuditRepository.UpsertAsync swallowed duplicate-key violation (error {SqlErrorNumber}) for TrackedOperationId {TrackedOperationId}; falling through to monotonic update.",
|
||||
ex.Number,
|
||||
idText);
|
||||
}
|
||||
|
||||
// Step 2: monotonic update. The CASE expression maps the stored Status
|
||||
// string to the same rank table the caller uses; we only mutate if the
|
||||
// incoming rank is strictly greater. Same-rank (including
|
||||
// terminal-over-terminal) is a no-op — first-write-wins at each rank.
|
||||
//
|
||||
// SourceNode-stamping (Task 14): SourceNode is updated via
|
||||
// COALESCE(@SourceNode, SourceNode). The operator returns @SourceNode
|
||||
// when it is non-null, otherwise the stored value — so the column
|
||||
// behaves protectively: a later packet that carries a null
|
||||
// SourceNode (e.g. a reconciliation pull from an unstamped node)
|
||||
// NEVER blanks out a value the first stamping packet set. A later
|
||||
// packet that DOES carry a non-null SourceNode replaces the previous
|
||||
// value — combined with the monotonic-rank guard this is
|
||||
// "last-non-null-wins on rank advance", which lets a missing
|
||||
// SourceNode be filled in later if Submit happened to be unstamped
|
||||
// and an Attempt/Resolve carries the node identity. Within one
|
||||
// lifecycle every packet should carry the same SourceNode value (one
|
||||
// execution, one node) so the "overwrite" path is in practice
|
||||
// idempotent.
|
||||
await _context.Database.ExecuteSqlInterpolatedAsync(
|
||||
$@"UPDATE dbo.SiteCalls
|
||||
SET Status = {siteCall.Status},
|
||||
RetryCount = {siteCall.RetryCount},
|
||||
LastError = {siteCall.LastError},
|
||||
HttpStatus = {siteCall.HttpStatus},
|
||||
UpdatedAtUtc = {siteCall.UpdatedAtUtc},
|
||||
TerminalAtUtc = {siteCall.TerminalAtUtc},
|
||||
IngestedAtUtc = {siteCall.IngestedAtUtc},
|
||||
SourceNode = COALESCE({siteCall.SourceNode}, SourceNode)
|
||||
WHERE TrackedOperationId = {idText}
|
||||
AND {incomingRank} > (CASE Status
|
||||
WHEN 'Submitted' THEN 0
|
||||
WHEN 'Forwarded' THEN 1
|
||||
WHEN 'Attempted' THEN 2
|
||||
WHEN 'Skipped' THEN 2
|
||||
WHEN 'Delivered' THEN 3
|
||||
WHEN 'Failed' THEN 3
|
||||
WHEN 'Parked' THEN 3
|
||||
WHEN 'Discarded' THEN 3
|
||||
ELSE -1
|
||||
END);",
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.Set<SiteCall>().FindAsync(new object?[] { id }, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SiteCall>> QueryAsync(
|
||||
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default)
|
||||
{
|
||||
if (filter is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(filter));
|
||||
}
|
||||
|
||||
if (paging is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(paging));
|
||||
}
|
||||
|
||||
// FormattableString interpolation parameterises every value (no concatenation)
|
||||
// so this is injection-safe. EF Core resolves the parameter values, the
|
||||
// composed sql is shaped to SQL Server's grammar and projected into the
|
||||
// SiteCall entity via FromSqlInterpolated. The CASE expressions wrap each
|
||||
// optional predicate so a null filter field degrades to a no-op (matches
|
||||
// every row) instead of branching at C# level into N variants.
|
||||
var afterCreated = paging.AfterCreatedAtUtc;
|
||||
var afterIdString = paging.AfterId?.Value.ToString("D");
|
||||
var hasCursor = afterCreated is not null && afterIdString is not null;
|
||||
|
||||
var fromUtc = filter.FromUtc;
|
||||
var toUtc = filter.ToUtc;
|
||||
var stuckCutoff = filter.StuckCutoffUtc;
|
||||
|
||||
// The stuck predicate (TerminalAtUtc IS NULL AND CreatedAtUtc < cutoff)
|
||||
// is pushed into SQL here — both columns are plain (no value converter)
|
||||
// and compose with the keyset cursor, so a StuckOnly page is honest:
|
||||
// never under-filled with a non-null next cursor. Mirrors how
|
||||
// NotificationOutboxRepository.QueryAsync applies NotificationOutboxFilter.StuckCutoff.
|
||||
//
|
||||
// SELECT-list maintenance: EF Core's FromSqlInterpolated requires every
|
||||
// entity-tracked column to appear in the result set. Adding a new column
|
||||
// to the SiteCall entity means extending the list below too — otherwise
|
||||
// every read trips "The required column 'X' was not present" at runtime.
|
||||
FormattableString sql = $@"
|
||||
SELECT TOP ({paging.PageSize})
|
||||
TrackedOperationId, Channel, Target, SourceSite, SourceNode, Status, RetryCount,
|
||||
LastError, HttpStatus, CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc, IngestedAtUtc
|
||||
FROM dbo.SiteCalls
|
||||
WHERE ({filter.Channel} IS NULL OR Channel = {filter.Channel})
|
||||
AND ({filter.SourceSite} IS NULL OR SourceSite = {filter.SourceSite})
|
||||
AND ({filter.SourceNode} IS NULL OR SourceNode = {filter.SourceNode})
|
||||
AND ({filter.Status} IS NULL OR Status = {filter.Status})
|
||||
AND ({filter.Target} IS NULL OR Target = {filter.Target})
|
||||
AND ({fromUtc} IS NULL OR CreatedAtUtc >= {fromUtc})
|
||||
AND ({toUtc} IS NULL OR CreatedAtUtc <= {toUtc})
|
||||
AND ({stuckCutoff} IS NULL OR (TerminalAtUtc IS NULL AND CreatedAtUtc < {stuckCutoff}))
|
||||
AND ({(hasCursor ? 1 : 0)} = 0
|
||||
OR CreatedAtUtc < {afterCreated}
|
||||
OR (CreatedAtUtc = {afterCreated} AND TrackedOperationId < {afterIdString}))
|
||||
ORDER BY CreatedAtUtc DESC, TrackedOperationId DESC;";
|
||||
|
||||
var rows = await _context.Set<SiteCall>()
|
||||
.FromSqlInterpolated(sql)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(ct);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.Database.ExecuteSqlInterpolatedAsync(
|
||||
$"DELETE FROM dbo.SiteCalls WHERE TerminalAtUtc IS NOT NULL AND TerminalAtUtc < {olderThanUtc};",
|
||||
ct);
|
||||
}
|
||||
|
||||
// Terminal status string literals for the interval-throughput KPIs. The
|
||||
// Status column is a plain varchar (no value converter), so these compare
|
||||
// directly in translated SQL.
|
||||
//
|
||||
// NOTE on the "buffered/non-terminal" definition: the SiteCalls operational
|
||||
// mirror stores AuditStatus-derived strings (Attempted/Delivered/Parked/
|
||||
// Failed/...), NOT the tracking-lifecycle Pending/Retrying names the spec's
|
||||
// KPI section uses. There is therefore no Status string that means
|
||||
// "buffered". The schema-honest predicate for "non-terminal / buffered" is
|
||||
// TerminalAtUtc IS NULL — consistent with PurgeTerminalAsync's terminal
|
||||
// predicate and with the SiteCall entity's own contract ("TerminalAtUtc ...
|
||||
// null while still active"). All buffered / stuck / oldest-pending counts
|
||||
// below key off TerminalAtUtc, not Status.
|
||||
private const string StatusParked = "Parked";
|
||||
private const string StatusDelivered = "Delivered";
|
||||
private const string StatusFailed = "Failed";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SiteCallKpiSnapshot> ComputeKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var bufferedCount = await _context.SiteCalls
|
||||
.CountAsync(s => s.TerminalAtUtc == null, ct);
|
||||
|
||||
var parkedCount = await _context.SiteCalls
|
||||
.CountAsync(s => s.Status == StatusParked, ct);
|
||||
|
||||
var failedLastInterval = await _context.SiteCalls
|
||||
.CountAsync(s => s.Status == StatusFailed
|
||||
&& s.TerminalAtUtc != null
|
||||
&& s.TerminalAtUtc >= intervalSince, ct);
|
||||
|
||||
var deliveredLastInterval = await _context.SiteCalls
|
||||
.CountAsync(s => s.Status == StatusDelivered
|
||||
&& s.TerminalAtUtc != null
|
||||
&& s.TerminalAtUtc >= intervalSince, ct);
|
||||
|
||||
var stuckCount = await _context.SiteCalls
|
||||
.CountAsync(s => s.TerminalAtUtc == null && s.CreatedAtUtc < stuckCutoff, ct);
|
||||
|
||||
var nonTerminal = _context.SiteCalls.Where(s => s.TerminalAtUtc == null);
|
||||
|
||||
TimeSpan? oldestPendingAge = null;
|
||||
if (await nonTerminal.AnyAsync(ct))
|
||||
{
|
||||
var oldestCreatedAt = await nonTerminal.MinAsync(s => s.CreatedAtUtc, ct);
|
||||
oldestPendingAge = now - oldestCreatedAt;
|
||||
}
|
||||
|
||||
return new SiteCallKpiSnapshot(
|
||||
BufferedCount: bufferedCount,
|
||||
ParkedCount: parkedCount,
|
||||
FailedLastInterval: failedLastInterval,
|
||||
DeliveredLastInterval: deliveredLastInterval,
|
||||
OldestPendingAge: oldestPendingAge,
|
||||
StuckCount: stuckCount);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var buffered = await CountBySiteAsync(s => s.TerminalAtUtc == null, ct);
|
||||
|
||||
var parked = await CountBySiteAsync(s => s.Status == StatusParked, ct);
|
||||
|
||||
var failed = await CountBySiteAsync(
|
||||
s => s.Status == StatusFailed
|
||||
&& s.TerminalAtUtc != null && s.TerminalAtUtc >= intervalSince, ct);
|
||||
|
||||
var delivered = await CountBySiteAsync(
|
||||
s => s.Status == StatusDelivered
|
||||
&& s.TerminalAtUtc != null && s.TerminalAtUtc >= intervalSince, ct);
|
||||
|
||||
var stuck = await CountBySiteAsync(
|
||||
s => s.TerminalAtUtc == null && s.CreatedAtUtc < stuckCutoff, ct);
|
||||
|
||||
// Oldest non-terminal CreatedAtUtc per site — a server-side GROUP BY MIN.
|
||||
var oldest = (await _context.SiteCalls
|
||||
.Where(s => s.TerminalAtUtc == null)
|
||||
.GroupBy(s => s.SourceSite)
|
||||
.Select(g => new { Site = g.Key, Oldest = g.Min(s => s.CreatedAtUtc) })
|
||||
.ToListAsync(ct))
|
||||
.ToDictionary(x => x.Site, x => x.Oldest);
|
||||
|
||||
var siteIds = buffered.Keys
|
||||
.Concat(parked.Keys).Concat(failed.Keys)
|
||||
.Concat(delivered.Keys).Concat(stuck.Keys)
|
||||
.Distinct()
|
||||
.OrderBy(s => s, StringComparer.Ordinal);
|
||||
|
||||
return siteIds.Select(site => new SiteCallSiteKpiSnapshot(
|
||||
SourceSite: site,
|
||||
BufferedCount: buffered.GetValueOrDefault(site),
|
||||
ParkedCount: parked.GetValueOrDefault(site),
|
||||
FailedLastInterval: failed.GetValueOrDefault(site),
|
||||
DeliveredLastInterval: delivered.GetValueOrDefault(site),
|
||||
OldestPendingAge: oldest.TryGetValue(site, out var createdAt)
|
||||
? now - createdAt
|
||||
: null,
|
||||
StuckCount: stuck.GetValueOrDefault(site))).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Counts <c>SiteCalls</c> rows matching <paramref name="predicate"/>, grouped by source site.</summary>
|
||||
private async Task<Dictionary<string, int>> CountBySiteAsync(
|
||||
System.Linq.Expressions.Expression<Func<SiteCall, bool>> predicate,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return await _context.SiteCalls
|
||||
.Where(predicate)
|
||||
.GroupBy(s => s.SourceSite)
|
||||
.Select(g => new { Site = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.Site, x => x.Count, ct);
|
||||
}
|
||||
|
||||
private static int GetRankOrThrow(string status)
|
||||
{
|
||||
if (!StatusRank.TryGetValue(status, out var rank))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Unknown SiteCall status '{status}'. Expected one of: {string.Join(", ", StatusRank.Keys)}.",
|
||||
nameof(status));
|
||||
}
|
||||
return rank;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core implementation of ISiteRepository for site and data connection management.
|
||||
/// </summary>
|
||||
public class SiteRepository : ISiteRepository
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _dbContext;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the SiteRepository.
|
||||
/// </summary>
|
||||
/// <param name="dbContext">The database context.</param>
|
||||
public SiteRepository(ScadaBridgeDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||
}
|
||||
|
||||
// --- Sites ---
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Site?> GetSiteByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.Sites.FindAsync([id], cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Site?> GetSiteByIdentifierAsync(string siteIdentifier, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.Sites
|
||||
.FirstOrDefaultAsync(s => s.SiteIdentifier == siteIdentifier, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Site>> GetAllSitesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.Sites.OrderBy(s => s.Name).ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddSiteAsync(Site site, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _dbContext.Sites.AddAsync(site, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateSiteAsync(Site site, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_dbContext.Sites.Update(site);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteSiteAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = _dbContext.Sites.Local.FirstOrDefault(s => s.Id == id);
|
||||
if (entity != null)
|
||||
{
|
||||
_dbContext.Sites.Remove(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
var stub = new Site("stub", "stub") { Id = id };
|
||||
_dbContext.Sites.Attach(stub);
|
||||
_dbContext.Sites.Remove(stub);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// --- Data Connections ---
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DataConnection?> GetDataConnectionByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.DataConnections.FindAsync([id], cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DataConnection>> GetAllDataConnectionsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.DataConnections.OrderBy(c => c.Name).ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.DataConnections
|
||||
.Where(c => c.SiteId == siteId)
|
||||
.OrderBy(c => c.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _dbContext.DataConnections.AddAsync(connection, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_dbContext.DataConnections.Update(connection);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteDataConnectionAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = _dbContext.DataConnections.Local.FirstOrDefault(c => c.Id == id);
|
||||
if (entity != null)
|
||||
{
|
||||
_dbContext.DataConnections.Remove(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
var stub = new DataConnection("stub", "stub", 0) { Id = id };
|
||||
_dbContext.DataConnections.Attach(stub);
|
||||
_dbContext.DataConnections.Remove(stub);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// --- Instances (for deletion constraint checks) ---
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.Instances
|
||||
.Where(i => i.SiteId == siteId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
+577
@@ -0,0 +1,577 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
|
||||
public class TemplateEngineRepository : ITemplateEngineRepository
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the TemplateEngineRepository class.
|
||||
/// </summary>
|
||||
/// <param name="context">The database context used to access template and instance data.</param>
|
||||
public TemplateEngineRepository(ScadaBridgeDbContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
// Template
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Template?> GetTemplateByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Templates
|
||||
.Include(t => t.Attributes)
|
||||
.Include(t => t.Alarms)
|
||||
.Include(t => t.Scripts)
|
||||
.Include(t => t.Compositions)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(t => t.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Template?> GetTemplateWithChildrenAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await GetTemplateByIdAsync(id, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Template>> GetTemplatesWithChildrenAsync(
|
||||
IEnumerable<string> names, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Transport-008: bulk lookup replaces the per-name N+1 in
|
||||
// BundleImporter.PreviewAsync. Filter out null / empty / duplicate
|
||||
// names before the query so EF emits a clean, deduplicated IN clause.
|
||||
if (names is null) return Array.Empty<Template>();
|
||||
var distinct = names
|
||||
.Where(n => !string.IsNullOrEmpty(n))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (distinct.Length == 0) return Array.Empty<Template>();
|
||||
|
||||
return await _context.Templates
|
||||
.Where(t => distinct.Contains(t.Name))
|
||||
.Include(t => t.Attributes)
|
||||
.Include(t => t.Alarms)
|
||||
.Include(t => t.Scripts)
|
||||
.Include(t => t.Compositions)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Template>> GetAllTemplatesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Templates
|
||||
.Include(t => t.Attributes)
|
||||
.Include(t => t.Alarms)
|
||||
.Include(t => t.Scripts)
|
||||
.Include(t => t.Compositions)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Template>> GetTemplatesComposingAsync(int composedTemplateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Templates
|
||||
.Where(t => t.Compositions.Any(c => c.ComposedTemplateId == composedTemplateId))
|
||||
.Include(t => t.Attributes)
|
||||
.Include(t => t.Scripts)
|
||||
.Include(t => t.Compositions)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddTemplateAsync(Template template, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.Templates.AddAsync(template, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateTemplateAsync(Template template, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.Templates.Update(template);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteTemplateAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var template = await _context.Templates.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (template != null)
|
||||
{
|
||||
_context.Templates.Remove(template);
|
||||
}
|
||||
}
|
||||
|
||||
// TemplateAttribute
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TemplateAttribute?> GetTemplateAttributeByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.TemplateAttributes.FindAsync(new object[] { id }, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TemplateAttribute>> GetAttributesByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.TemplateAttributes
|
||||
.Where(a => a.TemplateId == templateId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddTemplateAttributeAsync(TemplateAttribute attribute, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.TemplateAttributes.AddAsync(attribute, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateTemplateAttributeAsync(TemplateAttribute attribute, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.TemplateAttributes.Update(attribute);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteTemplateAttributeAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var attribute = await _context.TemplateAttributes.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (attribute != null)
|
||||
{
|
||||
_context.TemplateAttributes.Remove(attribute);
|
||||
}
|
||||
}
|
||||
|
||||
// TemplateAlarm
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TemplateAlarm?> GetTemplateAlarmByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.TemplateAlarms.FindAsync(new object[] { id }, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TemplateAlarm>> GetAlarmsByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.TemplateAlarms
|
||||
.Where(a => a.TemplateId == templateId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddTemplateAlarmAsync(TemplateAlarm alarm, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.TemplateAlarms.AddAsync(alarm, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateTemplateAlarmAsync(TemplateAlarm alarm, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.TemplateAlarms.Update(alarm);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteTemplateAlarmAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var alarm = await _context.TemplateAlarms.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (alarm != null)
|
||||
{
|
||||
_context.TemplateAlarms.Remove(alarm);
|
||||
}
|
||||
}
|
||||
|
||||
// TemplateScript
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TemplateScript?> GetTemplateScriptByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.TemplateScripts.FindAsync(new object[] { id }, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TemplateScript>> GetScriptsByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.TemplateScripts
|
||||
.Where(s => s.TemplateId == templateId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddTemplateScriptAsync(TemplateScript script, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.TemplateScripts.AddAsync(script, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateTemplateScriptAsync(TemplateScript script, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.TemplateScripts.Update(script);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteTemplateScriptAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var script = await _context.TemplateScripts.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (script != null)
|
||||
{
|
||||
_context.TemplateScripts.Remove(script);
|
||||
}
|
||||
}
|
||||
|
||||
// TemplateComposition
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TemplateComposition?> GetTemplateCompositionByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.TemplateCompositions.FindAsync(new object[] { id }, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TemplateComposition>> GetCompositionsByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.TemplateCompositions
|
||||
.Where(c => c.TemplateId == templateId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddTemplateCompositionAsync(TemplateComposition composition, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.TemplateCompositions.AddAsync(composition, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateTemplateCompositionAsync(TemplateComposition composition, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.TemplateCompositions.Update(composition);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteTemplateCompositionAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var composition = await _context.TemplateCompositions.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (composition != null)
|
||||
{
|
||||
_context.TemplateCompositions.Remove(composition);
|
||||
}
|
||||
}
|
||||
|
||||
// Instance
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Instance?> GetInstanceByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Instances
|
||||
.Include(i => i.AttributeOverrides)
|
||||
.Include(i => i.AlarmOverrides)
|
||||
.Include(i => i.ConnectionBindings)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(i => i.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Instance>> GetAllInstancesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Instances
|
||||
.Include(i => i.AttributeOverrides)
|
||||
.Include(i => i.AlarmOverrides)
|
||||
.Include(i => i.ConnectionBindings)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Instance>> GetInstancesByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Instances
|
||||
.Where(i => i.TemplateId == templateId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Instances
|
||||
.Where(i => i.SiteId == siteId)
|
||||
.Include(i => i.AttributeOverrides)
|
||||
.Include(i => i.AlarmOverrides)
|
||||
.Include(i => i.ConnectionBindings)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Instance?> GetInstanceByUniqueNameAsync(string uniqueName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Instances
|
||||
.Include(i => i.AttributeOverrides)
|
||||
.Include(i => i.AlarmOverrides)
|
||||
.Include(i => i.ConnectionBindings)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(i => i.UniqueName == uniqueName, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddInstanceAsync(Instance instance, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.Instances.AddAsync(instance, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateInstanceAsync(Instance instance, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.Instances.Update(instance);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteInstanceAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _context.Instances.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (instance != null)
|
||||
{
|
||||
_context.Instances.Remove(instance);
|
||||
}
|
||||
}
|
||||
|
||||
// InstanceAttributeOverride
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<InstanceAttributeOverride>> GetOverridesByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.InstanceAttributeOverrides
|
||||
.Where(o => o.InstanceId == instanceId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddInstanceAttributeOverrideAsync(InstanceAttributeOverride attributeOverride, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.InstanceAttributeOverrides.AddAsync(attributeOverride, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateInstanceAttributeOverrideAsync(InstanceAttributeOverride attributeOverride, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.InstanceAttributeOverrides.Update(attributeOverride);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteInstanceAttributeOverrideAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var attributeOverride = await _context.InstanceAttributeOverrides.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (attributeOverride != null)
|
||||
{
|
||||
_context.InstanceAttributeOverrides.Remove(attributeOverride);
|
||||
}
|
||||
}
|
||||
|
||||
// InstanceAlarmOverride
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<InstanceAlarmOverride>> GetAlarmOverridesByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.InstanceAlarmOverrides
|
||||
.Where(o => o.InstanceId == instanceId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<InstanceAlarmOverride?> GetAlarmOverrideAsync(int instanceId, string alarmCanonicalName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.InstanceAlarmOverrides
|
||||
.FirstOrDefaultAsync(
|
||||
o => o.InstanceId == instanceId && o.AlarmCanonicalName == alarmCanonicalName,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddInstanceAlarmOverrideAsync(InstanceAlarmOverride alarmOverride, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.InstanceAlarmOverrides.AddAsync(alarmOverride, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateInstanceAlarmOverrideAsync(InstanceAlarmOverride alarmOverride, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.InstanceAlarmOverrides.Update(alarmOverride);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteInstanceAlarmOverrideAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var alarmOverride = await _context.InstanceAlarmOverrides.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (alarmOverride != null)
|
||||
{
|
||||
_context.InstanceAlarmOverrides.Remove(alarmOverride);
|
||||
}
|
||||
}
|
||||
|
||||
// InstanceConnectionBinding
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<InstanceConnectionBinding>> GetBindingsByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.InstanceConnectionBindings
|
||||
.Where(b => b.InstanceId == instanceId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddInstanceConnectionBindingAsync(InstanceConnectionBinding binding, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.InstanceConnectionBindings.AddAsync(binding, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateInstanceConnectionBindingAsync(InstanceConnectionBinding binding, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.InstanceConnectionBindings.Update(binding);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteInstanceConnectionBindingAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var binding = await _context.InstanceConnectionBindings.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (binding != null)
|
||||
{
|
||||
_context.InstanceConnectionBindings.Remove(binding);
|
||||
}
|
||||
}
|
||||
|
||||
// Area
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Area?> GetAreaByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Areas
|
||||
.Include(a => a.Children)
|
||||
.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Area>> GetAreasBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Areas
|
||||
.Where(a => a.SiteId == siteId)
|
||||
.Include(a => a.Children)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddAreaAsync(Area area, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.Areas.AddAsync(area, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateAreaAsync(Area area, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.Areas.Update(area);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteAreaAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var area = await _context.Areas.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (area != null)
|
||||
{
|
||||
_context.Areas.Remove(area);
|
||||
}
|
||||
}
|
||||
|
||||
// SharedScript
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SharedScript?> GetSharedScriptByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.SharedScripts.FindAsync(new object[] { id }, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SharedScript?> GetSharedScriptByNameAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.SharedScripts
|
||||
.FirstOrDefaultAsync(s => s.Name == name, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SharedScript>> GetAllSharedScriptsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.SharedScripts.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddSharedScriptAsync(SharedScript sharedScript, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.SharedScripts.AddAsync(sharedScript, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateSharedScriptAsync(SharedScript sharedScript, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.SharedScripts.Update(sharedScript);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteSharedScriptAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sharedScript = await _context.SharedScripts.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (sharedScript != null)
|
||||
{
|
||||
_context.SharedScripts.Remove(sharedScript);
|
||||
}
|
||||
}
|
||||
|
||||
// TemplateFolder
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TemplateFolder?> GetFolderByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
=> await _context.TemplateFolders.FindAsync(new object[] { id }, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TemplateFolder>> GetAllFoldersAsync(CancellationToken cancellationToken = default)
|
||||
=> await _context.TemplateFolders.ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddFolderAsync(TemplateFolder folder, CancellationToken cancellationToken = default)
|
||||
=> await _context.TemplateFolders.AddAsync(folder, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateFolderAsync(TemplateFolder folder, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.TemplateFolders.Update(folder);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteFolderAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var folder = await _context.TemplateFolders.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (folder != null)
|
||||
{
|
||||
_context.TemplateFolders.Remove(folder);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
|
||||
public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
|
||||
{
|
||||
private readonly IDataProtectionProvider? _dataProtectionProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ScadaBridgeDbContext"/> class for schema-only access (design-time).
|
||||
/// </summary>
|
||||
/// <param name="options">Database context options.</param>
|
||||
public ScadaBridgeDbContext(DbContextOptions<ScadaBridgeDbContext> options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a context with an explicit Data Protection provider used to encrypt
|
||||
/// secret-bearing configuration columns at rest. The runtime resolves this overload
|
||||
/// via DI; design-time tooling uses the single-argument overload.
|
||||
/// </summary>
|
||||
/// <param name="options">Database context options.</param>
|
||||
/// <param name="dataProtectionProvider">Data Protection provider for encrypting secrets at rest.</param>
|
||||
public ScadaBridgeDbContext(DbContextOptions<ScadaBridgeDbContext> options, IDataProtectionProvider dataProtectionProvider)
|
||||
: base(options)
|
||||
{
|
||||
_dataProtectionProvider = dataProtectionProvider
|
||||
?? throw new ArgumentNullException(nameof(dataProtectionProvider));
|
||||
}
|
||||
|
||||
// Templates
|
||||
/// <summary>Gets the set of templates.</summary>
|
||||
public DbSet<Template> Templates => Set<Template>();
|
||||
/// <summary>Gets the set of template attributes.</summary>
|
||||
public DbSet<TemplateAttribute> TemplateAttributes => Set<TemplateAttribute>();
|
||||
/// <summary>Gets the set of template alarms.</summary>
|
||||
public DbSet<TemplateAlarm> TemplateAlarms => Set<TemplateAlarm>();
|
||||
/// <summary>Gets the set of template scripts.</summary>
|
||||
public DbSet<TemplateScript> TemplateScripts => Set<TemplateScript>();
|
||||
/// <summary>Gets the set of template compositions.</summary>
|
||||
public DbSet<TemplateComposition> TemplateCompositions => Set<TemplateComposition>();
|
||||
/// <summary>Gets the set of template folders.</summary>
|
||||
public DbSet<TemplateFolder> TemplateFolders => Set<TemplateFolder>();
|
||||
|
||||
// Instances
|
||||
/// <summary>Gets the set of instances.</summary>
|
||||
public DbSet<Instance> Instances => Set<Instance>();
|
||||
/// <summary>Gets the set of instance attribute overrides.</summary>
|
||||
public DbSet<InstanceAttributeOverride> InstanceAttributeOverrides => Set<InstanceAttributeOverride>();
|
||||
/// <summary>Gets the set of instance alarm overrides.</summary>
|
||||
public DbSet<InstanceAlarmOverride> InstanceAlarmOverrides => Set<InstanceAlarmOverride>();
|
||||
/// <summary>Gets the set of instance connection bindings.</summary>
|
||||
public DbSet<InstanceConnectionBinding> InstanceConnectionBindings => Set<InstanceConnectionBinding>();
|
||||
/// <summary>Gets the set of areas.</summary>
|
||||
public DbSet<Area> Areas => Set<Area>();
|
||||
|
||||
// Sites
|
||||
/// <summary>Gets the set of sites.</summary>
|
||||
public DbSet<Site> Sites => Set<Site>();
|
||||
/// <summary>Gets the set of data connections.</summary>
|
||||
public DbSet<DataConnection> DataConnections => Set<DataConnection>();
|
||||
|
||||
// Deployment
|
||||
/// <summary>Gets the set of deployment records.</summary>
|
||||
public DbSet<DeploymentRecord> DeploymentRecords => Set<DeploymentRecord>();
|
||||
/// <summary>Gets the set of system artifact deployment records.</summary>
|
||||
public DbSet<SystemArtifactDeploymentRecord> SystemArtifactDeploymentRecords => Set<SystemArtifactDeploymentRecord>();
|
||||
/// <summary>Gets the set of deployed configuration snapshots.</summary>
|
||||
public DbSet<DeployedConfigSnapshot> DeployedConfigSnapshots => Set<DeployedConfigSnapshot>();
|
||||
|
||||
// External Systems
|
||||
/// <summary>Gets the set of external system definitions.</summary>
|
||||
public DbSet<ExternalSystemDefinition> ExternalSystemDefinitions => Set<ExternalSystemDefinition>();
|
||||
/// <summary>Gets the set of external system methods.</summary>
|
||||
public DbSet<ExternalSystemMethod> ExternalSystemMethods => Set<ExternalSystemMethod>();
|
||||
/// <summary>Gets the set of database connection definitions.</summary>
|
||||
public DbSet<DatabaseConnectionDefinition> DatabaseConnectionDefinitions => Set<DatabaseConnectionDefinition>();
|
||||
|
||||
// Notifications
|
||||
/// <summary>Gets the set of notification lists.</summary>
|
||||
public DbSet<NotificationList> NotificationLists => Set<NotificationList>();
|
||||
/// <summary>Gets the set of notification recipients.</summary>
|
||||
public DbSet<NotificationRecipient> NotificationRecipients => Set<NotificationRecipient>();
|
||||
/// <summary>Gets the set of SMTP configurations.</summary>
|
||||
public DbSet<SmtpConfiguration> SmtpConfigurations => Set<SmtpConfiguration>();
|
||||
/// <summary>Gets the set of notifications.</summary>
|
||||
public DbSet<Notification> Notifications => Set<Notification>();
|
||||
|
||||
// Scripts
|
||||
/// <summary>Gets the set of shared scripts.</summary>
|
||||
public DbSet<SharedScript> SharedScripts => Set<SharedScript>();
|
||||
|
||||
// Security
|
||||
/// <summary>Gets the set of LDAP group mappings.</summary>
|
||||
public DbSet<LdapGroupMapping> LdapGroupMappings => Set<LdapGroupMapping>();
|
||||
/// <summary>Gets the set of site scope rules.</summary>
|
||||
public DbSet<SiteScopeRule> SiteScopeRules => Set<SiteScopeRule>();
|
||||
|
||||
// Inbound API
|
||||
/// <summary>Gets the set of API keys.</summary>
|
||||
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
|
||||
/// <summary>Gets the set of API methods.</summary>
|
||||
public DbSet<ApiMethod> ApiMethods => Set<ApiMethod>();
|
||||
|
||||
// Audit
|
||||
/// <summary>Gets the set of audit log entries.</summary>
|
||||
public DbSet<AuditLogEntry> AuditLogEntries => Set<AuditLogEntry>();
|
||||
/// <summary>Gets the set of audit logs.</summary>
|
||||
public DbSet<AuditEvent> AuditLogs => Set<AuditEvent>();
|
||||
/// <summary>Gets the set of site calls.</summary>
|
||||
public DbSet<SiteCall> SiteCalls => Set<SiteCall>();
|
||||
|
||||
// Data Protection Keys (for shared ASP.NET Data Protection across nodes)
|
||||
/// <summary>Gets the set of data protection keys.</summary>
|
||||
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
base.OnConfiguring(optionsBuilder);
|
||||
|
||||
// The secret-column converter built in OnModelCreating differs depending on whether
|
||||
// a real Data Protection provider was supplied (encrypting converter) or not
|
||||
// (schema-only converter). EF Core's default model cache keys only on context type,
|
||||
// so a provider-bearing and a schema-only context sharing the same options type
|
||||
// would otherwise share one cached model — and whichever was built first would win.
|
||||
// Distinguish them so each gets its own model.
|
||||
optionsBuilder.ReplaceService<IModelCacheKeyFactory, SecretAwareModelCacheKeyFactory>();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ScadaBridgeDbContext).Assembly);
|
||||
|
||||
ApplySecretColumnEncryption(modelBuilder);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int SaveChanges(bool acceptAllChangesOnSuccess)
|
||||
{
|
||||
GuardSecretWritesHaveAKeyRing();
|
||||
return base.SaveChanges(acceptAllChangesOnSuccess);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<int> SaveChangesAsync(
|
||||
bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
|
||||
{
|
||||
GuardSecretWritesHaveAKeyRing();
|
||||
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>True when this context was constructed with a real Data Protection provider.</summary>
|
||||
internal bool HasSecretEncryptionProvider => _dataProtectionProvider is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Fails fast — before any database round-trip — if a context built without a real
|
||||
/// Data Protection key ring (the schema-only single-argument constructor) is about to
|
||||
/// persist a non-null secret-bearing column. Without this guard the schema-only
|
||||
/// protector would still throw, but only deep inside EF's update pipeline wrapped in a
|
||||
/// <c>DbUpdateException</c>/<c>CryptographicException</c>; surfacing a clear
|
||||
/// <see cref="InvalidOperationException"/> here makes the misconfiguration obvious.
|
||||
/// </summary>
|
||||
private void GuardSecretWritesHaveAKeyRing()
|
||||
{
|
||||
if (_dataProtectionProvider is not null)
|
||||
return;
|
||||
|
||||
foreach (var entry in ChangeTracker.Entries())
|
||||
{
|
||||
if (entry.State is not (EntityState.Added or EntityState.Modified))
|
||||
continue;
|
||||
|
||||
string? secretProperty = entry.Entity switch
|
||||
{
|
||||
SmtpConfiguration => nameof(SmtpConfiguration.Credentials),
|
||||
ExternalSystemDefinition => nameof(ExternalSystemDefinition.AuthConfiguration),
|
||||
DatabaseConnectionDefinition => nameof(DatabaseConnectionDefinition.ConnectionString),
|
||||
_ => null
|
||||
};
|
||||
if (secretProperty is null)
|
||||
continue;
|
||||
|
||||
if (entry.Property(secretProperty).CurrentValue is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"This ScadaBridgeDbContext was constructed without a Data Protection key " +
|
||||
"ring (the single-argument, schema-only constructor). It cannot persist " +
|
||||
$"the secret-bearing column '{entry.Entity.GetType().Name}.{secretProperty}'. " +
|
||||
"Construct the context with the DI-registered IDataProtectionProvider " +
|
||||
"(AddConfigurationDatabase wires this up).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Model cache key factory that folds <see cref="HasSecretEncryptionProvider"/> into the
|
||||
/// cache key, so a write-capable (provider-bearing) context and a schema-only context do
|
||||
/// not share a cached model.
|
||||
/// </summary>
|
||||
private sealed class SecretAwareModelCacheKeyFactory : IModelCacheKeyFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a model cache key that includes the Data Protection provider state.
|
||||
/// </summary>
|
||||
/// <param name="context">The database context.</param>
|
||||
/// <param name="designTime">Whether the model is being created at design-time.</param>
|
||||
/// <returns>A cache key tuple.</returns>
|
||||
public object Create(DbContext context, bool designTime)
|
||||
=> (context.GetType(),
|
||||
designTime,
|
||||
(context as ScadaBridgeDbContext)?.HasSecretEncryptionProvider ?? false);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a model cache key for run-time contexts.
|
||||
/// </summary>
|
||||
/// <param name="context">The database context.</param>
|
||||
/// <returns>A cache key tuple.</returns>
|
||||
public object Create(DbContext context) => Create(context, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies encryption-at-rest to columns that hold authentication secrets
|
||||
/// (SMTP credentials, external-system auth config, database connection strings)
|
||||
/// so they are never persisted as plaintext.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When no Data Protection provider is supplied — design-time <c>dotnet ef</c> tooling,
|
||||
/// which only emits schema and never reads or writes secret data — a schema-only
|
||||
/// protector is used. It produces an identical <c>nvarchar</c> schema, but throws a
|
||||
/// clear <see cref="InvalidOperationException"/> if a secret column is ever read or
|
||||
/// written through it. This deliberately does NOT silently substitute an ephemeral
|
||||
/// (in-memory, process-lifetime) key: encrypting a runtime write with a throwaway key
|
||||
/// would persist ciphertext that becomes permanently undecryptable on the next process
|
||||
/// restart, with no error. A write-capable context must be constructed with the
|
||||
/// DI-registered provider whose keys are persisted to this database.
|
||||
/// </remarks>
|
||||
private void ApplySecretColumnEncryption(ModelBuilder modelBuilder)
|
||||
{
|
||||
IDataProtector protector = _dataProtectionProvider is { } provider
|
||||
? provider.CreateProtector(EncryptedStringConverter.ProtectorPurpose)
|
||||
: SchemaOnlyDataProtector.Instance;
|
||||
|
||||
// Held as the non-generic ValueConverter base so all three HasConversion calls
|
||||
// read identically. The converter's CLR type is string?->string?; binding it to
|
||||
// the non-nullable DatabaseConnectionDefinition.ConnectionString property would
|
||||
// otherwise raise a CS8620 nullability mismatch — the non-generic reference is
|
||||
// the supported way to apply one converter uniformly across nullable and
|
||||
// non-nullable string columns.
|
||||
ValueConverter converter = new EncryptedStringConverter(protector);
|
||||
|
||||
modelBuilder.Entity<SmtpConfiguration>()
|
||||
.Property(s => s.Credentials)
|
||||
.HasConversion(converter);
|
||||
|
||||
modelBuilder.Entity<ExternalSystemDefinition>()
|
||||
.Property(e => e.AuthConfiguration)
|
||||
.HasConversion(converter);
|
||||
|
||||
modelBuilder.Entity<DatabaseConnectionDefinition>()
|
||||
.Property(d => d.ConnectionString)
|
||||
.HasConversion(converter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IDataProtector"/> for contexts built without a real Data Protection
|
||||
/// provider (design-time / schema-only). It satisfies model building but fails fast
|
||||
/// with a clear message if a secret column is actually read or written, rather than
|
||||
/// silently producing throwaway ciphertext that cannot be decrypted after a restart.
|
||||
/// </summary>
|
||||
private sealed class SchemaOnlyDataProtector : IDataProtector
|
||||
{
|
||||
internal static readonly SchemaOnlyDataProtector Instance = new();
|
||||
|
||||
private const string Message =
|
||||
"This ScadaBridgeDbContext was constructed without a Data Protection key ring " +
|
||||
"(the single-argument, schema-only constructor). Secret-bearing configuration " +
|
||||
"columns cannot be read or written through it. Construct the context with the " +
|
||||
"DI-registered IDataProtectionProvider (AddConfigurationDatabase wires this up).";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a schema-only protector that raises when called.
|
||||
/// </summary>
|
||||
/// <param name="purpose">The protector purpose string.</param>
|
||||
/// <returns>This instance.</returns>
|
||||
public IDataProtector CreateProtector(string purpose) => this;
|
||||
|
||||
/// <summary>
|
||||
/// Protects plaintext (schema-only version always throws).
|
||||
/// </summary>
|
||||
/// <param name="plaintext">The data to protect.</param>
|
||||
/// <returns>Never returns.</returns>
|
||||
public byte[] Protect(byte[] plaintext) => throw new InvalidOperationException(Message);
|
||||
|
||||
/// <summary>
|
||||
/// Unprotects ciphertext (schema-only version always throws).
|
||||
/// </summary>
|
||||
/// <param name="protectedData">The protected data.</param>
|
||||
/// <returns>Never returns.</returns>
|
||||
public byte[] Unprotect(byte[] protectedData) => throw new InvalidOperationException(Message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the ScadaBridgeDbContext with the provided SQL Server connection string.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to register into.</param>
|
||||
/// <param name="connectionString">SQL Server connection string for the central configuration database.</param>
|
||||
public static IServiceCollection AddConfigurationDatabase(this IServiceCollection services, string connectionString)
|
||||
{
|
||||
// The DbContext is constructed via the (options, IDataProtectionProvider) overload so
|
||||
// secret-bearing configuration columns are encrypted at rest. AddDataProtection below
|
||||
// registers IDataProtectionProvider as a singleton; resolving it here does not recurse
|
||||
// because key-ring loading is lazy (first Protect/Unprotect), not triggered by
|
||||
// CreateProtector during model building.
|
||||
services.AddDbContext<ScadaBridgeDbContext>((serviceProvider, options) =>
|
||||
{
|
||||
options.UseSqlServer(connectionString)
|
||||
.ConfigureWarnings(w => w.Ignore(
|
||||
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning));
|
||||
});
|
||||
|
||||
// AddDbContext registers ScadaBridgeDbContext via EF's activator, which only injects
|
||||
// DbContextOptions. Override that registration (last registration wins for resolution)
|
||||
// with a factory that also supplies the IDataProtectionProvider, so the encrypting
|
||||
// value converter for secret columns is always wired up at runtime.
|
||||
services.AddScoped(serviceProvider =>
|
||||
{
|
||||
var options = serviceProvider.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>();
|
||||
var protectionProvider = serviceProvider.GetRequiredService<IDataProtectionProvider>();
|
||||
return new ScadaBridgeDbContext(options, protectionProvider);
|
||||
});
|
||||
|
||||
services.AddScoped<ISecurityRepository, SecurityRepository>();
|
||||
services.AddScoped<ICentralUiRepository, CentralUiRepository>();
|
||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||
services.AddScoped<IDeploymentManagerRepository, DeploymentManagerRepository>();
|
||||
services.AddScoped<ISiteRepository, SiteRepository>();
|
||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||
services.AddScoped<INotificationOutboxRepository, NotificationOutboxRepository>();
|
||||
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
|
||||
services.AddScoped<ISiteCallAuditRepository, SiteCallAuditRepository>();
|
||||
// CD-016: factory registration wires a lazy accessor for IApiKeyHasher so
|
||||
// the production peppered hasher is used (via DI) when GetApiKeyByValueAsync
|
||||
// is actually called, but composition roots that never call it (and may
|
||||
// not register IApiKeyHasher at all) still bring up the repository.
|
||||
services.AddScoped<IInboundApiRepository>(sp => new InboundApiRepository(
|
||||
sp.GetRequiredService<ScadaBridgeDbContext>(),
|
||||
hasherAccessor: () => sp.GetService<Commons.Types.InboundApi.IApiKeyHasher>()
|
||||
?? Commons.Types.InboundApi.ApiKeyHasher.Default,
|
||||
logger: sp.GetService<ILogger<InboundApiRepository>>()));
|
||||
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
||||
services.AddScoped<IAuditService, AuditService>();
|
||||
services.AddScoped<IInstanceLocator, InstanceLocator>();
|
||||
|
||||
// #23 M6 Bundle D: IPartitionMaintenance drives the daily roll-forward
|
||||
// of pf_AuditLog_Month from the central AuditLogPartitionMaintenanceService
|
||||
// hosted service. Scoped because the implementation reuses the per-scope
|
||||
// ScadaBridgeDbContext for raw-SQL execution; the hosted service opens a
|
||||
// fresh scope on each tick (mirrors AuditLogPurgeActor / AuditLogIngestActor).
|
||||
services.AddScoped<IPartitionMaintenance, AuditLogPartitionMaintenance>();
|
||||
|
||||
services.AddDataProtection()
|
||||
.PersistKeysToDbContext<ScadaBridgeDbContext>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Obsolete parameterless overload. This previously registered nothing, which meant a
|
||||
/// central node wired up with it failed late and opaquely — the first repository
|
||||
/// resolution threw a DI exception far from the actual misconfiguration. Use
|
||||
/// <see cref="AddConfigurationDatabase(IServiceCollection, string)"/> and pass the
|
||||
/// configured connection string.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection (unused; this overload always throws).</param>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Always thrown. The connection string is required; there is no valid no-op registration.
|
||||
/// </exception>
|
||||
[Obsolete(
|
||||
"AddConfigurationDatabase() with no connection string registers nothing and is not a " +
|
||||
"valid configuration. Call AddConfigurationDatabase(connectionString) instead.",
|
||||
error: true)]
|
||||
public static IServiceCollection AddConfigurationDatabase(this IServiceCollection services)
|
||||
{
|
||||
// Defence-in-depth: even if a caller suppresses the compile-time obsolete error,
|
||||
// fail fast at wire-up time rather than silently registering nothing and surfacing
|
||||
// an opaque DI resolution failure much later.
|
||||
throw new InvalidOperationException(
|
||||
"AddConfigurationDatabase() requires a connection string. Call " +
|
||||
"AddConfigurationDatabase(connectionString) with the configured " +
|
||||
"'ScadaBridge:Database:ConfigurationDb' value.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Holder for the active bundle import id, backed by an <see cref="AsyncLocal{T}"/>
|
||||
/// so each logical asynchronous call chain observes its own value. AuditService
|
||||
/// reads it while writing AuditLogEntry rows.
|
||||
/// <para>
|
||||
/// Thread-safety / concurrency contract (Transport-009): the previous Scoped
|
||||
/// instance with a plain auto-property mutated by <c>BundleImporter.ApplyAsync</c>
|
||||
/// was vulnerable to cross-contamination if two imports ran concurrently inside
|
||||
/// a shared DI scope — either via <c>Task.WhenAll</c> on a single Blazor circuit
|
||||
/// or via a misconfigured singleton registration. Backing the property with
|
||||
/// <see cref="AsyncLocal{T}"/> means every fresh logical-call-context — every
|
||||
/// distinct <c>ApplyAsync</c> invocation, even ones sharing the same DI scope —
|
||||
/// gets its own independent value, and the value flows naturally through every
|
||||
/// <c>await</c> in the chain. Concurrent imports no longer leak BundleImportIds
|
||||
/// across audit rows.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The class is still registered as Scoped so injection works with the existing
|
||||
/// DI graph, but its in-memory state is per-call-context regardless of lifetime.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class AuditCorrelationContext : IAuditCorrelationContext
|
||||
{
|
||||
private static readonly AsyncLocal<Guid?> _bundleImportId = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid? BundleImportId
|
||||
{
|
||||
get => _bundleImportId.Value;
|
||||
set => _bundleImportId.Value = value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
||||
|
||||
public class AuditService : IAuditService
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
private readonly IAuditCorrelationContext _correlationContext;
|
||||
|
||||
/// <summary>
|
||||
/// Serializer options for audit <c>afterState</c> payloads. Audit writes commit in the
|
||||
/// same transaction as the change they record, so a serialization exception here would
|
||||
/// roll back the entire business operation. Reference cycles (common when an EF entity
|
||||
/// with loaded navigations is passed in) are ignored rather than thrown, and depth is
|
||||
/// bounded so a pathological graph cannot produce an unbounded payload.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions AuditSerializerOptions = new()
|
||||
{
|
||||
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles,
|
||||
MaxDepth = 32
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the audit service with the EF Core context and correlation context.
|
||||
/// </summary>
|
||||
/// <param name="context">The EF Core database context used to stage audit entries.</param>
|
||||
/// <param name="correlationContext">Provides the active bundle import id for audit row stamping.</param>
|
||||
public AuditService(ScadaBridgeDbContext context, IAuditCorrelationContext correlationContext)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_correlationContext = correlationContext ?? throw new ArgumentNullException(nameof(correlationContext));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task LogAsync(
|
||||
string user,
|
||||
string action,
|
||||
string entityType,
|
||||
string entityId,
|
||||
string entityName,
|
||||
object? afterState,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entry = new AuditLogEntry(user, action, entityType, entityId, entityName)
|
||||
{
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
AfterStateJson = afterState != null
|
||||
? SerializeAfterState(afterState)
|
||||
: null,
|
||||
// Stamp the active bundle import id (if any) so audit rows emitted during a
|
||||
// bundle import are attributable to that import session. Null in the normal
|
||||
// interactive code path.
|
||||
BundleImportId = _correlationContext.BundleImportId
|
||||
};
|
||||
|
||||
// Add to change tracker only — caller is responsible for calling SaveChangesAsync
|
||||
// to ensure atomicity with the entity change.
|
||||
await _context.AuditLogEntries.AddAsync(entry, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the caller-supplied after-state, tolerating arbitrary object shapes.
|
||||
/// Reference cycles are ignored via <see cref="AuditSerializerOptions"/>. If serialization
|
||||
/// still fails (e.g. <c>MaxDepth</c> exceeded), the audit entry is preserved with a
|
||||
/// diagnostic placeholder rather than throwing — a serialization failure must never
|
||||
/// roll back the business operation the audit entry is recording.
|
||||
/// </summary>
|
||||
private static string SerializeAfterState(object afterState)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Serialize(afterState, AuditSerializerOptions);
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or NotSupportedException)
|
||||
{
|
||||
return JsonSerializer.Serialize(new
|
||||
{
|
||||
AuditSerializationError = ex.Message,
|
||||
StateType = afterState.GetType().FullName
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves instance unique names to site identifiers using the configuration database.
|
||||
/// </summary>
|
||||
public class InstanceLocator : IInstanceLocator
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
|
||||
/// <summary>Initializes the locator with the EF Core database context.</summary>
|
||||
/// <param name="context">The database context used to look up instances and sites.</param>
|
||||
public InstanceLocator(ScadaBridgeDbContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string?> GetSiteIdForInstanceAsync(
|
||||
string instanceUniqueName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _context.Set<Commons.Entities.Instances.Instance>()
|
||||
.FirstOrDefaultAsync(i => i.UniqueName == instanceUniqueName, cancellationToken);
|
||||
|
||||
if (instance == null)
|
||||
return null;
|
||||
|
||||
var site = await _context.Set<Commons.Entities.Sites.Site>()
|
||||
.FindAsync(new object[] { instance.SiteId }, cancellationToken);
|
||||
|
||||
return site?.SiteIdentifier;
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user