From 7d9550f7797dfaaf6829e86e70da1d411c829336 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 10:19:33 -0400 Subject: [PATCH] feat(configdb): composite PK + UX_AuditLog_EventId on AuditEvent mapping (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundle C migration aligns AuditLog to ps_AuditLog_Month(OccurredAtUtc). A partitioned table's clustered key must include the partition column, so the PK becomes the composite {EventId, OccurredAtUtc} โ€” a divergence from Bundle B's single-column PK that needs reconciling in the EF mapping before the migration is generated. EventId remains the idempotency key for AuditLogRepository.InsertIfNotExistsAsync (M1-T8), so a dedicated unique index UX_AuditLog_EventId preserves the single-column uniqueness constraint. Tests updated: - Configure_MapsToAuditLogTable_WithCompositePrimaryKey (replaces the WithEventIdAsPrimaryKey assertion) verifies {EventId, OccurredAtUtc}. - Configure_DeclaresUniqueIndex_OnEventIdAlone_ForIdempotencyLookups asserts the new UX_AuditLog_EventId is unique and on EventId alone. - Configure_ExpectedIndexes_WithCorrectNames now expects six index names (the original five plus UX_AuditLog_EventId). --- .../AuditLogEntityTypeConfiguration.cs | 10 +++++- .../AuditLogEntityTypeConfigurationTests.cs | 34 ++++++++++++++++--- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs index ca914d3..9ad90e0 100644 --- a/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs @@ -15,7 +15,15 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration e.EventId); + // 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) diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs index b70ee0f..9427442 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs @@ -28,8 +28,11 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable } [Fact] - public void Configure_MapsToAuditLogTable_WithEventIdAsPrimaryKey() + public void Configure_MapsToAuditLogTable_WithCompositePrimaryKey() { + // Composite PK {EventId, OccurredAtUtc} is required by the partitioned + // AuditLog table โ€” the clustered key must include the partition column + // (OccurredAtUtc) so each row can be located in its partition (#23 Bundle C). var entity = _context.Model.FindEntityType(typeof(AuditEvent)); Assert.NotNull(entity); @@ -37,8 +40,28 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable var pk = entity.FindPrimaryKey(); Assert.NotNull(pk); - var pkProperty = Assert.Single(pk!.Properties); - Assert.Equal(nameof(AuditEvent.EventId), pkProperty.Name); + + var pkPropertyNames = pk!.Properties.Select(p => p.Name).ToArray(); + Assert.Equal(new[] { nameof(AuditEvent.EventId), nameof(AuditEvent.OccurredAtUtc) }, pkPropertyNames); + } + + [Fact] + public void Configure_DeclaresUniqueIndex_OnEventIdAlone_ForIdempotencyLookups() + { + // EventId remains globally unique (the idempotency key for + // InsertIfNotExistsAsync, per M1-T8) via a dedicated unique index that + // is independent of the composite PK. + var entity = _context.Model.FindEntityType(typeof(AuditEvent)); + Assert.NotNull(entity); + + var eventIdIndex = entity!.GetIndexes() + .SingleOrDefault(i => i.GetDatabaseName() == "UX_AuditLog_EventId"); + + Assert.NotNull(eventIdIndex); + Assert.True(eventIdIndex!.IsUnique); + + var indexedProperty = Assert.Single(eventIdIndex.Properties); + Assert.Equal(nameof(AuditEvent.EventId), indexedProperty.Name); } [Fact] @@ -56,7 +79,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable } [Fact] - public void Configure_FiveExpectedIndexes_WithCorrectNames() + public void Configure_ExpectedIndexes_WithCorrectNames() { var entity = _context.Model.FindEntityType(typeof(AuditEvent)); Assert.NotNull(entity); @@ -66,6 +89,8 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable .OrderBy(n => n, StringComparer.Ordinal) .ToList(); + // Five reconciliation/query indexes from alog.md ยง4, plus the EventId unique + // index introduced alongside the composite PK (Bundle C). var expected = new[] { "IX_AuditLog_Channel_Status_Occurred", @@ -73,6 +98,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable "IX_AuditLog_OccurredAtUtc", "IX_AuditLog_Site_Occurred", "IX_AuditLog_Target_Occurred", + "UX_AuditLog_EventId", }; Assert.Equal(expected, indexNames);