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");
|
||||
|
||||
@@ -1,20 +1,41 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using AuditOutcome = ZB.MOM.WW.Audit.AuditOutcome;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Transitional EF Core persistence shape for the central <c>dbo.AuditLog</c> table
|
||||
/// (Audit Log #23). This is the 24-column row formerly modelled by
|
||||
/// <c>ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.AuditEvent</c>; in C3 (Task 2.5)
|
||||
/// the canonical <c>ZB.MOM.WW.Audit.AuditEvent</c> became the type at every seam,
|
||||
/// emit site, DTO boundary, and redactor, and this row type was relocated here as a
|
||||
/// storage-only entity so the existing table keeps working unchanged.
|
||||
/// EF Core persistence shape for the central <c>dbo.AuditLog</c> table after the
|
||||
/// C5 collapse (Audit Log #23, Task 2.5). The table is now the 10 canonical
|
||||
/// <c>ZB.MOM.WW.Audit.AuditEvent</c> fields stored DIRECTLY plus a set of
|
||||
/// read-only, server-side <b>persisted computed columns</b> derived from
|
||||
/// <see cref="DetailsJson"/> (<c>JSON_VALUE</c> … <c>PERSISTED</c>) so the
|
||||
/// reporting queries stay indexable without re-parsing JSON.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The repository maps canonical ⇄ this row at the persistence boundary via
|
||||
/// <c>ZB.MOM.WW.ScadaBridge.Commons.Types.Audit.AuditRowProjection</c>. C5 replaces
|
||||
/// this shim + table with the real DetailsJson-backed schema.
|
||||
/// <b>C5 (Task 2.5).</b> The transitional 24-typed-column shim is retired. The
|
||||
/// repository writes the 10 canonical columns directly (no <c>Decompose</c>) and
|
||||
/// the computed columns auto-derive at INSERT; reads build the canonical
|
||||
/// <c>AuditEvent</c> straight off the canonical columns (no <c>Recompose</c>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Canonical columns (writable):</b> <see cref="EventId"/>,
|
||||
/// <see cref="OccurredAtUtc"/>, <see cref="Actor"/>, <see cref="Action"/>,
|
||||
/// <see cref="Outcome"/>, <see cref="Channel"/> (the canonical <c>Category</c>
|
||||
/// column — for ScadaBridge, Category = channel name), <see cref="Target"/>,
|
||||
/// <see cref="SourceNode"/>, <see cref="CorrelationId"/>,
|
||||
/// <see cref="DetailsJson"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Persisted computed columns (read-only):</b> <see cref="Kind"/>,
|
||||
/// <see cref="Status"/>, <see cref="SourceSiteId"/>, <see cref="ExecutionId"/>,
|
||||
/// <see cref="ParentExecutionId"/> (the five spec'd queryability columns), plus
|
||||
/// <see cref="IngestedAtUtc"/> (central ingest timestamp, also a DetailsJson
|
||||
/// field). These are populated by SQL Server from <see cref="DetailsJson"/>; EF
|
||||
/// never writes them. Their getters expose them as typed
|
||||
/// (enum / <see cref="Guid"/> / <see cref="DateTime"/>) properties so the
|
||||
/// existing LINQ filter/aggregate queries keep their meaning; the value
|
||||
/// converters that turn enum names ⇄ varchar match the JSON_VALUE string output.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// All <c>*Utc</c>-suffixed <see cref="DateTime"/> properties are invariantly UTC
|
||||
@@ -27,6 +48,8 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||
/// </remarks>
|
||||
public sealed record AuditLogRow
|
||||
{
|
||||
// ── Canonical columns (the 10 ZB.MOM.WW.Audit.AuditEvent fields) ──────────
|
||||
|
||||
/// <summary>Idempotency key; uniquely identifies one audit lifecycle event.</summary>
|
||||
public Guid EventId { get; init; }
|
||||
|
||||
@@ -38,7 +61,75 @@ public sealed record AuditLogRow
|
||||
}
|
||||
private readonly DateTime _occurredAtUtc;
|
||||
|
||||
/// <summary>UTC timestamp when the row was ingested at central; null on the site hot-path.</summary>
|
||||
/// <summary>Authenticated actor for inbound paths (API key name, user, etc.); null/empty for system/anon.</summary>
|
||||
public string? Actor { get; init; }
|
||||
|
||||
/// <summary>Canonical action verb — <c>"{channel}.{kind}"</c> (e.g. <c>ApiOutbound.ApiCall</c>).</summary>
|
||||
public string Action { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Normalized canonical outcome (Success / Failure / Denied).</summary>
|
||||
public AuditOutcome Outcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust-boundary channel the audited action crossed. Stored in the canonical
|
||||
/// <c>Category</c> column (for ScadaBridge the canonical Category IS the channel
|
||||
/// name); exposed here as the strongly-typed <see cref="AuditChannel"/> enum.
|
||||
/// </summary>
|
||||
public AuditChannel Channel { get; init; }
|
||||
|
||||
/// <summary>Target of the action: external system name, db connection name, list name, or inbound method.</summary>
|
||||
public string? Target { get; init; }
|
||||
|
||||
/// <summary>The cluster node on which the event was emitted.</summary>
|
||||
public string? SourceNode { get; init; }
|
||||
|
||||
/// <summary>Correlation id linking related audit rows (e.g. the cached-op lifecycle).</summary>
|
||||
public Guid? CorrelationId { get; init; }
|
||||
|
||||
/// <summary>Canonical JSON extension bag carrying every ScadaBridge domain field.</summary>
|
||||
public string? DetailsJson { get; init; }
|
||||
|
||||
// ── Persisted computed columns (read-only; derived from DetailsJson) ──────
|
||||
|
||||
/// <summary>
|
||||
/// Specific event kind. Computed column <c>JSON_VALUE(DetailsJson,'$.kind')</c>
|
||||
/// PERSISTED; read-only (the DB derives it on INSERT).
|
||||
/// </summary>
|
||||
public AuditKind Kind { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Lifecycle status. Computed column <c>JSON_VALUE(DetailsJson,'$.status')</c>
|
||||
/// PERSISTED; read-only.
|
||||
/// </summary>
|
||||
public AuditStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Site id where the action originated; null for central-direct events. Computed
|
||||
/// column <c>JSON_VALUE(DetailsJson,'$.sourceSiteId')</c> PERSISTED; read-only.
|
||||
/// </summary>
|
||||
public string? SourceSiteId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Id of the originating script execution / inbound request. Computed column
|
||||
/// <c>CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier)</c>
|
||||
/// PERSISTED; read-only.
|
||||
/// </summary>
|
||||
public Guid? ExecutionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ExecutionId of the execution that spawned this run; null for top-level runs.
|
||||
/// Computed column
|
||||
/// <c>CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier)</c>
|
||||
/// PERSISTED; read-only.
|
||||
/// </summary>
|
||||
public Guid? ParentExecutionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the central AuditLog store ingested this row; null until
|
||||
/// central stamps it. Computed column over
|
||||
/// <c>JSON_VALUE(DetailsJson,'$.ingestedAtUtc')</c> (normalized to UTC datetime2)
|
||||
/// PERSISTED; read-only.
|
||||
/// </summary>
|
||||
public DateTime? IngestedAtUtc
|
||||
{
|
||||
get => _ingestedAtUtc;
|
||||
@@ -47,67 +138,4 @@ public sealed record AuditLogRow
|
||||
: null;
|
||||
}
|
||||
private readonly DateTime? _ingestedAtUtc;
|
||||
|
||||
/// <summary>Trust-boundary channel the audited action crossed.</summary>
|
||||
public AuditChannel Channel { get; init; }
|
||||
|
||||
/// <summary>Specific event kind within the channel.</summary>
|
||||
public AuditKind Kind { get; init; }
|
||||
|
||||
/// <summary>Correlation id linking related audit rows (e.g. the cached-op lifecycle).</summary>
|
||||
public Guid? CorrelationId { get; init; }
|
||||
|
||||
/// <summary>Id of the originating script execution / inbound request.</summary>
|
||||
public Guid? ExecutionId { get; init; }
|
||||
|
||||
/// <summary>ExecutionId of the execution that spawned this run; null for top-level runs.</summary>
|
||||
public Guid? ParentExecutionId { get; init; }
|
||||
|
||||
/// <summary>Site id where the action originated; null for central-direct events.</summary>
|
||||
public string? SourceSiteId { get; init; }
|
||||
|
||||
/// <summary>The cluster node on which the event was emitted.</summary>
|
||||
public string? SourceNode { get; init; }
|
||||
|
||||
/// <summary>Instance id where the action originated, when applicable.</summary>
|
||||
public string? SourceInstanceId { get; init; }
|
||||
|
||||
/// <summary>Script that initiated the action, when applicable.</summary>
|
||||
public string? SourceScript { get; init; }
|
||||
|
||||
/// <summary>Authenticated actor for inbound paths (API key name, user, etc.).</summary>
|
||||
public string? Actor { get; init; }
|
||||
|
||||
/// <summary>Target of the action: external system name, db connection name, list name, or inbound method.</summary>
|
||||
public string? Target { get; init; }
|
||||
|
||||
/// <summary>Lifecycle status of this row.</summary>
|
||||
public AuditStatus Status { get; init; }
|
||||
|
||||
/// <summary>HTTP status code where applicable.</summary>
|
||||
public int? HttpStatus { get; init; }
|
||||
|
||||
/// <summary>Duration of the audited action in milliseconds, when measurable.</summary>
|
||||
public int? DurationMs { get; init; }
|
||||
|
||||
/// <summary>Human-readable error summary on failure rows.</summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>Verbose error detail (stack/exception) on failure rows.</summary>
|
||||
public string? ErrorDetail { get; init; }
|
||||
|
||||
/// <summary>Truncated/redacted request summary; capped per AuditLogOptions.</summary>
|
||||
public string? RequestSummary { get; init; }
|
||||
|
||||
/// <summary>Truncated/redacted response summary; capped per AuditLogOptions.</summary>
|
||||
public string? ResponseSummary { get; init; }
|
||||
|
||||
/// <summary>True when Request/Response summaries were truncated to the payload cap.</summary>
|
||||
public bool PayloadTruncated { get; init; }
|
||||
|
||||
/// <summary>Free-form JSON extension column for channel-specific extras.</summary>
|
||||
public string? Extra { get; init; }
|
||||
|
||||
/// <summary>Site-local forwarding state; null on central rows.</summary>
|
||||
public AuditForwardState? ForwardState { get; init; }
|
||||
}
|
||||
|
||||
+1724
File diff suppressed because it is too large
Load Diff
+244
@@ -0,0 +1,244 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// C5 of the ScadaBridge audit re-architecture (Task 2.5): collapses the central
|
||||
/// <c>dbo.AuditLog</c> table from the transitional 24 typed columns to the 10
|
||||
/// canonical <c>ZB.MOM.WW.Audit.AuditEvent</c> columns plus six read-only,
|
||||
/// server-side <b>persisted computed columns</b> derived from <c>DetailsJson</c>
|
||||
/// (<c>JSON_VALUE</c> … <c>PERSISTED</c>) that back the indexed reporting queries.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why new-table + copy (not in-place ALTER).</b> An in-place collapse is
|
||||
/// infeasible: the partition-aligned indexes are keyed on columns that are being
|
||||
/// dropped; the purge path (<c>SwitchOutPartitionAsync</c>) hard-codes a
|
||||
/// byte-identical staging column list; and dropping <c>nvarchar(max)</c> columns
|
||||
/// per partition is expensive. Instead this migration builds a fresh
|
||||
/// <c>dbo.AuditLog_v2</c> on the SAME preserved partition scheme
|
||||
/// (<c>ps_AuditLog_Month(OccurredAtUtc)</c>), copies every row with a one-way
|
||||
/// projection of the old typed columns into canonical columns + <c>DetailsJson</c>,
|
||||
/// drops the old table, renames v2 into place, and re-grants the append-only roles.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Down() is a documented ONE-WAY.</b> The projection of ~17 typed columns into
|
||||
/// a single JSON bag is lossy to reverse byte-for-byte (e.g. the codec's
|
||||
/// null-omission + key order, the Action/Category/Outcome derivation), so the
|
||||
/// reverse is NOT implemented. <see cref="Down"/> throws
|
||||
/// <see cref="System.NotSupportedException"/> with guidance to restore from backup.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Partition / SWITCH / computed-column interaction.</b> The new table and its
|
||||
/// non-clustered indexes are created directly on <c>ps_AuditLog_Month</c> so the
|
||||
/// partition-switch purge keeps working; the non-aligned <c>UX_AuditLog_EventId</c>
|
||||
/// stays on <c>[PRIMARY]</c> exactly as before. The persisted computed columns are
|
||||
/// part of the table's storage, so the staging table used by
|
||||
/// <c>SwitchOutPartitionAsync</c> MUST declare them with identical expressions —
|
||||
/// see that method (kept in sync with the v2 DDL below).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public partial class CollapseAuditLogToCanonical : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// 1) Create dbo.AuditLog_v2 on the PRESERVED partition scheme. Ten
|
||||
// canonical columns first (ordinal-stable), then the six persisted
|
||||
// computed columns. The clustered PK is composite {EventId,
|
||||
// OccurredAtUtc} (partition-aligned). The computed-column expressions
|
||||
// here are the single source of truth that AuditLogEntityTypeConfiguration
|
||||
// and SwitchOutPartitionAsync's staging table must mirror exactly.
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE TABLE dbo.AuditLog_v2 (
|
||||
EventId uniqueidentifier NOT NULL,
|
||||
OccurredAtUtc datetime2(7) NOT NULL,
|
||||
Actor nvarchar(256) NULL,
|
||||
Action varchar(64) NOT NULL,
|
||||
Outcome varchar(16) NOT NULL,
|
||||
Category varchar(32) NOT NULL,
|
||||
Target nvarchar(256) NULL,
|
||||
SourceNode varchar(64) NULL,
|
||||
CorrelationId uniqueidentifier NULL,
|
||||
DetailsJson nvarchar(max) NULL,
|
||||
Kind AS JSON_VALUE(DetailsJson,'$.kind') PERSISTED,
|
||||
Status AS JSON_VALUE(DetailsJson,'$.status') PERSISTED,
|
||||
SourceSiteId AS JSON_VALUE(DetailsJson,'$.sourceSiteId') PERSISTED,
|
||||
ExecutionId AS CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier) PERSISTED,
|
||||
ParentExecutionId AS CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier) PERSISTED,
|
||||
-- IngestedAtUtc is NOT persisted: the datetimeoffset cast / SWITCHOFFSET is
|
||||
-- non-deterministic and SQL Server rejects a PERSISTED non-deterministic
|
||||
-- computed column. It is not indexed, so non-persistence costs nothing.
|
||||
IngestedAtUtc AS CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7)),
|
||||
CONSTRAINT PK_AuditLog_v2 PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc)
|
||||
ON ps_AuditLog_Month(OccurredAtUtc)
|
||||
) ON ps_AuditLog_Month(OccurredAtUtc);");
|
||||
|
||||
// 2) Data copy: project the old 24-column rows into the canonical shape.
|
||||
// - Action = Channel + '.' + Kind (matches AuditFieldBuilders.BuildAction)
|
||||
// - Category = Channel (matches AuditFieldBuilders.BuildCategory)
|
||||
// - Outcome = AuditOutcomeProjector.Project(Status, Kind):
|
||||
// Kind='InboundAuthFailure' -> 'Denied' (wins over any status);
|
||||
// Status in ('Failed','Parked','Discarded') -> 'Failure'; else 'Success'.
|
||||
// - Actor : empty string maps to NULL (canonical Actor is non-null,
|
||||
// but the old column stored NULL for system/anon — keep NULL).
|
||||
// - DetailsJson: every domain field re-serialised as a single JSON object
|
||||
// with camelCase keys matching AuditDetailsCodec. FOR JSON
|
||||
// PATH, WITHOUT_ARRAY_WRAPPER OMITS null keys by default
|
||||
// (no INCLUDE_NULL_VALUES), matching the codec's
|
||||
// JsonIgnoreCondition.WhenWritingNull; channel/kind/status
|
||||
// are NOT NULL in the legacy table so they are always present
|
||||
// and emit the enum-name strings the computed Kind/Status
|
||||
// (and Channel-as-Category) derive from. payloadTruncated is a
|
||||
// non-null bit so it is always written, matching the codec
|
||||
// (which always writes the bool).
|
||||
// The six computed columns auto-derive from DetailsJson on INSERT.
|
||||
migrationBuilder.Sql(@"
|
||||
INSERT INTO dbo.AuditLog_v2
|
||||
(EventId, OccurredAtUtc, Actor, Action, Outcome, Category, Target, SourceNode, CorrelationId, DetailsJson)
|
||||
SELECT
|
||||
a.EventId,
|
||||
a.OccurredAtUtc,
|
||||
NULLIF(a.Actor, '') AS Actor,
|
||||
a.Channel + '.' + a.Kind AS Action,
|
||||
CASE
|
||||
WHEN a.Kind = 'InboundAuthFailure' THEN 'Denied'
|
||||
WHEN a.Status IN ('Failed','Parked','Discarded') THEN 'Failure'
|
||||
ELSE 'Success'
|
||||
END AS Outcome,
|
||||
a.Channel AS Category,
|
||||
a.Target,
|
||||
a.SourceNode,
|
||||
a.CorrelationId,
|
||||
(
|
||||
SELECT
|
||||
a.Channel AS channel,
|
||||
a.Kind AS kind,
|
||||
a.Status AS status,
|
||||
a.ExecutionId AS executionId,
|
||||
a.ParentExecutionId AS parentExecutionId,
|
||||
a.SourceSiteId AS sourceSiteId,
|
||||
a.SourceInstanceId AS sourceInstanceId,
|
||||
a.SourceScript AS sourceScript,
|
||||
a.HttpStatus AS httpStatus,
|
||||
a.DurationMs AS durationMs,
|
||||
a.ErrorMessage AS errorMessage,
|
||||
a.ErrorDetail AS errorDetail,
|
||||
a.RequestSummary AS requestSummary,
|
||||
a.ResponseSummary AS responseSummary,
|
||||
CAST(a.PayloadTruncated AS bit) AS payloadTruncated,
|
||||
a.Extra AS extra,
|
||||
CASE WHEN a.IngestedAtUtc IS NULL THEN NULL
|
||||
ELSE CONVERT(varchar(33),
|
||||
CAST(a.IngestedAtUtc AS datetimeoffset(7)) AT TIME ZONE 'UTC',
|
||||
126)
|
||||
END AS ingestedAtUtc
|
||||
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||
) AS DetailsJson
|
||||
FROM dbo.AuditLog a;");
|
||||
|
||||
// 3) Drop the old table and rename v2 into place. The table rename uses
|
||||
// the 'schema.table' form; the PK constraint rename uses the BARE
|
||||
// constraint name with @objtype='OBJECT' (a constraint is a schema-
|
||||
// scoped object, NOT table-qualified — a 'dbo.AuditLog.PK_…' form is
|
||||
// rejected as ambiguous). The non-clustered indexes were dropped with
|
||||
// the old table; they are recreated by name in step 4 (no index rename
|
||||
// needed because the v2 table was created without them).
|
||||
migrationBuilder.Sql(@"
|
||||
DROP TABLE dbo.AuditLog;
|
||||
EXEC sp_rename 'dbo.AuditLog_v2', 'AuditLog';
|
||||
EXEC sp_rename 'PK_AuditLog_v2', 'PK_AuditLog', 'OBJECT';");
|
||||
|
||||
// 4) Recreate the reconciliation/query indexes on the new shape, names
|
||||
// preserved (alog.md §4 semantics): Channel→Category, Site/Node read
|
||||
// off the canonical/computed columns. All non-clustered indexes are
|
||||
// partition-aligned on ps_AuditLog_Month(OccurredAtUtc) so the
|
||||
// partition-switch purge keeps touching a single partition. The
|
||||
// UX_AuditLog_EventId unique index is INTENTIONALLY non-aligned (on
|
||||
// [PRIMARY]) to give single-column EventId uniqueness for
|
||||
// InsertIfNotExistsAsync idempotency.
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE NONCLUSTERED INDEX IX_AuditLog_OccurredAtUtc
|
||||
ON dbo.AuditLog (OccurredAtUtc DESC)
|
||||
ON ps_AuditLog_Month(OccurredAtUtc);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_AuditLog_Site_Occurred
|
||||
ON dbo.AuditLog (SourceSiteId ASC, OccurredAtUtc DESC)
|
||||
ON ps_AuditLog_Month(OccurredAtUtc);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_AuditLog_CorrelationId
|
||||
ON dbo.AuditLog (CorrelationId)
|
||||
WHERE CorrelationId IS NOT NULL
|
||||
ON ps_AuditLog_Month(OccurredAtUtc);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_AuditLog_Channel_Status_Occurred
|
||||
ON dbo.AuditLog (Category ASC, Status ASC, OccurredAtUtc DESC)
|
||||
ON ps_AuditLog_Month(OccurredAtUtc);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_AuditLog_Target_Occurred
|
||||
ON dbo.AuditLog (Target ASC, OccurredAtUtc DESC)
|
||||
WHERE Target IS NOT NULL
|
||||
ON ps_AuditLog_Month(OccurredAtUtc);
|
||||
|
||||
-- IX_AuditLog_Execution / IX_AuditLog_ParentExecution are NOT filtered: SQL
|
||||
-- Server forbids a filtered-index WHERE predicate from referencing a computed
|
||||
-- column (ExecutionId / ParentExecutionId are persisted computed columns). An
|
||||
-- unfiltered index still backs the equality lookups GetExecutionTreeAsync uses;
|
||||
-- it just also indexes the NULL rows. (The pre-C5 typed columns allowed the
|
||||
-- IS NOT NULL filter; the computed-column constraint does not.)
|
||||
CREATE NONCLUSTERED INDEX IX_AuditLog_Execution
|
||||
ON dbo.AuditLog (ExecutionId)
|
||||
ON ps_AuditLog_Month(OccurredAtUtc);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_AuditLog_ParentExecution
|
||||
ON dbo.AuditLog (ParentExecutionId)
|
||||
ON ps_AuditLog_Month(OccurredAtUtc);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_AuditLog_Node_Occurred
|
||||
ON dbo.AuditLog (SourceNode ASC, OccurredAtUtc ASC)
|
||||
ON ps_AuditLog_Month(OccurredAtUtc);
|
||||
|
||||
CREATE UNIQUE NONCLUSTERED INDEX UX_AuditLog_EventId
|
||||
ON dbo.AuditLog (EventId)
|
||||
ON [PRIMARY];");
|
||||
|
||||
// 5) Re-grant the append-only roles on the renamed table. The grants were
|
||||
// object-scoped to the old (now-dropped) table, so they must be re-issued
|
||||
// against the new one. Idempotent role creation guards a fresh DB. The
|
||||
// DENY UPDATE / DENY DELETE on the writer role is deliberate — a future
|
||||
// db_datawriter membership cannot quietly re-enable mutation (DENY > 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;
|
||||
|
||||
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)
|
||||
{
|
||||
// ONE-WAY MIGRATION. Collapsing the 24 typed columns into the canonical
|
||||
// shape + a single DetailsJson bag is lossy to reverse byte-for-byte: the
|
||||
// Action/Category/Outcome derivation discards the original Channel/Kind/
|
||||
// Status split in a way only DetailsJson can reconstruct, and the codec's
|
||||
// null-omission + key-order contract cannot be reproduced by a generic
|
||||
// reverse projection. Reversing in place would also have to rebuild the
|
||||
// partition-aligned indexes on the dropped typed columns. If a rollback is
|
||||
// required, restore the central database from a pre-migration backup.
|
||||
throw new System.NotSupportedException(
|
||||
"CollapseAuditLogToCanonical is a one-way migration (Task 2.5 C5): the " +
|
||||
"central dbo.AuditLog collapse projects the legacy typed columns into a " +
|
||||
"lossy canonical/DetailsJson shape that cannot be reversed automatically. " +
|
||||
"Restore the database from a pre-migration backup to roll back.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+123
-139
@@ -41,145 +41,6 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
b.ToTable("DataProtectionKeys");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities.AuditLogRow", b =>
|
||||
{
|
||||
b.Property<Guid>("EventId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime>("OccurredAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Actor")
|
||||
.HasMaxLength(128)
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(128)");
|
||||
|
||||
b.Property<string>("Channel")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(32)");
|
||||
|
||||
b.Property<Guid?>("CorrelationId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int?>("DurationMs")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ErrorDetail")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("nvarchar(1024)");
|
||||
|
||||
b.Property<Guid?>("ExecutionId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("Extra")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ForwardState")
|
||||
.HasMaxLength(32)
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(32)");
|
||||
|
||||
b.Property<int?>("HttpStatus")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("IngestedAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(32)");
|
||||
|
||||
b.Property<Guid?>("ParentExecutionId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<bool>("PayloadTruncated")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("RequestSummary")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ResponseSummary")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("SourceInstanceId")
|
||||
.HasMaxLength(128)
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(128)");
|
||||
|
||||
b.Property<string>("SourceNode")
|
||||
.HasMaxLength(64)
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(64)");
|
||||
|
||||
b.Property<string>("SourceScript")
|
||||
.HasMaxLength(128)
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(128)");
|
||||
|
||||
b.Property<string>("SourceSiteId")
|
||||
.HasMaxLength(64)
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(64)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(32)");
|
||||
|
||||
b.Property<string>("Target")
|
||||
.HasMaxLength(256)
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(256)");
|
||||
|
||||
b.HasKey("EventId", "OccurredAtUtc");
|
||||
|
||||
b.HasIndex("CorrelationId")
|
||||
.HasDatabaseName("IX_AuditLog_CorrelationId")
|
||||
.HasFilter("[CorrelationId] IS NOT NULL");
|
||||
|
||||
b.HasIndex("EventId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_AuditLog_EventId");
|
||||
|
||||
b.HasIndex("ExecutionId")
|
||||
.HasDatabaseName("IX_AuditLog_Execution")
|
||||
.HasFilter("[ExecutionId] IS NOT NULL");
|
||||
|
||||
b.HasIndex("OccurredAtUtc")
|
||||
.IsDescending()
|
||||
.HasDatabaseName("IX_AuditLog_OccurredAtUtc");
|
||||
|
||||
b.HasIndex("ParentExecutionId")
|
||||
.HasDatabaseName("IX_AuditLog_ParentExecution")
|
||||
.HasFilter("[ParentExecutionId] IS NOT NULL");
|
||||
|
||||
b.HasIndex("SourceNode", "OccurredAtUtc")
|
||||
.HasDatabaseName("IX_AuditLog_Node_Occurred");
|
||||
|
||||
b.HasIndex("SourceSiteId", "OccurredAtUtc")
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("IX_AuditLog_Site_Occurred");
|
||||
|
||||
b.HasIndex("Target", "OccurredAtUtc")
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("IX_AuditLog_Target_Occurred")
|
||||
.HasFilter("[Target] IS NOT NULL");
|
||||
|
||||
b.HasIndex("Channel", "Status", "OccurredAtUtc")
|
||||
.IsDescending(false, false, true)
|
||||
.HasDatabaseName("IX_AuditLog_Channel_Status_Occurred");
|
||||
|
||||
b.ToTable("AuditLog", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.AuditLogEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -1490,6 +1351,129 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
b.ToTable("TemplateScripts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities.AuditLogRow", b =>
|
||||
{
|
||||
b.Property<Guid>("EventId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime>("OccurredAtUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(64)");
|
||||
|
||||
b.Property<string>("Actor")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("Channel")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(32)")
|
||||
.HasColumnName("Category");
|
||||
|
||||
b.Property<Guid?>("CorrelationId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("DetailsJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<Guid?>("ExecutionId")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("uniqueidentifier")
|
||||
.HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier)", true);
|
||||
|
||||
b.Property<DateTime?>("IngestedAtUtc")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("datetime2(7)")
|
||||
.HasComputedColumnSql("CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7))", false);
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasMaxLength(32)
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(32)")
|
||||
.HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.kind')", true);
|
||||
|
||||
b.Property<string>("Outcome")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(16)");
|
||||
|
||||
b.Property<Guid?>("ParentExecutionId")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("uniqueidentifier")
|
||||
.HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier)", true);
|
||||
|
||||
b.Property<string>("SourceNode")
|
||||
.HasMaxLength(64)
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(64)");
|
||||
|
||||
b.Property<string>("SourceSiteId")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasMaxLength(64)
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(64)")
|
||||
.HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.sourceSiteId')", true);
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasMaxLength(32)
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(32)")
|
||||
.HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.status')", true);
|
||||
|
||||
b.Property<string>("Target")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.HasKey("EventId", "OccurredAtUtc");
|
||||
|
||||
b.HasIndex("CorrelationId")
|
||||
.HasDatabaseName("IX_AuditLog_CorrelationId")
|
||||
.HasFilter("[CorrelationId] IS NOT NULL");
|
||||
|
||||
b.HasIndex("EventId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_AuditLog_EventId");
|
||||
|
||||
b.HasIndex("ExecutionId")
|
||||
.HasDatabaseName("IX_AuditLog_Execution");
|
||||
|
||||
b.HasIndex("OccurredAtUtc")
|
||||
.IsDescending()
|
||||
.HasDatabaseName("IX_AuditLog_OccurredAtUtc");
|
||||
|
||||
b.HasIndex("ParentExecutionId")
|
||||
.HasDatabaseName("IX_AuditLog_ParentExecution");
|
||||
|
||||
b.HasIndex("SourceNode", "OccurredAtUtc")
|
||||
.HasDatabaseName("IX_AuditLog_Node_Occurred");
|
||||
|
||||
b.HasIndex("SourceSiteId", "OccurredAtUtc")
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("IX_AuditLog_Site_Occurred");
|
||||
|
||||
b.HasIndex("Target", "OccurredAtUtc")
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("IX_AuditLog_Target_Occurred")
|
||||
.HasFilter("[Target] IS NOT NULL");
|
||||
|
||||
b.HasIndex("Channel", "Status", "OccurredAtUtc")
|
||||
.IsDescending(false, false, true)
|
||||
.HasDatabaseName("IX_AuditLog_Channel_Status_Occurred");
|
||||
|
||||
b.ToTable("AuditLog", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeployedConfigSnapshot", b =>
|
||||
{
|
||||
b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null)
|
||||
|
||||
+79
-90
@@ -46,36 +46,35 @@ public class AuditLogRepository : IAuditLogRepository
|
||||
throw new ArgumentNullException(nameof(evt));
|
||||
}
|
||||
|
||||
// C3 transitional shim: the canonical record carries the ScadaBridge domain
|
||||
// fields inside DetailsJson — decompose it into the typed 24-column values the
|
||||
// existing dbo.AuditLog table expects. Central rows leave ForwardState null
|
||||
// (it is a site-storage-only concern, never on a central row).
|
||||
var r = AuditRowProjection.Decompose(evt);
|
||||
// C5 (Task 2.5): write the 10 canonical columns DIRECTLY — no Decompose.
|
||||
// The five queryability columns (Kind/Status/SourceSiteId/ExecutionId/
|
||||
// ParentExecutionId) plus IngestedAtUtc are PERSISTED computed columns on
|
||||
// dbo.AuditLog; SQL Server derives them from DetailsJson at INSERT, so they
|
||||
// are intentionally absent from this column list (writing a computed column
|
||||
// is an error). The canonical OccurredAtUtc is UTC by construction; store a
|
||||
// Kind=Utc DateTime so downstream UTC/local conversions are safe.
|
||||
var occurred = DateTime.SpecifyKind(evt.OccurredAtUtc.UtcDateTime, DateTimeKind.Utc);
|
||||
|
||||
// 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 = r.Channel.ToString();
|
||||
var kind = r.Kind.ToString();
|
||||
var status = r.Status.ToString();
|
||||
string? forwardState = null;
|
||||
// Canonical Actor is a required non-null string; an empty Actor maps to a
|
||||
// NULL column (legacy/central rows stored null for system/anon).
|
||||
string? actor = string.IsNullOrEmpty(evt.Actor) ? null : evt.Actor;
|
||||
|
||||
// Outcome / Category are varchar columns (Outcome via HasConversion<string>;
|
||||
// Category carries the channel name). Bind as strings rather than relying on
|
||||
// parameter type inference.
|
||||
var outcome = evt.Outcome.ToString();
|
||||
string? category = evt.Category;
|
||||
|
||||
// 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 = {r.EventId})
|
||||
$@"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)
|
||||
(EventId, OccurredAtUtc, Actor, Action, Outcome, Category, Target, SourceNode, CorrelationId, DetailsJson)
|
||||
VALUES
|
||||
({r.EventId}, {r.OccurredAtUtc}, {r.IngestedAtUtc}, {channel}, {kind}, {r.CorrelationId}, {r.ExecutionId}, {r.ParentExecutionId},
|
||||
{r.SourceSiteId}, {r.SourceNode}, {r.SourceInstanceId}, {r.SourceScript}, {r.Actor}, {r.Target}, {status},
|
||||
{r.HttpStatus}, {r.DurationMs}, {r.ErrorMessage}, {r.ErrorDetail}, {r.RequestSummary},
|
||||
{r.ResponseSummary}, {r.PayloadTruncated}, {r.Extra}, {forwardState});",
|
||||
({evt.EventId}, {occurred}, {actor}, {evt.Action}, {outcome}, {category}, {evt.Target}, {evt.SourceNode}, {evt.CorrelationId}, {evt.DetailsJson});",
|
||||
ct);
|
||||
}
|
||||
catch (SqlException ex) when (
|
||||
@@ -92,7 +91,7 @@ VALUES
|
||||
ex,
|
||||
"InsertIfNotExistsAsync swallowed duplicate-key violation (error {SqlErrorNumber}) for EventId {EventId}; treating as no-op.",
|
||||
ex.Number,
|
||||
r.EventId);
|
||||
evt.EventId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,9 +109,11 @@ VALUES
|
||||
throw new ArgumentNullException(nameof(paging));
|
||||
}
|
||||
|
||||
// C3 transitional shim: the typed-column filter predicates query the
|
||||
// AuditLogRow persistence shape as before (C6 retargets how the filter is
|
||||
// applied); the materialized rows are recomposed into canonical records.
|
||||
// C5 (Task 2.5): the filter predicates bind to the canonical columns and the
|
||||
// persisted computed columns directly — Channel→Category, Kind/Status/
|
||||
// SourceSiteId/ExecutionId/ParentExecutionId are computed columns. The
|
||||
// materialized rows are projected to the canonical record by reading the 10
|
||||
// canonical columns (no 24-column Recompose).
|
||||
var query = _context.Set<AuditLogRow>().AsNoTracking();
|
||||
|
||||
// Multi-value dimensions: a null OR empty list means "no constraint"
|
||||
@@ -201,36 +202,29 @@ VALUES
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C3 transitional shim: recompose a canonical <see cref="AuditEvent"/> from a
|
||||
/// materialized <see cref="AuditLogRow"/> read back from <c>dbo.AuditLog</c>.
|
||||
/// <c>ForwardState</c> is dropped (central rows never carry it; it is not a
|
||||
/// canonical / DetailsJson field).
|
||||
/// C5 (Task 2.5): build the canonical <see cref="AuditEvent"/> DIRECTLY from the
|
||||
/// 10 canonical columns of a materialized <see cref="AuditLogRow"/> read back from
|
||||
/// <c>dbo.AuditLog</c> — no 24-column <c>Recompose</c>, because the table now holds
|
||||
/// the canonical shape (every ScadaBridge domain field already lives in
|
||||
/// <c>DetailsJson</c>). The persisted computed columns are read helpers only and
|
||||
/// are not part of the canonical record. <see cref="AuditLogRow.Channel"/> is the
|
||||
/// canonical <c>Category</c> column (Category = channel name for ScadaBridge).
|
||||
/// </summary>
|
||||
private static AuditEvent RowToCanonical(AuditLogRow row)
|
||||
=> AuditRowProjection.Recompose(new AuditRowProjection.AuditRowValues(
|
||||
EventId: row.EventId,
|
||||
OccurredAtUtc: row.OccurredAtUtc,
|
||||
IngestedAtUtc: row.IngestedAtUtc,
|
||||
Channel: row.Channel,
|
||||
Kind: row.Kind,
|
||||
Status: row.Status,
|
||||
CorrelationId: row.CorrelationId,
|
||||
ExecutionId: row.ExecutionId,
|
||||
ParentExecutionId: row.ParentExecutionId,
|
||||
SourceSiteId: row.SourceSiteId,
|
||||
SourceNode: row.SourceNode,
|
||||
SourceInstanceId: row.SourceInstanceId,
|
||||
SourceScript: row.SourceScript,
|
||||
Actor: row.Actor,
|
||||
Target: row.Target,
|
||||
HttpStatus: row.HttpStatus,
|
||||
DurationMs: row.DurationMs,
|
||||
ErrorMessage: row.ErrorMessage,
|
||||
ErrorDetail: row.ErrorDetail,
|
||||
RequestSummary: row.RequestSummary,
|
||||
ResponseSummary: row.ResponseSummary,
|
||||
PayloadTruncated: row.PayloadTruncated,
|
||||
Extra: row.Extra));
|
||||
=> new()
|
||||
{
|
||||
EventId = row.EventId,
|
||||
OccurredAtUtc = new DateTimeOffset(
|
||||
DateTime.SpecifyKind(row.OccurredAtUtc, DateTimeKind.Utc)),
|
||||
Actor = row.Actor ?? string.Empty,
|
||||
Action = row.Action,
|
||||
Outcome = row.Outcome,
|
||||
Category = row.Channel.ToString(),
|
||||
Target = row.Target,
|
||||
SourceNode = row.SourceNode,
|
||||
CorrelationId = row.CorrelationId,
|
||||
DetailsJson = row.DetailsJson,
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default)
|
||||
@@ -270,38 +264,29 @@ VALUES
|
||||
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.
|
||||
-- byte-identical to the C5 dbo.AuditLog — INCLUDING the persisted
|
||||
-- computed columns, whose definitions must match EXACTLY (same
|
||||
-- expression text + PERSISTED) or ALTER TABLE ... SWITCH PARTITION
|
||||
-- rejects the operation with msg 4904/4948. The ordinal order also
|
||||
-- matches dbo.AuditLog_v2 (the CollapseAuditLogToCanonical migration):
|
||||
-- 10 canonical columns first, then the 6 computed columns.
|
||||
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,
|
||||
Actor nvarchar(256) NULL,
|
||||
Action varchar(64) NOT NULL,
|
||||
Outcome varchar(16) NOT NULL,
|
||||
Category varchar(32) NOT NULL,
|
||||
Target nvarchar(256) NULL,
|
||||
SourceNode varchar(64) NULL,
|
||||
CorrelationId uniqueidentifier NULL,
|
||||
DetailsJson nvarchar(max) NULL,
|
||||
Kind AS JSON_VALUE(DetailsJson,'$.kind') PERSISTED,
|
||||
Status AS JSON_VALUE(DetailsJson,'$.status') PERSISTED,
|
||||
SourceSiteId AS JSON_VALUE(DetailsJson,'$.sourceSiteId') PERSISTED,
|
||||
ExecutionId AS CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier) PERSISTED,
|
||||
ParentExecutionId AS CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier) PERSISTED,
|
||||
IngestedAtUtc AS CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7)),
|
||||
CONSTRAINT PK_{stagingTableName} PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc)
|
||||
) ON [PRIMARY];
|
||||
|
||||
@@ -648,24 +633,28 @@ VALUES
|
||||
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.
|
||||
-- C5 (Task 2.5): ExecutionId / ParentExecutionId / SourceSiteId
|
||||
-- are persisted computed columns (same names); Channel is now the
|
||||
-- canonical Category column (Category = channel name, so the
|
||||
-- Channels aggregate still yields channel names); SourceInstanceId
|
||||
-- is no longer a column — read it from DetailsJson via JSON_VALUE.
|
||||
-- ParentExecutionId / SourceSiteId / SourceInstanceId are derived
|
||||
-- via MIN: every audit row of one execution carries the SAME value
|
||||
-- (stamped once per script run / inbound request) — MIN simply
|
||||
-- picks that one shared value, 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
|
||||
(SELECT STRING_AGG(d.Category, ',')
|
||||
FROM (SELECT DISTINCT a2.Category 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(JSON_VALUE(a.DetailsJson,'$.sourceInstanceId')) AS [SourceInstanceId],
|
||||
MIN(a.OccurredAtUtc) AS [FirstOccurredAtUtc],
|
||||
MAX(a.OccurredAtUtc) AS [LastOccurredAtUtc]
|
||||
FROM ChainIds ids
|
||||
|
||||
@@ -154,6 +154,43 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ScadaBridgeDbContext).Assembly);
|
||||
|
||||
ApplySecretColumnEncryption(modelBuilder);
|
||||
|
||||
NeutralizeSqlServerComputedColumnsForNonSqlServerProviders(modelBuilder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C5 (Task 2.5): the central <c>dbo.AuditLog</c> persisted computed columns use
|
||||
/// SQL Server's <c>JSON_VALUE</c> expression, which only SQL Server can evaluate.
|
||||
/// On a non-SQL-Server provider (the SQLite test contexts) emitting that SQL in a
|
||||
/// <c>CREATE TABLE</c> fails ("no such function: JSON_VALUE"). Strip the
|
||||
/// computed-column SQL for any non-SQL-Server provider so those columns degrade to
|
||||
/// plain (always-null on the test provider) nullable columns; SQL Server keeps the
|
||||
/// real <c>… AS JSON_VALUE(...) PERSISTED</c> definitions. This is a model-shape
|
||||
/// adaptation only — it never runs under the design-time SQL Server provider, so the
|
||||
/// migration / model-snapshot remain the canonical SQL Server shape.
|
||||
/// </summary>
|
||||
private void NeutralizeSqlServerComputedColumnsForNonSqlServerProviders(ModelBuilder modelBuilder)
|
||||
{
|
||||
// Database.IsSqlServer() reads the configured provider — true for production and
|
||||
// for the design-time factory (UseSqlServer), false for the SQLite test contexts.
|
||||
if (Database.IsSqlServer())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
||||
{
|
||||
foreach (var property in entityType.GetProperties())
|
||||
{
|
||||
if (property.GetComputedColumnSql() is not null)
|
||||
{
|
||||
property.SetComputedColumnSql(null);
|
||||
property.SetColumnType(null);
|
||||
property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never;
|
||||
property.SetAfterSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Save);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
Reference in New Issue
Block a user