diff --git a/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs b/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs index 7b15962..9932c5c 100644 --- a/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs +++ b/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs @@ -45,12 +45,43 @@ public interface IAuditLogRepository /// /// Switches out (purges) the monthly partition whose lower bound is - /// . The honest M1 implementation throws - /// : the UX_AuditLog_EventId unique - /// index is non-partition-aligned (lives on [PRIMARY], not on - /// ps_AuditLog_Month), so SQL Server rejects - /// ALTER TABLE … SWITCH PARTITION until the drop-and-rebuild dance - /// shipped by the M6 purge actor is in place. + /// . /// + /// + /// + /// Drop-and-rebuild dance. UX_AuditLog_EventId is intentionally + /// non-partition-aligned (it lives on [PRIMARY] so single-column + /// EventId uniqueness — required by — + /// can be enforced cheaply). SQL Server rejects + /// ALTER TABLE … SWITCH PARTITION while a non-aligned unique index + /// is present, so the M6 implementation drops the index, creates a staging + /// table with byte-identical schema, switches the partition's data into + /// staging, drops staging (discarding the rows), and rebuilds the unique + /// index. The CATCH branch guarantees the index is rebuilt even on partial + /// failure so the table never returns to live traffic without its + /// idempotency-supporting index. + /// + /// + /// Outage window. The dance briefly removes the unique index, so + /// concurrent calls during the switch + /// could in principle race past the IF NOT EXISTS check without the index + /// catching the duplicate. This is acceptable for the daily purge cadence + /// — the inserts that the IF NOT EXISTS check guards are themselves rare + /// enough that a sub-second collision window is operationally negligible, + /// and the composite PK still rejects same-(EventId, OccurredAtUtc) rows. + /// + /// Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default); + + /// + /// Returns the set of pf_AuditLog_Month partition lower-bound + /// boundaries whose partitions contain only rows with + /// strictly older than + /// . Boundaries whose partition is empty are + /// excluded (a no-op switch is wasted work). Used by the M6 purge actor + /// to enumerate retention-eligible months on every tick. + /// + Task> GetPartitionBoundariesOlderThanAsync( + DateTime threshold, + CancellationToken ct = default); } diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs index d88271f..9dc2f41 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs @@ -179,18 +179,199 @@ VALUES } /// - /// M1 honest contract: throws . The - /// UX_AuditLog_EventId unique index is non-aligned with - /// ps_AuditLog_Month (it lives on [PRIMARY] to keep - /// cheap), and SQL Server rejects - /// ALTER TABLE … SWITCH PARTITION when a non-aligned index is present. - /// The drop-and-rebuild dance that makes the switch legal ships with the M6 - /// purge actor. + /// M6-T4 production implementation of the drop-and-rebuild dance documented + /// on . /// - public Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default) + /// + /// + /// The staging table name is GUID-suffixed so concurrent purge attempts on + /// different boundaries cannot collide. The staging schema is byte-identical + /// to the live AuditLog table (same column types, lengths, + /// nullability, and clustered-key shape) — SQL Server's + /// ALTER TABLE … SWITCH PARTITION rejects any drift. Keep this CREATE + /// in sync with both the migration that ships the live table + /// (20260520142214_AddAuditLogTable) and + /// AuditLogEntityTypeConfiguration. + /// + /// + /// All five steps run inside an explicit transaction so the SWITCH + + /// staging-DROP are atomic from the perspective of a consumer reading via + /// snapshot isolation; the CATCH rolls back and runs an idempotent + /// "rebuild UX_AuditLog_EventId if it doesn't exist" so a partial failure + /// never leaves the live table without its idempotency-supporting unique + /// index. + /// + /// + public async Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default) { - throw new NotSupportedException( - "AuditLog partition switch is blocked by the non-aligned UX_AuditLog_EventId " + - "unique index; the drop-and-rebuild dance ships in M6 (purge actor)."); + // 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. + var monthBoundaryStr = monthBoundary.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss"); + + 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, + 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;"; + + await _context.Database.ExecuteSqlRawAsync(sql, ct); + } + + /// + /// Returns the set of pf_AuditLog_Month boundaries whose partition's + /// MAX(OccurredAtUtc) is strictly older than . + /// Boundaries with empty partitions are excluded — purging an empty + /// partition is wasted I/O. + /// + /// + /// + /// The CTE pulls every boundary value defined by the partition function and + /// joins it (via $PARTITION.pf_AuditLog_Month) to the live AuditLog + /// to compute per-partition MAX(OccurredAtUtc). The outer filter + /// keeps only those whose MAX is non-NULL (partition has rows) AND strictly + /// less than the threshold (every row is past retention). + /// + /// + /// Note: the query scans the live OccurredAtUtc column to compute + /// the MAX per partition. With IX_AuditLog_OccurredAtUtc on the + /// partition-aligned scheme this is a single index seek per partition; for + /// 24 partitions and a daily purge cadence the cost is negligible. + /// + /// + public async Task> 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(); + 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)) + { + results.Add(reader.GetDateTime(0)); + } + } + finally + { + if (openedHere) + { + await conn.CloseAsync().ConfigureAwait(false); + } + } + + return results; } } diff --git a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs index 36de05f..203fb6d 100644 --- a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs @@ -216,5 +216,9 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture _inner.SwitchOutPartitionAsync(monthBoundary, ct); + + public Task> GetPartitionBoundariesOlderThanAsync( + DateTime threshold, CancellationToken ct = default) => + _inner.GetPartitionBoundariesOlderThanAsync(threshold, ct); } } diff --git a/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs index 2d77dcd..d1dad16 100644 --- a/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs @@ -89,6 +89,10 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture Task.CompletedTask; + + public Task> GetPartitionBoundariesOlderThanAsync( + DateTime threshold, CancellationToken ct = default) => + Task.FromResult>(Array.Empty()); } /// diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs index 958b2b1..df1daeb 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Types.Audit; @@ -309,21 +310,221 @@ public class AuditLogRepositoryTests : IClassFixture Assert.True(events.Select(e => e.EventId).ToHashSet().SetEquals(allIds)); } + // ------------------------------------------------------------------------ + // M6-T4 Bundle C: SwitchOutPartitionAsync drop-and-rebuild integration tests + // ------------------------------------------------------------------------ + // + // The partition-switch path replaces M1's NotSupportedException stub with + // the production drop-DROP-INDEX → CREATE-staging → SWITCH PARTITION → + // DROP-staging → CREATE-INDEX dance documented in alog.md §4. These tests + // verify the side effects an outsider can observe: + // * rows in the targeted month are removed + // * rows in OTHER months are NOT touched + // * UX_AuditLog_EventId still exists after a successful switch + // * InsertIfNotExistsAsync's first-write-wins idempotency still holds + // after a switch (the rebuilt index is real) + // * a thrown SqlException leaves UX_AuditLog_EventId rebuilt (the CATCH + // branch's recovery path runs) + [SkippableFact] - public async Task SwitchOutPartitionAsync_ThrowsNotSupported_ForM1() + public async Task SwitchOutPartitionAsync_OldPartition_RemovesRows_NewPartitionsKept() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + // Three distinct months — Jan, Feb, Mar 2026 — so the switch on Jan's + // boundary purges exactly one month's worth of rows. Boundary values + // come from the partition function's pre-seeded list (alog.md §4). + var janEvt = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 1, 15, 10, 0, 0, DateTimeKind.Utc)); + var febEvt = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 2, 15, 10, 0, 0, DateTimeKind.Utc)); + var marEvt = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 3, 15, 10, 0, 0, DateTimeKind.Utc)); + await repo.InsertIfNotExistsAsync(janEvt); + await repo.InsertIfNotExistsAsync(febEvt); + await repo.InsertIfNotExistsAsync(marEvt); + + // Boundary value '2026-01-01' identifies the January 2026 partition under + // RANGE RIGHT semantics ($PARTITION returns the partition into which the + // boundary value itself falls — the partition whose lower bound is the + // boundary). + await repo.SwitchOutPartitionAsync(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + await using var readContext = CreateContext(); + var remaining = await readContext.Set() + .Where(e => e.SourceSiteId == siteId) + .ToListAsync(); + + Assert.DoesNotContain(remaining, e => e.EventId == janEvt.EventId); + Assert.Contains(remaining, e => e.EventId == febEvt.EventId); + Assert.Contains(remaining, e => e.EventId == marEvt.EventId); + } + + [SkippableFact] + public async Task SwitchOutPartitionAsync_RebuildsUxIndex_AfterSwitch() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); await using var context = CreateContext(); var repo = new AuditLogRepository(context); - // The partition-switch path is intentionally blocked in M1 because - // UX_AuditLog_EventId is non-aligned. The drop-and-rebuild dance ships - // with the M6 purge actor. - var ex = await Assert.ThrowsAsync( - () => repo.SwitchOutPartitionAsync(new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc))); + // Pick a different month per test so successive test runs (which share + // the fixture's MSSQL database) don't tread on each other. + await repo.SwitchOutPartitionAsync(new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)); - Assert.Contains("M6", ex.Message, StringComparison.OrdinalIgnoreCase); + await using var verifyContext = CreateContext(); + var indexExists = await ScalarAsync( + verifyContext, + "SELECT COUNT(*) FROM sys.indexes " + + "WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog');"); + Assert.Equal(1, indexExists); + } + + [SkippableFact] + public async Task SwitchOutPartitionAsync_InsertIfNotExistsAsync_StillEnforcesFirstWriteWins_AfterSwitch() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + // Pre-existing row in May 2026 — must survive a switch on a different + // (older) partition. + var preExisting = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc)); + await repo.InsertIfNotExistsAsync(preExisting); + + // Switch out the June 2026 partition (different month, empty). + await repo.SwitchOutPartitionAsync(new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)); + + // Re-attempting the same EventId after the switch must STILL be a no-op + // (UX_AuditLog_EventId is the index that enables idempotency; if the + // rebuild left it broken, this insert would silently produce a duplicate + // row and the count assertion below would catch it). + var dup = preExisting with { ErrorMessage = "second-should-be-ignored-after-switch" }; + await repo.InsertIfNotExistsAsync(dup); + + await using var readContext = CreateContext(); + var rows = await readContext.Set() + .Where(e => e.SourceSiteId == siteId) + .ToListAsync(); + + Assert.Single(rows); + Assert.Equal(preExisting.EventId, rows[0].EventId); + // First-write-wins: the original ErrorMessage (null) survives. + Assert.Null(rows[0].ErrorMessage); + } + + [SkippableFact] + public async Task SwitchOutPartitionAsync_PartialFailure_RebuildsUxIndex_RaisesException() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + // Force a deterministic switch failure with an inbound FOREIGN KEY: + // ALTER TABLE … SWITCH refuses to move rows out of a partition that's + // referenced by an FK from another table, raising msg 4928 + // ("ALTER TABLE SWITCH statement failed because target table … has a + // foreign key …"). The CATCH branch then rolls back and rebuilds the + // unique index — which the assertion below verifies. + // + // The probe table is uniquely named with a guid suffix so reruns of + // this test inside the same fixture DB never collide. We clean it up + // in the finally so the constraint never leaks into other tests. + var probeTable = $"AuditFkProbe_{Guid.NewGuid():N}".Substring(0, 32); + await using (var setup = new SqlConnection(_fixture.ConnectionString)) + { + await setup.OpenAsync(); + await using var cmd = setup.CreateCommand(); + // Composite FK references AuditLog's composite PK (EventId, OccurredAtUtc). + cmd.CommandText = + $"CREATE TABLE dbo.[{probeTable}] ( " + + $" EventId uniqueidentifier NOT NULL, " + + $" OccurredAtUtc datetime2(7) NOT NULL, " + + $" CONSTRAINT FK_{probeTable}_AuditLog FOREIGN KEY (EventId, OccurredAtUtc) " + + $" REFERENCES dbo.AuditLog(EventId, OccurredAtUtc));"; + await cmd.ExecuteNonQueryAsync(); + } + + try + { + var ex = await Assert.ThrowsAnyAsync( + () => repo.SwitchOutPartitionAsync(new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc))); + // Smoke-check the message references the SWITCH statement so we + // know we hit the engineered failure, not some unrelated error. + Assert.Contains("SWITCH", ex.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + // Always drop the probe table so the FK is gone before the next + // test runs against the shared fixture. + await using var cleanup = new SqlConnection(_fixture.ConnectionString); + await cleanup.OpenAsync(); + await using var cmd = cleanup.CreateCommand(); + cmd.CommandText = + $"IF OBJECT_ID('dbo.[{probeTable}]', 'U') IS NOT NULL DROP TABLE dbo.[{probeTable}];"; + await cmd.ExecuteNonQueryAsync(); + } + + // The CATCH block in the production SQL guarantees UX_AuditLog_EventId + // is rebuilt regardless of which step failed inside the TRY. + await using var verifyContext = CreateContext(); + var indexExists = await ScalarAsync( + verifyContext, + "SELECT COUNT(*) FROM sys.indexes " + + "WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog');"); + Assert.Equal(1, indexExists); + } + + // ------------------------------------------------------------------------ + // M6-T4 Bundle C: GetPartitionBoundariesOlderThanAsync + // ------------------------------------------------------------------------ + + [SkippableFact] + public async Task GetPartitionBoundariesOlderThanAsync_ReturnsBoundaries_WithMaxOccurredOlderThanThreshold() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + // Seed events in two months: July 2026 (old) and August 2026 (new). + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: new DateTime(2026, 7, 10, 0, 0, 0, DateTimeKind.Utc))); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: new DateTime(2026, 8, 10, 0, 0, 0, DateTimeKind.Utc))); + + // Threshold = Aug 1 2026 — July partition's MAX (July 10) is older; + // August partition's MAX (August 10) is newer. We expect only the July + // boundary back. + var threshold = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc); + var boundaries = await repo.GetPartitionBoundariesOlderThanAsync(threshold); + + // The repo may also return EARLIER boundaries that have no data (their + // MAX is NULL → treated as "no data, nothing to purge" by the contract). + // We only assert the inclusion/exclusion that matters for our seeded + // rows. + Assert.Contains(new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc), boundaries); + Assert.DoesNotContain(new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc), boundaries); + } + + private async Task ScalarAsync(ScadaLinkDbContext context, string sql) + { + var conn = context.Database.GetDbConnection(); + if (conn.State != System.Data.ConnectionState.Open) + { + await conn.OpenAsync(); + } + await using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + var result = await cmd.ExecuteScalarAsync(); + if (result is null || result is DBNull) + { + return default!; + } + return (T)Convert.ChangeType(result, typeof(T) == typeof(string) ? typeof(string) : Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T))!; } // --- helpers ------------------------------------------------------------