feat(audit)!: ScadaBridge C5 — collapse central dbo.AuditLog to 10 canonical cols + persisted computed cols; CollapseAuditLogToCanonical migration; repo writes canonical directly (Task 2.5)
This commit is contained in:
+131
-72
@@ -6,11 +6,12 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// Maps the <see cref="AuditLogRow"/> persistence shape to the central <c>AuditLog</c>
|
||||
/// table described in alog.md §4. Column lengths/types and the named indexes are
|
||||
/// fixed by that specification — keep this in sync with the doc. C3 (Task 2.5) kept
|
||||
/// the table unchanged; the canonical record is mapped onto this row at the repository
|
||||
/// boundary via <c>AuditRowProjection</c>.
|
||||
/// Maps the C5 (Task 2.5) <see cref="AuditLogRow"/> persistence shape to the central
|
||||
/// <c>dbo.AuditLog</c> table: the 10 canonical <c>ZB.MOM.WW.Audit.AuditEvent</c> columns
|
||||
/// (writable) plus six read-only, server-side <b>persisted computed columns</b> derived
|
||||
/// from <c>DetailsJson</c> via <c>JSON_VALUE</c>. The computed-column SQL and the index
|
||||
/// set here mirror the <c>CollapseAuditLogToCanonical</c> migration's
|
||||
/// <c>dbo.AuditLog_v2</c> DDL byte-for-byte — keep them in sync.
|
||||
/// </summary>
|
||||
public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditLogRow>
|
||||
{
|
||||
@@ -35,88 +36,146 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditLog
|
||||
: null,
|
||||
v => v.HasValue ? DateTime.SpecifyKind(v.Value, DateTimeKind.Utc) : null);
|
||||
|
||||
// The computed columns derive enum-named strings from DetailsJson (e.g.
|
||||
// JSON_VALUE(...,'$.kind') == "CachedResolve"), exactly the value
|
||||
// HasConversion<string>() expects on read. The repository never writes these
|
||||
// (they are server-computed), but the string<->enum converter is still
|
||||
// required so EF materialises them as the strongly-typed enum a LINQ
|
||||
// predicate like `e.Kind == AuditKind.CachedResolve` translates against.
|
||||
|
||||
/// <summary>Applies the EF Core type configuration for <see cref="AuditLogRow"/> to the model builder.</summary>
|
||||
/// <param name="builder">The entity type builder to configure.</param>
|
||||
public void Configure(EntityTypeBuilder<AuditLogRow> 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);
|
||||
// ── Canonical columns (writable) ─────────────────────────────────────
|
||||
//
|
||||
// Column SQL TYPES are intentionally left to EF's relational conventions
|
||||
// (driven by HasMaxLength / IsUnicode / the CLR type) rather than pinned
|
||||
// with HasColumnType, so the SAME configuration maps to SQL Server
|
||||
// (varchar(n) / nvarchar(n) / uniqueidentifier / datetime2) in production
|
||||
// AND to the SQLite test provider (TEXT) without a `(max)`/`uniqueidentifier`
|
||||
// literal leaking into SQLite DDL. The migration's raw DDL pins the exact
|
||||
// SQL Server types; EF's conventions agree with them (verified clean via
|
||||
// `has-pending-model-changes`).
|
||||
|
||||
// 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);
|
||||
// Enforce DateTimeKind.Utc on the OccurredAtUtc column. See the
|
||||
// UtcConverter remarks above for the rationale.
|
||||
builder.Property(e => e.OccurredAtUtc)
|
||||
.HasConversion(UtcConverter);
|
||||
|
||||
builder.Property(e => e.Actor)
|
||||
.HasMaxLength(128)
|
||||
.IsUnicode(false);
|
||||
.HasMaxLength(256);
|
||||
|
||||
builder.Property(e => e.Action)
|
||||
.HasMaxLength(64)
|
||||
.IsUnicode(false)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.Outcome)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(16)
|
||||
.IsUnicode(false)
|
||||
.IsRequired();
|
||||
|
||||
// Channel rides in the canonical Category column (Category = channel name
|
||||
// for ScadaBridge). Stored as the enum's name in varchar(32); the
|
||||
// string<->enum converter lets `e.Channel == AuditChannel.X` translate to
|
||||
// `[Category] = 'X'` server-side.
|
||||
builder.Property(e => e.Channel)
|
||||
.HasColumnName("Category")
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(32)
|
||||
.IsUnicode(false)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.Target)
|
||||
.HasMaxLength(256)
|
||||
.IsUnicode(false);
|
||||
.HasMaxLength(256);
|
||||
|
||||
// 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);
|
||||
// DetailsJson: unbounded → nvarchar(max) on SQL Server, TEXT on SQLite.
|
||||
// (No HasMaxLength / HasColumnType — let conventions pick per provider.)
|
||||
|
||||
// ErrorDetail, RequestSummary, ResponseSummary, Extra: leave as nvarchar(max).
|
||||
// ── Persisted computed columns (read-only; derived from DetailsJson) ──
|
||||
//
|
||||
// Each is `… AS <expr> PERSISTED`; EF must never attempt to write them
|
||||
// (ValueGeneratedOnAddOrUpdate + metadata-only mapping). The SQL strings
|
||||
// here MUST match the migration's dbo.AuditLog_v2 DDL exactly so
|
||||
// `dotnet ef migrations has-pending-model-changes` stays clean. The SQLite
|
||||
// test context strips the computed-column SQL (JSON_VALUE is unknown to
|
||||
// SQLite) so EnsureCreated still works.
|
||||
|
||||
// Indexes — names locked to alog.md §4 for reconciliation/migration discoverability.
|
||||
builder.Property(e => e.Kind)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(32)
|
||||
.IsUnicode(false)
|
||||
.HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.kind')", stored: true)
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.Status)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(32)
|
||||
.IsUnicode(false)
|
||||
.HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.status')", stored: true)
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.SourceSiteId)
|
||||
.HasMaxLength(64)
|
||||
.IsUnicode(false)
|
||||
.HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.sourceSiteId')", stored: true)
|
||||
.ValueGeneratedOnAddOrUpdate();
|
||||
|
||||
builder.Property(e => e.ExecutionId)
|
||||
.HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier)", stored: true)
|
||||
.ValueGeneratedOnAddOrUpdate();
|
||||
|
||||
builder.Property(e => e.ParentExecutionId)
|
||||
.HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier)", stored: true)
|
||||
.ValueGeneratedOnAddOrUpdate();
|
||||
|
||||
// IngestedAtUtc rides in DetailsJson as an ISO-8601-with-offset string
|
||||
// (always +00:00 — the codec normalises to UTC). SWITCHOFFSET(...,0)
|
||||
// normalises any offset to UTC before the datetime2 cast, so the column
|
||||
// is the UTC wall-clock regardless. The datetimeoffset cast / SWITCHOFFSET
|
||||
// is NON-DETERMINISTic to SQL Server, so this computed column is NOT
|
||||
// persisted (stored:false) — a PERSISTED non-deterministic column is
|
||||
// rejected at CREATE. It is not indexed, so non-persistence costs nothing.
|
||||
// Routed through the nullable UTC converter so the materialised value
|
||||
// carries Kind=Utc.
|
||||
builder.Property(e => e.IngestedAtUtc)
|
||||
.HasColumnType("datetime2(7)")
|
||||
.HasConversion(NullableUtcConverter)
|
||||
.HasComputedColumnSql(
|
||||
"CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7))",
|
||||
stored: false)
|
||||
.ValueGeneratedOnAddOrUpdate();
|
||||
|
||||
// ── Keys + indexes ───────────────────────────────────────────────────
|
||||
|
||||
// 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 (non-aligned) 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");
|
||||
|
||||
// Index names are locked for reconciliation/migration discoverability. The
|
||||
// column SETS migrate to the canonical/computed shape (alog.md §4 semantics
|
||||
// preserved): Channel→Category, Site/Node/Execution/ParentExecution now read
|
||||
// off the computed columns.
|
||||
builder.HasIndex(e => e.OccurredAtUtc)
|
||||
.IsDescending(true)
|
||||
.HasDatabaseName("IX_AuditLog_OccurredAtUtc");
|
||||
@@ -129,22 +188,22 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditLog
|
||||
.HasFilter("[CorrelationId] IS NOT NULL")
|
||||
.HasDatabaseName("IX_AuditLog_CorrelationId");
|
||||
|
||||
// ExecutionId / ParentExecutionId are persisted computed columns: SQL Server
|
||||
// forbids a filtered-index WHERE predicate referencing a computed column, so
|
||||
// these two indexes are UNFILTERED (they also index NULL rows; equality
|
||||
// lookups are unaffected). Keep HasFilter(null) so the model matches the
|
||||
// migration DDL exactly.
|
||||
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");
|
||||
|
||||
// IX_AuditLog_Channel_Status_Occurred name preserved; columns are now the
|
||||
// canonical Category (= channel) + computed Status + OccurredAtUtc.
|
||||
builder.HasIndex(e => new { e.Channel, e.Status, e.OccurredAtUtc })
|
||||
.IsDescending(false, false, true)
|
||||
.HasDatabaseName("IX_AuditLog_Channel_Status_Occurred");
|
||||
|
||||
Reference in New Issue
Block a user