refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,704 @@
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
/// <summary>
/// EF Core implementation of <see cref="IAuditLogRepository"/>. See the interface
/// for the append-only contract; this class only adds notes on the data-access
/// strategy used by each method.
/// </summary>
public class AuditLogRepository : IAuditLogRepository
{
// SQL Server error numbers for duplicate-key violations on
// UX_AuditLog_EventId. 2601 is a unique-index violation; 2627 is a
// primary-key/unique-constraint violation. The IF NOT EXISTS … INSERT
// pattern has a check-then-act race window — two sessions can both pass
// the EXISTS check and then both attempt the INSERT — and the loser
// surfaces as one of these errors. Idempotency demands we swallow them.
private const int SqlErrorUniqueIndexViolation = 2601;
private const int SqlErrorPrimaryKeyViolation = 2627;
private readonly ScadaBridgeDbContext _context;
private readonly ILogger<AuditLogRepository> _logger;
/// <summary>Initializes a new instance of the AuditLogRepository class.</summary>
/// <param name="context">The database context.</param>
/// <param name="logger">Optional logger instance.</param>
public AuditLogRepository(ScadaBridgeDbContext context, ILogger<AuditLogRepository>? logger = null)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_logger = logger ?? NullLogger<AuditLogRepository>.Instance;
}
/// <inheritdoc />
public async Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
{
if (evt is null)
{
throw new ArgumentNullException(nameof(evt));
}
// Enum columns are stored as varchar(32) (HasConversion<string>()), so do
// the conversion in C# rather than relying on parameter type inference —
// SqlClient would otherwise bind enums as int by default.
var channel = evt.Channel.ToString();
var kind = evt.Kind.ToString();
var status = evt.Status.ToString();
var forwardState = evt.ForwardState?.ToString();
// FormattableString interpolation parameterises every value (no concatenation),
// so this is safe against injection even for the string columns.
try
{
await _context.Database.ExecuteSqlInterpolatedAsync(
$@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId})
INSERT INTO dbo.AuditLog
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, ExecutionId, ParentExecutionId,
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, Status,
HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary,
ResponseSummary, PayloadTruncated, Extra, ForwardState)
VALUES
({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId}, {evt.ExecutionId}, {evt.ParentExecutionId},
{evt.SourceSiteId}, {evt.SourceNode}, {evt.SourceInstanceId}, {evt.SourceScript}, {evt.Actor}, {evt.Target}, {status},
{evt.HttpStatus}, {evt.DurationMs}, {evt.ErrorMessage}, {evt.ErrorDetail}, {evt.RequestSummary},
{evt.ResponseSummary}, {evt.PayloadTruncated}, {evt.Extra}, {forwardState});",
ct);
}
catch (SqlException ex) when (
ex.Number == SqlErrorUniqueIndexViolation
|| ex.Number == SqlErrorPrimaryKeyViolation)
{
// Two concurrent sessions both passed the IF NOT EXISTS check and
// both attempted the INSERT — the loser raises 2601/2627 against
// UX_AuditLog_EventId. First-write-wins idempotency is already the
// documented contract for this method, so the race outcome is
// semantically a no-op. Swallow at Debug; other SqlExceptions
// bubble.
_logger.LogDebug(
ex,
"InsertIfNotExistsAsync swallowed duplicate-key violation (error {SqlErrorNumber}) for EventId {EventId}; treating as no-op.",
ex.Number,
evt.EventId);
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default)
{
if (filter is null)
{
throw new ArgumentNullException(nameof(filter));
}
if (paging is null)
{
throw new ArgumentNullException(nameof(paging));
}
var query = _context.Set<AuditEvent>().AsNoTracking();
// Multi-value dimensions: a null OR empty list means "no constraint"
// (the { Count: > 0 } guard prevents an empty list collapsing to a
// WHERE 1=0). A non-empty list translates to a SQL IN (…) via EF Core's
// IReadOnlyList<T>.Contains support — server-side, no client-eval.
if (filter.Channels is { Count: > 0 } channels)
{
query = query.Where(e => channels.Contains(e.Channel));
}
if (filter.Kinds is { Count: > 0 } kinds)
{
query = query.Where(e => kinds.Contains(e.Kind));
}
if (filter.Statuses is { Count: > 0 } statuses)
{
query = query.Where(e => statuses.Contains(e.Status));
}
if (filter.SourceSiteIds is { Count: > 0 } sourceSiteIds)
{
query = query.Where(e => e.SourceSiteId != null && sourceSiteIds.Contains(e.SourceSiteId));
}
// SourceNode filter mirrors SourceSiteIds: a non-empty list translates to
// SQL IN (…); NULL SourceNode rows are excluded when the filter is set.
if (filter.SourceNodes is { Count: > 0 } sourceNodes)
{
query = query.Where(e => e.SourceNode != null && sourceNodes.Contains(e.SourceNode));
}
if (!string.IsNullOrEmpty(filter.Target))
{
var target = filter.Target;
query = query.Where(e => e.Target == target);
}
if (!string.IsNullOrEmpty(filter.Actor))
{
var actor = filter.Actor;
query = query.Where(e => e.Actor == actor);
}
if (filter.CorrelationId is { } correlationId)
{
query = query.Where(e => e.CorrelationId == correlationId);
}
if (filter.ExecutionId is { } executionId)
{
query = query.Where(e => e.ExecutionId == executionId);
}
if (filter.ParentExecutionId is { } parentExecutionId)
{
query = query.Where(e => e.ParentExecutionId == parentExecutionId);
}
if (filter.FromUtc is { } fromUtc)
{
query = query.Where(e => e.OccurredAtUtc >= fromUtc);
}
if (filter.ToUtc is { } toUtc)
{
query = query.Where(e => e.OccurredAtUtc <= toUtc);
}
// Keyset cursor on (OccurredAtUtc desc, EventId desc).
if (paging.AfterOccurredAtUtc is { } afterOccurred && paging.AfterEventId is { } afterEventId)
{
query = query.Where(e =>
e.OccurredAtUtc < afterOccurred
|| (e.OccurredAtUtc == afterOccurred && e.EventId.CompareTo(afterEventId) < 0));
}
return await query
.OrderByDescending(e => e.OccurredAtUtc)
.ThenByDescending(e => e.EventId)
.Take(paging.PageSize)
.ToListAsync(ct);
}
/// <inheritdoc />
public async Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default)
{
// GUID-suffixed staging name: prevents collision with any concurrent
// purge attempt and avoids polluting the AuditLog object namespace with
// a predictable identifier.
var stagingTableName = $"AuditLog_Staging_{Guid.NewGuid():N}";
// ISO 8601 in UTC — SQL Server's datetime2 literal parser accepts this
// unambiguously and the value is round-trip-safe across SET DATEFORMAT
// settings. CD-021: use datetime2(7) precision (.fffffff) so a future
// non-midnight or sub-second boundary doesn't silently round to the
// wrong partition (today the migration only seeds at T00:00:00 exactly,
// but the format string is on the boundary value's own contract — match
// it to the column precision rather than to the current seed pattern).
var monthBoundaryStr = monthBoundary.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff");
// Two-statement batch: the first SELECT samples the per-partition row
// count BEFORE the dance so we can report it back to the purge actor;
// the second batch performs the drop-and-rebuild. We use OUTPUT-style
// variables wired through @@ROWCOUNT after the SWITCH is not viable
// because SWITCH is a metadata-only operation that doesn't move rows in
// a way @@ROWCOUNT can observe.
var sampleSql = $@"
SELECT COUNT_BIG(*) FROM dbo.AuditLog
WHERE $PARTITION.pf_AuditLog_Month(OccurredAtUtc) =
$partition.pf_AuditLog_Month('{monthBoundaryStr}');";
var sql = $@"
BEGIN TRY
BEGIN TRANSACTION;
-- 1. Drop the non-aligned unique index. ALTER TABLE SWITCH refuses
-- to run while it exists.
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog'))
DROP INDEX UX_AuditLog_EventId ON dbo.AuditLog;
-- 2. Staging table on [PRIMARY] (non-partitioned) with column shapes
-- byte-identical to dbo.AuditLog. Any drift here causes SWITCH to
-- reject the operation with msg 4904/4915.
CREATE TABLE dbo.[{stagingTableName}] (
EventId uniqueidentifier NOT NULL,
OccurredAtUtc datetime2(7) NOT NULL,
IngestedAtUtc datetime2(7) NULL,
Channel varchar(32) NOT NULL,
Kind varchar(32) NOT NULL,
CorrelationId uniqueidentifier NULL,
SourceSiteId varchar(64) NULL,
SourceInstanceId varchar(128) NULL,
SourceScript varchar(128) NULL,
Actor varchar(128) NULL,
Target varchar(256) NULL,
Status varchar(32) NOT NULL,
HttpStatus int NULL,
DurationMs int NULL,
ErrorMessage nvarchar(1024) NULL,
ErrorDetail nvarchar(max) NULL,
RequestSummary nvarchar(max) NULL,
ResponseSummary nvarchar(max) NULL,
PayloadTruncated bit NOT NULL,
Extra nvarchar(max) NULL,
ForwardState varchar(32) NULL,
-- ExecutionId, ParentExecutionId, and SourceNode are last (in this
-- ordinal order) because each was added to the live AuditLog table
-- by a later ALTER TABLE ADD migration; the staging table must
-- match the live table column shape ordinal-for-ordinal or
-- ALTER TABLE ... SWITCH PARTITION fails (msg 4904/4915).
ExecutionId uniqueidentifier NULL,
ParentExecutionId uniqueidentifier NULL,
SourceNode varchar(64) NULL,
CONSTRAINT PK_{stagingTableName} PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc)
) ON [PRIMARY];
-- 3. Switch the partition out. $partition.pf_AuditLog_Month returns
-- the partition number that contains the supplied boundary value;
-- SWITCH PARTITION N moves that partition's pages to the staging
-- table (metadata-only, no row copying).
DECLARE @partitionNumber int = $partition.pf_AuditLog_Month('{monthBoundaryStr}');
DECLARE @sql nvarchar(max) = 'ALTER TABLE dbo.AuditLog SWITCH PARTITION ' + CAST(@partitionNumber AS nvarchar(10)) + ' TO dbo.[{stagingTableName}];';
EXEC sp_executesql @sql;
-- 4. Drop staging — the rows are discarded here. This is the purge.
DROP TABLE dbo.[{stagingTableName}];
-- 5. Rebuild the non-aligned unique index. Live traffic that hit the
-- table during steps 1-4 saw composite-PK uniqueness only; from
-- here on, single-column EventId uniqueness is restored.
CREATE UNIQUE NONCLUSTERED INDEX UX_AuditLog_EventId ON dbo.AuditLog (EventId) ON [PRIMARY];
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;
-- Best-effort staging cleanup. The DROP INDEX in step 1 is now
-- rolled back (so the index is back), but the staging table from
-- step 2 may or may not survive the rollback depending on the
-- failure point. Guard the DROP so a missing staging table doesn't
-- mask the original error.
IF OBJECT_ID('dbo.[{stagingTableName}]', 'U') IS NOT NULL DROP TABLE dbo.[{stagingTableName}];
-- Idempotent index rebuild — covers the niche case where ROLLBACK
-- failed to restore UX_AuditLog_EventId (or the failure happened
-- AFTER the COMMIT, which shouldn't be possible inside this TRY
-- but is cheap insurance). Without this, a failed switch could
-- leave the live table without its idempotency-supporting index.
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog'))
CREATE UNIQUE NONCLUSTERED INDEX UX_AuditLog_EventId ON dbo.AuditLog (EventId) ON [PRIMARY];
-- Surface the original error to the caller — the purge actor logs
-- and continues with the next boundary.
THROW;
END CATCH;";
// Sample the row count before the switch. The sample is best-effort
// (no transaction wrapping the sample-then-switch pair) because the
// central singleton is the only writer to this RPC and a daily-purge
// tick doesn't compete with concurrent SwitchOut callers. A
// concurrent INSERT racing the sample under-reports by at most a
// few rows, which is acceptable for an "approximate" purged-row
// count surfaced via AuditLogPurgedEvent.
long rowsDeleted = 0;
var conn = _context.Database.GetDbConnection();
var openedHere = false;
if (conn.State != System.Data.ConnectionState.Open)
{
await conn.OpenAsync(ct).ConfigureAwait(false);
openedHere = true;
}
try
{
await using (var sampleCmd = conn.CreateCommand())
{
sampleCmd.CommandText = sampleSql;
var sampleResult = await sampleCmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
if (sampleResult is not null && sampleResult is not DBNull)
{
rowsDeleted = Convert.ToInt64(sampleResult);
}
}
}
finally
{
if (openedHere)
{
await conn.CloseAsync().ConfigureAwait(false);
}
}
await _context.Database.ExecuteSqlRawAsync(sql, ct);
return rowsDeleted;
}
/// <inheritdoc />
public async Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold,
CancellationToken ct = default)
{
var thresholdUtc = threshold.ToUniversalTime();
var thresholdStr = thresholdUtc.ToString("yyyy-MM-dd HH:mm:ss.fffffff");
// Per-partition MAX over the live table. We materialise the boundary
// list first (24 rows) then LEFT JOIN to the MAX aggregate so empty
// partitions surface as NULL and get filtered out by the WHERE clause.
var sql = $@"
WITH Boundaries AS (
SELECT CAST(rv.value AS datetime2(7)) AS BoundaryValue,
rv.boundary_id AS BoundaryId
FROM sys.partition_range_values rv
INNER JOIN sys.partition_functions pf ON rv.function_id = pf.function_id
WHERE pf.name = 'pf_AuditLog_Month'
)
SELECT b.BoundaryValue
FROM Boundaries b
CROSS APPLY (
SELECT MAX(a.OccurredAtUtc) AS MaxOccurredAt
FROM dbo.AuditLog a
WHERE $PARTITION.pf_AuditLog_Month(a.OccurredAtUtc) = b.BoundaryId + 1
) x
WHERE x.MaxOccurredAt IS NOT NULL
AND x.MaxOccurredAt < CAST('{thresholdStr}' AS datetime2(7))
ORDER BY b.BoundaryValue;";
var conn = _context.Database.GetDbConnection();
var openedHere = false;
if (conn.State != System.Data.ConnectionState.Open)
{
await conn.OpenAsync(ct).ConfigureAwait(false);
openedHere = true;
}
var results = new List<DateTime>();
try
{
await using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
// SQL Server's datetime2 surfaces as DateTimeKind.Unspecified
// through ADO.NET (the column type carries no offset/kind).
// Boundary values are stored in UTC, so re-tag the kind here —
// matches the explicit defence in
// AuditLogPartitionMaintenance.GetMaxBoundaryAsync and prevents
// downstream .ToLocalTime()/.ToUniversalTime() conversions
// from silently treating the value as local time.
results.Add(DateTime.SpecifyKind(reader.GetDateTime(0), DateTimeKind.Utc));
}
}
finally
{
if (openedHere)
{
await conn.CloseAsync().ConfigureAwait(false);
}
}
return results;
}
/// <inheritdoc />
public async Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window,
DateTime? nowUtc = null,
CancellationToken ct = default)
{
var anchorUtc = (nowUtc ?? DateTime.UtcNow).ToUniversalTime();
var thresholdUtc = anchorUtc - window;
// ExecuteSqlInterpolated parameterises every interpolation — the enum
// discriminators are passed as varchar parameters that match the
// varchar(32) Status column (HasConversion<string>()).
var failedStr = nameof(Commons.Types.Enums.AuditStatus.Failed);
var parkedStr = nameof(Commons.Types.Enums.AuditStatus.Parked);
var discardedStr = nameof(Commons.Types.Enums.AuditStatus.Discarded);
long total = 0;
long errors = 0;
var conn = _context.Database.GetDbConnection();
var openedHere = false;
if (conn.State != System.Data.ConnectionState.Open)
{
await conn.OpenAsync(ct).ConfigureAwait(false);
openedHere = true;
}
try
{
await using var cmd = conn.CreateCommand();
// Named parameters keep the prepared statement cache stable across
// calls — only the values change. COUNT_BIG returns a bigint so
// we read into long even when the running total fits in int.
cmd.CommandText = @"
SELECT
COUNT_BIG(*) AS Total,
SUM(CASE WHEN Status IN (@failed, @parked, @discarded) THEN 1 ELSE 0 END) AS Errors
FROM dbo.AuditLog
WHERE OccurredAtUtc >= @threshold
AND OccurredAtUtc <= @anchor;";
var pThreshold = cmd.CreateParameter();
pThreshold.ParameterName = "@threshold";
pThreshold.Value = thresholdUtc;
cmd.Parameters.Add(pThreshold);
var pAnchor = cmd.CreateParameter();
pAnchor.ParameterName = "@anchor";
pAnchor.Value = anchorUtc;
cmd.Parameters.Add(pAnchor);
var pFailed = cmd.CreateParameter();
pFailed.ParameterName = "@failed";
pFailed.Value = failedStr;
cmd.Parameters.Add(pFailed);
var pParked = cmd.CreateParameter();
pParked.ParameterName = "@parked";
pParked.Value = parkedStr;
cmd.Parameters.Add(pParked);
var pDiscarded = cmd.CreateParameter();
pDiscarded.ParameterName = "@discarded";
pDiscarded.Value = discardedStr;
cmd.Parameters.Add(pDiscarded);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
if (await reader.ReadAsync(ct).ConfigureAwait(false))
{
// SUM over an empty set is NULL; COUNT_BIG over an empty set is 0.
total = reader.IsDBNull(0) ? 0L : reader.GetInt64(0);
errors = reader.IsDBNull(1) ? 0L : Convert.ToInt64(reader.GetValue(1));
}
}
finally
{
if (openedHere)
{
await conn.CloseAsync().ConfigureAwait(false);
}
}
return new AuditLogKpiSnapshot(
TotalEventsLastHour: total,
ErrorEventsLastHour: errors,
BacklogTotal: 0L,
AsOfUtc: anchorUtc);
}
// Hard ceiling on chain depth for both the upward walk and the downward
// recursive CTE. The ParentExecutionId graph is a tree (acyclic by
// construction — each execution is minted fresh, its parent always
// pre-exists), so this is purely a guard against corrupt/pathological data:
// a cycle must surface as a bounded error rather than hang the server.
// Chains are shallow (1-2 levels typical) so the guard is never reached in
// practice.
private const int ExecutionChainMaxDepth = 32;
/// <inheritdoc />
public async Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId,
CancellationToken ct = default)
{
var conn = _context.Database.GetDbConnection();
var openedHere = false;
if (conn.State != System.Data.ConnectionState.Open)
{
await conn.OpenAsync(ct).ConfigureAwait(false);
openedHere = true;
}
try
{
// --- Phase 1: walk up to the root ---------------------------------
// Climb ParentExecutionId until a node has no parent (root) or the
// parent execution has no rows of its own (purged/stub — the climb
// cannot continue past a row-less node). The depth cap guards
// against a cycle in corrupt data; a tree never reaches it.
var rootExecutionId = executionId;
for (var depth = 0; depth < ExecutionChainMaxDepth; depth++)
{
Guid? parent;
await using (var upCmd = conn.CreateCommand())
{
upCmd.CommandText =
"SELECT TOP 1 ParentExecutionId FROM dbo.AuditLog " +
"WHERE ExecutionId = @cur AND ParentExecutionId IS NOT NULL;";
var pCur = upCmd.CreateParameter();
pCur.ParameterName = "@cur";
pCur.Value = rootExecutionId;
upCmd.Parameters.Add(pCur);
var result = await upCmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
parent = result is null or DBNull ? null : (Guid)result;
}
if (parent is null)
{
// No parent row for the current node — it is the root (or a
// row-less stub at the top of what survives). Stop climbing.
break;
}
rootExecutionId = parent.Value;
}
// --- Phase 2: walk down from the root via a recursive CTE ---------
// Edges : a non-recursive, DISTINCT (ExecutionId, ParentExecutionId)
// edge set distilled from AuditLog. Recursing over edges
// instead of raw rows means an execution with N audit rows
// contributes ONE recursion path, not N — MAXRECURSION
// bounds depth, not per-level width, so the raw-row form
// could fan out badly. One edge per execution because all
// rows of an execution share a single ParentExecutionId
// (see the MIN(...) note on the final projection).
// Chain : seeded at the root edge, recursively joins each edge whose
// ParentExecutionId is an ExecutionId already in the chain.
// Each edge carries its own ParentExecutionId, so the chain
// of edges already surfaces every execution id in the tree
// — including a row-less stub parent, which appears as the
// ParentExecutionId of its child's edge. No separate
// union-back CTE is needed.
// Final : collect every distinct execution id reachable from the
// chain (each edge's ExecutionId plus its non-null
// ParentExecutionId), LEFT JOIN back to AuditLog and
// GROUP BY so a stub parent — which owns no edge of its own
// because it emitted no rows — still surfaces as a node with
// RowCount 0 and NULL aggregates.
var nodes = new List<ExecutionTreeNode>();
await using (var downCmd = conn.CreateCommand())
{
downCmd.CommandText = $@"
WITH Edges AS (
SELECT DISTINCT ExecutionId, ParentExecutionId
FROM dbo.AuditLog
WHERE ExecutionId IS NOT NULL
),
Chain AS (
-- Anchor: the root execution id, seeded as a literal so
-- it is present even when the root is a row-less stub
-- (a purged/no-action parent owns no edge of its own).
-- The root is parentless by construction — the upward
-- walk stopped there — so its ParentExecutionId is NULL.
SELECT CAST(@root AS uniqueidentifier) AS ExecutionId,
CAST(NULL AS uniqueidentifier) AS ParentExecutionId
UNION ALL
SELECT e.ExecutionId, e.ParentExecutionId
FROM Edges e
INNER JOIN Chain c ON e.ParentExecutionId = c.ExecutionId
),
ChainIds AS (
SELECT ExecutionId FROM Chain
UNION
SELECT ParentExecutionId FROM Chain
WHERE ParentExecutionId IS NOT NULL
)
-- ParentExecutionId / SourceSiteId / SourceInstanceId are
-- derived via MIN: every audit row of one execution carries
-- the SAME ParentExecutionId (and source identity) — it is
-- stamped once per script run / inbound request — so MIN
-- simply picks that one shared value, it is not collapsing a
-- genuine disagreement across rows.
SELECT
ids.ExecutionId AS [ExecutionId],
MIN(a.ParentExecutionId) AS [ParentExecutionId],
COUNT(a.EventId) AS [RowCount],
(SELECT STRING_AGG(d.Channel, ',')
FROM (SELECT DISTINCT a2.Channel FROM dbo.AuditLog a2
WHERE a2.ExecutionId = ids.ExecutionId) d) AS [Channels],
(SELECT STRING_AGG(d.Status, ',')
FROM (SELECT DISTINCT a2.Status FROM dbo.AuditLog a2
WHERE a2.ExecutionId = ids.ExecutionId) d) AS [Statuses],
MIN(a.SourceSiteId) AS [SourceSiteId],
MIN(a.SourceInstanceId) AS [SourceInstanceId],
MIN(a.OccurredAtUtc) AS [FirstOccurredAtUtc],
MAX(a.OccurredAtUtc) AS [LastOccurredAtUtc]
FROM ChainIds ids
LEFT JOIN dbo.AuditLog a ON a.ExecutionId = ids.ExecutionId
GROUP BY ids.ExecutionId
OPTION (MAXRECURSION {ExecutionChainMaxDepth});";
var pRoot = downCmd.CreateParameter();
pRoot.ParameterName = "@root";
pRoot.Value = rootExecutionId;
downCmd.Parameters.Add(pRoot);
await using var reader = await downCmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
var nodeExecutionId = reader.GetGuid(0);
Guid? parentExecutionId = reader.IsDBNull(1) ? null : reader.GetGuid(1);
var rowCount = reader.GetInt32(2);
var channels = SplitAggregate(reader.IsDBNull(3) ? null : reader.GetString(3));
var statuses = SplitAggregate(reader.IsDBNull(4) ? null : reader.GetString(4));
var sourceSiteId = reader.IsDBNull(5) ? null : reader.GetString(5);
var sourceInstanceId = reader.IsDBNull(6) ? null : reader.GetString(6);
DateTime? firstOccurred = reader.IsDBNull(7) ? null : reader.GetDateTime(7);
DateTime? lastOccurred = reader.IsDBNull(8) ? null : reader.GetDateTime(8);
nodes.Add(new ExecutionTreeNode(
ExecutionId: nodeExecutionId,
ParentExecutionId: parentExecutionId,
RowCount: rowCount,
Channels: channels,
Statuses: statuses,
SourceSiteId: sourceSiteId,
SourceInstanceId: sourceInstanceId,
FirstOccurredAtUtc: firstOccurred,
LastOccurredAtUtc: lastOccurred));
}
}
return nodes;
}
finally
{
if (openedHere)
{
await conn.CloseAsync().ConfigureAwait(false);
}
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default)
{
return await _context.Set<AuditEvent>()
.AsNoTracking()
.Where(e => e.SourceNode != null)
.Select(e => e.SourceNode!)
.Distinct()
.OrderBy(n => n)
.ToListAsync(ct);
}
/// <summary>
/// Splits a <c>STRING_AGG</c> comma-joined value into a distinct, ordered
/// list. A null/empty aggregate (a stub node with no rows) yields an empty
/// list rather than a single empty string.
/// </summary>
private static IReadOnlyList<string> SplitAggregate(string? aggregate)
{
if (string.IsNullOrEmpty(aggregate))
{
return Array.Empty<string>();
}
return aggregate
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.Ordinal)
.OrderBy(v => v, StringComparer.Ordinal)
.ToArray();
}
}
@@ -0,0 +1,166 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
public class CentralUiRepository : ICentralUiRepository
{
private readonly ScadaBridgeDbContext _context;
/// <summary>
/// Initializes a new instance of the <see cref="CentralUiRepository"/> class.
/// </summary>
/// <param name="context">The EF Core database context.</param>
public CentralUiRepository(ScadaBridgeDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
/// <inheritdoc />
public async Task<IReadOnlyList<Site>> GetAllSitesAsync(CancellationToken cancellationToken = default)
{
return await _context.Sites
.AsNoTracking()
.OrderBy(s => s.Name)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
{
return await _context.DataConnections
.AsNoTracking()
.Where(d => d.SiteId == siteId)
.OrderBy(d => d.Name)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<DataConnection>> GetAllDataConnectionsAsync(CancellationToken cancellationToken = default)
{
return await _context.DataConnections
.AsNoTracking()
.OrderBy(d => d.Name)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Template>> GetTemplateTreeAsync(CancellationToken cancellationToken = default)
{
return await _context.Templates
.AsNoTracking()
.Include(t => t.Attributes)
.Include(t => t.Alarms)
.Include(t => t.Scripts)
.Include(t => t.Compositions)
.AsSplitQuery()
.OrderBy(t => t.Name)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Instance>> GetInstancesFilteredAsync(
int? siteId = null,
int? templateId = null,
string? searchTerm = null,
CancellationToken cancellationToken = default)
{
var query = _context.Instances.AsNoTracking().AsQueryable();
if (siteId.HasValue)
query = query.Where(i => i.SiteId == siteId.Value);
if (templateId.HasValue)
query = query.Where(i => i.TemplateId == templateId.Value);
if (!string.IsNullOrWhiteSpace(searchTerm))
query = query.Where(i => i.UniqueName.Contains(searchTerm));
return await query
.OrderBy(i => i.UniqueName)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<DeploymentRecord>> GetRecentDeploymentsAsync(int count, CancellationToken cancellationToken = default)
{
return await _context.DeploymentRecords
.AsNoTracking()
.OrderByDescending(d => d.DeployedAt)
.Take(count)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Area>> GetAreaTreeBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
{
return await _context.Areas
.AsNoTracking()
.Where(a => a.SiteId == siteId)
.Include(a => a.Children)
.OrderBy(a => a.Name)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<(IReadOnlyList<AuditLogEntry> Entries, int TotalCount)> GetAuditLogEntriesAsync(
string? user = null,
string? entityType = null,
string? action = null,
DateTimeOffset? from = null,
DateTimeOffset? to = null,
string? entityId = null,
string? entityName = null,
Guid? bundleImportId = null,
int page = 1,
int pageSize = 50,
CancellationToken cancellationToken = default)
{
var query = _context.AuditLogEntries.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(user))
query = query.Where(a => a.User == user);
if (!string.IsNullOrWhiteSpace(entityType))
query = query.Where(a => a.EntityType == entityType);
if (!string.IsNullOrWhiteSpace(action))
query = query.Where(a => a.Action == action);
if (from.HasValue)
query = query.Where(a => a.Timestamp >= from.Value);
if (to.HasValue)
query = query.Where(a => a.Timestamp <= to.Value);
if (!string.IsNullOrWhiteSpace(entityId))
query = query.Where(a => a.EntityId == entityId);
if (!string.IsNullOrWhiteSpace(entityName))
query = query.Where(a => a.EntityName.Contains(entityName));
if (bundleImportId is Guid bundleId)
query = query.Where(a => a.BundleImportId == bundleId);
var totalCount = await query.CountAsync(cancellationToken);
var entries = await query
.OrderByDescending(a => a.Timestamp)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
return (entries, totalCount);
}
/// <inheritdoc />
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
return await _context.SaveChangesAsync(cancellationToken);
}
}
@@ -0,0 +1,250 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
/// <summary>
/// EF Core implementation of <see cref="IDeploymentManagerRepository"/> covering
/// the deployment pipeline's persistence surface: <c>DeploymentRecord</c> CRUD
/// (with optimistic concurrency via <c>DeploymentRecord.RowVersion</c>),
/// <c>SystemArtifactDeploymentRecord</c> CRUD, <c>DeployedConfigSnapshot</c> CRUD,
/// and a Restrict-FK-aware <see cref="DeleteInstanceAsync"/> that explicitly
/// clears dependent deployment-record rows before removing an instance.
/// </summary>
public class DeploymentManagerRepository : IDeploymentManagerRepository
{
private readonly ScadaBridgeDbContext _dbContext;
/// <summary>
/// Initializes a new instance of the DeploymentManagerRepository class.
/// </summary>
/// <param name="dbContext">The database context for accessing deployment data.</param>
public DeploymentManagerRepository(ScadaBridgeDbContext dbContext)
{
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
}
// --- DeploymentRecord ---
/// <inheritdoc />
public async Task<DeploymentRecord?> GetDeploymentRecordByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _dbContext.DeploymentRecords.FindAsync([id], cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<DeploymentRecord>> GetAllDeploymentRecordsAsync(CancellationToken cancellationToken = default)
{
return await _dbContext.DeploymentRecords
.OrderByDescending(d => d.DeployedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<DeploymentRecord>> GetDeploymentsByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
{
return await _dbContext.DeploymentRecords
.Where(d => d.InstanceId == instanceId)
.OrderByDescending(d => d.DeployedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<DeploymentRecord?> GetCurrentDeploymentStatusAsync(int instanceId, CancellationToken cancellationToken = default)
{
return await _dbContext.DeploymentRecords
.Where(d => d.InstanceId == instanceId)
.OrderByDescending(d => d.DeployedAt)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<DeploymentRecord?> GetDeploymentByDeploymentIdAsync(string deploymentId, CancellationToken cancellationToken = default)
{
return await _dbContext.DeploymentRecords
.FirstOrDefaultAsync(d => d.DeploymentId == deploymentId, cancellationToken);
}
/// <inheritdoc />
public async Task AddDeploymentRecordAsync(DeploymentRecord record, CancellationToken cancellationToken = default)
{
await _dbContext.DeploymentRecords.AddAsync(record, cancellationToken);
}
/// <inheritdoc />
public Task UpdateDeploymentRecordAsync(DeploymentRecord record, CancellationToken cancellationToken = default)
{
_dbContext.DeploymentRecords.Update(record);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteDeploymentRecordAsync(int id, byte[] expectedRowVersion, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(expectedRowVersion);
// CD-017: DeploymentRecord carries a SQL Server rowversion concurrency token.
// The stub-attach delete path must seed EF's OriginalValues["RowVersion"] with
// the caller's last-observed value so the generated SQL becomes
// `DELETE ... WHERE Id = @id AND RowVersion = @prior`. Without this seeding a
// concurrent edit is silently overwritten; with it, EF raises
// DbUpdateConcurrencyException on SaveChangesAsync — the documented
// optimistic-concurrency contract on deployment status records.
var record = _dbContext.DeploymentRecords.Local.FirstOrDefault(d => d.Id == id);
if (record != null)
{
var entry = _dbContext.Entry(record);
entry.OriginalValues["RowVersion"] = expectedRowVersion;
_dbContext.DeploymentRecords.Remove(record);
}
else
{
var stub = new DeploymentRecord("stub", "stub") { Id = id };
_dbContext.DeploymentRecords.Attach(stub);
var entry = _dbContext.Entry(stub);
entry.OriginalValues["RowVersion"] = expectedRowVersion;
_dbContext.DeploymentRecords.Remove(stub);
}
return Task.CompletedTask;
}
// --- SystemArtifactDeploymentRecord ---
/// <inheritdoc />
public async Task<SystemArtifactDeploymentRecord?> GetSystemArtifactDeploymentByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _dbContext.SystemArtifactDeploymentRecords.FindAsync([id], cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<SystemArtifactDeploymentRecord>> GetAllSystemArtifactDeploymentsAsync(CancellationToken cancellationToken = default)
{
return await _dbContext.SystemArtifactDeploymentRecords
.OrderByDescending(d => d.DeployedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddSystemArtifactDeploymentAsync(SystemArtifactDeploymentRecord record, CancellationToken cancellationToken = default)
{
await _dbContext.SystemArtifactDeploymentRecords.AddAsync(record, cancellationToken);
}
/// <inheritdoc />
public Task UpdateSystemArtifactDeploymentAsync(SystemArtifactDeploymentRecord record, CancellationToken cancellationToken = default)
{
_dbContext.SystemArtifactDeploymentRecords.Update(record);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteSystemArtifactDeploymentAsync(int id, CancellationToken cancellationToken = default)
{
var record = _dbContext.SystemArtifactDeploymentRecords.Local.FirstOrDefault(d => d.Id == id);
if (record != null)
{
_dbContext.SystemArtifactDeploymentRecords.Remove(record);
}
else
{
var stub = new SystemArtifactDeploymentRecord("stub", "stub") { Id = id };
_dbContext.SystemArtifactDeploymentRecords.Attach(stub);
_dbContext.SystemArtifactDeploymentRecords.Remove(stub);
}
return Task.CompletedTask;
}
// --- WP-8: DeployedConfigSnapshot ---
/// <inheritdoc />
public async Task<DeployedConfigSnapshot?> GetDeployedSnapshotByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
{
return await _dbContext.Set<DeployedConfigSnapshot>()
.FirstOrDefaultAsync(s => s.InstanceId == instanceId, cancellationToken);
}
/// <inheritdoc />
public async Task AddDeployedSnapshotAsync(DeployedConfigSnapshot snapshot, CancellationToken cancellationToken = default)
{
await _dbContext.Set<DeployedConfigSnapshot>().AddAsync(snapshot, cancellationToken);
}
/// <inheritdoc />
public Task UpdateDeployedSnapshotAsync(DeployedConfigSnapshot snapshot, CancellationToken cancellationToken = default)
{
_dbContext.Set<DeployedConfigSnapshot>().Update(snapshot);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteDeployedSnapshotAsync(int instanceId, CancellationToken cancellationToken = default)
{
var snapshot = await _dbContext.Set<DeployedConfigSnapshot>()
.FirstOrDefaultAsync(s => s.InstanceId == instanceId, cancellationToken);
if (snapshot != null)
{
_dbContext.Set<DeployedConfigSnapshot>().Remove(snapshot);
}
}
// --- Instance lookups for deployment pipeline ---
/// <inheritdoc />
public async Task<Instance?> GetInstanceByIdAsync(int instanceId, CancellationToken cancellationToken = default)
{
return await _dbContext.Set<Instance>()
.Include(i => i.AttributeOverrides)
.Include(i => i.AlarmOverrides)
.Include(i => i.ConnectionBindings)
.AsSplitQuery()
.FirstOrDefaultAsync(i => i.Id == instanceId, cancellationToken);
}
/// <inheritdoc />
public async Task<Instance?> GetInstanceByUniqueNameAsync(string uniqueName, CancellationToken cancellationToken = default)
{
return await _dbContext.Set<Instance>()
.Include(i => i.AttributeOverrides)
.Include(i => i.AlarmOverrides)
.Include(i => i.ConnectionBindings)
.AsSplitQuery()
.FirstOrDefaultAsync(i => i.UniqueName == uniqueName, cancellationToken);
}
/// <inheritdoc />
public Task UpdateInstanceAsync(Instance instance, CancellationToken cancellationToken = default)
{
_dbContext.Set<Instance>().Update(instance);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteInstanceAsync(int instanceId, CancellationToken cancellationToken = default)
{
// DeploymentRecords have a Restrict FK to Instance — remove them
// explicitly first. The snapshot, overrides, and connection bindings
// are configured with cascade delete and go with the instance.
var records = await _dbContext.DeploymentRecords
.Where(d => d.InstanceId == instanceId)
.ToListAsync(cancellationToken);
if (records.Count > 0)
{
_dbContext.DeploymentRecords.RemoveRange(records);
}
var instance = await _dbContext.Set<Instance>()
.FirstOrDefaultAsync(i => i.Id == instanceId, cancellationToken);
if (instance != null)
{
_dbContext.Set<Instance>().Remove(instance);
}
}
/// <inheritdoc />
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
return await _dbContext.SaveChangesAsync(cancellationToken);
}
}
@@ -0,0 +1,113 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
public class ExternalSystemRepository : IExternalSystemRepository
{
private readonly ScadaBridgeDbContext _context;
/// <summary>
/// Initializes a new instance of the ExternalSystemRepository class.
/// </summary>
/// <param name="context">The database context for accessing external system data.</param>
public ExternalSystemRepository(ScadaBridgeDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
/// <inheritdoc />
public async Task<ExternalSystemDefinition?> GetExternalSystemByIdAsync(int id, CancellationToken cancellationToken = default)
=> await _context.Set<ExternalSystemDefinition>().FindAsync(new object[] { id }, cancellationToken);
/// <inheritdoc />
// ExternalSystemGateway-011: genuine name-keyed query (server-side WHERE) so the
// gateway's hot-path resolution does not fetch every system and filter in memory.
public async Task<ExternalSystemDefinition?> GetExternalSystemByNameAsync(string name, CancellationToken cancellationToken = default)
=> await _context.Set<ExternalSystemDefinition>()
.FirstOrDefaultAsync(s => s.Name == name, cancellationToken);
/// <inheritdoc />
public async Task<IReadOnlyList<ExternalSystemDefinition>> GetAllExternalSystemsAsync(CancellationToken cancellationToken = default)
=> await _context.Set<ExternalSystemDefinition>().ToListAsync(cancellationToken);
/// <inheritdoc />
public async Task AddExternalSystemAsync(ExternalSystemDefinition definition, CancellationToken cancellationToken = default)
=> await _context.Set<ExternalSystemDefinition>().AddAsync(definition, cancellationToken);
/// <inheritdoc />
public Task UpdateExternalSystemAsync(ExternalSystemDefinition definition, CancellationToken cancellationToken = default)
{ _context.Set<ExternalSystemDefinition>().Update(definition); return Task.CompletedTask; }
/// <inheritdoc />
public async Task DeleteExternalSystemAsync(int id, CancellationToken cancellationToken = default)
{
var entity = await GetExternalSystemByIdAsync(id, cancellationToken);
if (entity != null) _context.Set<ExternalSystemDefinition>().Remove(entity);
}
/// <inheritdoc />
public async Task<ExternalSystemMethod?> GetExternalSystemMethodByIdAsync(int id, CancellationToken cancellationToken = default)
=> await _context.Set<ExternalSystemMethod>().FindAsync(new object[] { id }, cancellationToken);
/// <inheritdoc />
// ExternalSystemGateway-011: genuine name-keyed query scoped to the parent system.
public async Task<ExternalSystemMethod?> GetMethodByNameAsync(int externalSystemId, string methodName, CancellationToken cancellationToken = default)
=> await _context.Set<ExternalSystemMethod>()
.FirstOrDefaultAsync(
m => m.ExternalSystemDefinitionId == externalSystemId && m.Name == methodName,
cancellationToken);
/// <inheritdoc />
public async Task<IReadOnlyList<ExternalSystemMethod>> GetMethodsByExternalSystemIdAsync(int externalSystemId, CancellationToken cancellationToken = default)
=> await _context.Set<ExternalSystemMethod>().Where(m => m.ExternalSystemDefinitionId == externalSystemId).ToListAsync(cancellationToken);
/// <inheritdoc />
public async Task AddExternalSystemMethodAsync(ExternalSystemMethod method, CancellationToken cancellationToken = default)
=> await _context.Set<ExternalSystemMethod>().AddAsync(method, cancellationToken);
/// <inheritdoc />
public Task UpdateExternalSystemMethodAsync(ExternalSystemMethod method, CancellationToken cancellationToken = default)
{ _context.Set<ExternalSystemMethod>().Update(method); return Task.CompletedTask; }
/// <inheritdoc />
public async Task DeleteExternalSystemMethodAsync(int id, CancellationToken cancellationToken = default)
{
var entity = await GetExternalSystemMethodByIdAsync(id, cancellationToken);
if (entity != null) _context.Set<ExternalSystemMethod>().Remove(entity);
}
/// <inheritdoc />
public async Task<DatabaseConnectionDefinition?> GetDatabaseConnectionByIdAsync(int id, CancellationToken cancellationToken = default)
=> await _context.Set<DatabaseConnectionDefinition>().FindAsync(new object[] { id }, cancellationToken);
/// <inheritdoc />
// ExternalSystemGateway-011: genuine name-keyed query (server-side WHERE).
public async Task<DatabaseConnectionDefinition?> GetDatabaseConnectionByNameAsync(string name, CancellationToken cancellationToken = default)
=> await _context.Set<DatabaseConnectionDefinition>()
.FirstOrDefaultAsync(c => c.Name == name, cancellationToken);
/// <inheritdoc />
public async Task<IReadOnlyList<DatabaseConnectionDefinition>> GetAllDatabaseConnectionsAsync(CancellationToken cancellationToken = default)
=> await _context.Set<DatabaseConnectionDefinition>().ToListAsync(cancellationToken);
/// <inheritdoc />
public async Task AddDatabaseConnectionAsync(DatabaseConnectionDefinition definition, CancellationToken cancellationToken = default)
=> await _context.Set<DatabaseConnectionDefinition>().AddAsync(definition, cancellationToken);
/// <inheritdoc />
public Task UpdateDatabaseConnectionAsync(DatabaseConnectionDefinition definition, CancellationToken cancellationToken = default)
{ _context.Set<DatabaseConnectionDefinition>().Update(definition); return Task.CompletedTask; }
/// <inheritdoc />
public async Task DeleteDatabaseConnectionAsync(int id, CancellationToken cancellationToken = default)
{
var entity = await GetDatabaseConnectionByIdAsync(id, cancellationToken);
if (entity != null) _context.Set<DatabaseConnectionDefinition>().Remove(entity);
}
/// <inheritdoc />
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
=> await _context.SaveChangesAsync(cancellationToken);
}
@@ -0,0 +1,145 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
public class InboundApiRepository : IInboundApiRepository
{
private readonly ScadaBridgeDbContext _context;
// CD-016: lazily resolved so the InboundAPI ApiKeyHasher factory (which throws
// when no pepper is configured) is only invoked if GetApiKeyByValueAsync is
// actually called — Central/Host startup composition roots that never call
// this method (the production ApiKeyValidator deliberately doesn't) get to
// bring InboundApiRepository up without forcing every test to wire a
// throw-away pepper into InboundApiOptions.
private readonly Func<IApiKeyHasher> _hasherAccessor;
private readonly ILogger<InboundApiRepository> _logger;
/// <summary>
/// Initializes a new instance of the InboundApiRepository class.
/// </summary>
/// <param name="context">The database context for accessing inbound API data.</param>
/// <param name="hasherAccessor">
/// CD-016: factory that returns the API-key hasher used to digest a candidate
/// plaintext for the peppered <see cref="GetApiKeyByValueAsync"/> lookup.
/// Resolution is deferred to first call so a composition root that doesn't
/// register <see cref="IApiKeyHasher"/> (or whose factory would throw because
/// no pepper is configured) can still bring up the repository for callers that
/// don't touch the value-lookup path. Defaults to a factory returning
/// <see cref="ApiKeyHasher.Default"/>; production wires
/// <c>sp =&gt; sp.GetRequiredService&lt;IApiKeyHasher&gt;()</c> via DI so the
/// lookup uses the same peppered digest as the production write path.
/// </param>
/// <param name="logger">Optional logger instance for warnings and diagnostics.</param>
public InboundApiRepository(
ScadaBridgeDbContext context,
Func<IApiKeyHasher>? hasherAccessor = null,
ILogger<InboundApiRepository>? logger = null)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_hasherAccessor = hasherAccessor ?? (() => ApiKeyHasher.Default);
_logger = logger ?? NullLogger<InboundApiRepository>.Instance;
}
/// <inheritdoc />
public async Task<ApiKey?> GetApiKeyByIdAsync(int id, CancellationToken cancellationToken = default)
=> await _context.Set<ApiKey>().FindAsync(new object[] { id }, cancellationToken);
/// <inheritdoc />
public async Task<IReadOnlyList<ApiKey>> GetAllApiKeysAsync(CancellationToken cancellationToken = default)
=> await _context.Set<ApiKey>().ToListAsync(cancellationToken);
/// <inheritdoc />
public async Task<ApiKey?> GetApiKeyByValueAsync(string keyValue, CancellationToken cancellationToken = default)
{
// CD-016: hash the candidate with the DI-provided peppered hasher so this
// lookup matches keys whose stored KeyHash was produced by the production
// ApiKeyHasher(pepper). The pre-fix call to ApiKeyHasher.Default would
// silently return null for every real key on any peppered deployment.
// Resolution is deferred until this method is actually called so the
// pepper-validating factory doesn't fire during startup composition.
var keyHash = _hasherAccessor().Hash(keyValue);
return await _context.Set<ApiKey>().FirstOrDefaultAsync(k => k.KeyHash == keyHash, cancellationToken);
}
/// <inheritdoc />
public async Task AddApiKeyAsync(ApiKey apiKey, CancellationToken cancellationToken = default)
=> await _context.Set<ApiKey>().AddAsync(apiKey, cancellationToken);
/// <inheritdoc />
public Task UpdateApiKeyAsync(ApiKey apiKey, CancellationToken cancellationToken = default)
{ _context.Set<ApiKey>().Update(apiKey); return Task.CompletedTask; }
/// <inheritdoc />
public async Task DeleteApiKeyAsync(int id, CancellationToken cancellationToken = default)
{
var entity = await GetApiKeyByIdAsync(id, cancellationToken);
if (entity != null) _context.Set<ApiKey>().Remove(entity);
}
/// <inheritdoc />
public async Task<ApiMethod?> GetApiMethodByIdAsync(int id, CancellationToken cancellationToken = default)
=> await _context.Set<ApiMethod>().FindAsync(new object[] { id }, cancellationToken);
/// <inheritdoc />
public async Task<IReadOnlyList<ApiMethod>> GetAllApiMethodsAsync(CancellationToken cancellationToken = default)
=> await _context.Set<ApiMethod>().ToListAsync(cancellationToken);
/// <inheritdoc />
public async Task<ApiMethod?> GetMethodByNameAsync(string name, CancellationToken cancellationToken = default)
=> await _context.Set<ApiMethod>().FirstOrDefaultAsync(m => m.Name == name, cancellationToken);
/// <inheritdoc />
public async Task<IReadOnlyList<ApiKey>> GetApprovedKeysForMethodAsync(int methodId, CancellationToken cancellationToken = default)
{
var method = await _context.Set<ApiMethod>().FindAsync(new object[] { methodId }, cancellationToken);
if (method?.ApprovedApiKeyIds == null)
return new List<ApiKey>();
// ApprovedApiKeyIds is a comma-separated string of integer ApiKey ids. A token that
// fails to parse indicates a corrupt value: it is dropped (it cannot identify a key),
// but the corruption is logged as a warning so it is observable rather than silent.
// A corrupt list would otherwise quietly approve fewer keys than intended.
var keyIds = new List<int>();
foreach (var token in method.ApprovedApiKeyIds.Split(',', StringSplitOptions.RemoveEmptyEntries))
{
var trimmed = token.Trim();
if (int.TryParse(trimmed, out var id) && id > 0)
{
keyIds.Add(id);
}
else
{
_logger.LogWarning(
"ApiMethod {MethodId} has a malformed approved-API-key id token '{Token}' " +
"in ApprovedApiKeyIds; it was dropped. The method may approve fewer keys than expected.",
methodId, trimmed);
}
}
return await _context.Set<ApiKey>().Where(k => keyIds.Contains(k.Id)).ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddApiMethodAsync(ApiMethod method, CancellationToken cancellationToken = default)
=> await _context.Set<ApiMethod>().AddAsync(method, cancellationToken);
/// <inheritdoc />
public Task UpdateApiMethodAsync(ApiMethod method, CancellationToken cancellationToken = default)
{ _context.Set<ApiMethod>().Update(method); return Task.CompletedTask; }
/// <inheritdoc />
public async Task DeleteApiMethodAsync(int id, CancellationToken cancellationToken = default)
{
var entity = await GetApiMethodByIdAsync(id, cancellationToken);
if (entity != null) _context.Set<ApiMethod>().Remove(entity);
}
/// <inheritdoc />
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
=> await _context.SaveChangesAsync(cancellationToken);
}
@@ -0,0 +1,318 @@
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
/// <summary>
/// EF Core data access for the central notification outbox. See
/// <see cref="INotificationOutboxRepository"/> for the behaviour contract.
/// </summary>
public class NotificationOutboxRepository : INotificationOutboxRepository
{
// SQL Server duplicate-key error numbers, matching the AuditLogRepository
// and SiteCallAuditRepository race-fixes. 2601 is a unique-index violation;
// 2627 is a primary-key/unique-constraint violation. The IF NOT EXISTS …
// INSERT pattern has a check-then-act race window — two sessions can both
// pass the EXISTS check and then both attempt the INSERT — and the loser
// surfaces as one of these. The site→central handoff is documented
// at-least-once with insert-if-not-exists, so the collision IS the expected
// contention mode; idempotency demands we swallow them rather than let the
// site retry the same NotificationId forever.
private const int SqlErrorUniqueIndexViolation = 2601;
private const int SqlErrorPrimaryKeyViolation = 2627;
private readonly ScadaBridgeDbContext _context;
private readonly ILogger<NotificationOutboxRepository> _logger;
// Statuses that represent a finished notification lifecycle. Non-terminal is the complement.
private static readonly NotificationStatus[] TerminalStatuses =
{
NotificationStatus.Delivered,
NotificationStatus.Parked,
NotificationStatus.Discarded,
};
/// <summary>Initializes a new instance of <see cref="NotificationOutboxRepository"/> with the given EF Core context.</summary>
/// <param name="context">The EF Core database context.</param>
/// <param name="logger">Optional logger instance.</param>
public NotificationOutboxRepository(ScadaBridgeDbContext context, ILogger<NotificationOutboxRepository>? logger = null)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_logger = logger ?? NullLogger<NotificationOutboxRepository>.Instance;
}
/// <inheritdoc />
public async Task<bool> InsertIfNotExistsAsync(Notification n, CancellationToken cancellationToken = default)
{
if (n is null)
{
throw new ArgumentNullException(nameof(n));
}
// Enum columns are stored as varchar(32) (HasConversion<string>()); convert
// in C# rather than relying on parameter type inference (SqlClient would
// otherwise bind enums as int by default and break the column conversion).
var type = n.Type.ToString();
var status = n.Status.ToString();
// FormattableString interpolation parameterises every value (no concatenation),
// so this is safe against injection even for the string columns.
try
{
var rowsAffected = await _context.Database.ExecuteSqlInterpolatedAsync(
$@"IF NOT EXISTS (SELECT 1 FROM dbo.Notifications WHERE NotificationId = {n.NotificationId})
INSERT INTO dbo.Notifications
(NotificationId, Type, ListName, Subject, Body, TypeData, Status, RetryCount, LastError,
ResolvedTargets, SourceSiteId, SourceNode, SourceInstanceId, SourceScript,
OriginExecutionId, OriginParentExecutionId,
SiteEnqueuedAt, CreatedAt, LastAttemptAt, NextAttemptAt, DeliveredAt)
VALUES
({n.NotificationId}, {type}, {n.ListName}, {n.Subject}, {n.Body}, {n.TypeData}, {status}, {n.RetryCount}, {n.LastError},
{n.ResolvedTargets}, {n.SourceSiteId}, {n.SourceNode}, {n.SourceInstanceId}, {n.SourceScript},
{n.OriginExecutionId}, {n.OriginParentExecutionId},
{n.SiteEnqueuedAt}, {n.CreatedAt}, {n.LastAttemptAt}, {n.NextAttemptAt}, {n.DeliveredAt});",
cancellationToken);
// rowsAffected == 1 -> we inserted; 0 -> a prior row was already there
// (IF NOT EXISTS short-circuited the INSERT).
return rowsAffected == 1;
}
catch (SqlException ex) when (
ex.Number == SqlErrorUniqueIndexViolation
|| ex.Number == SqlErrorPrimaryKeyViolation)
{
// Two concurrent sessions both passed IF NOT EXISTS and both
// attempted the INSERT — the loser raises 2601/2627 against the
// NotificationId primary key. First-write-wins idempotency is the
// documented contract (the site→central handoff is at-least-once,
// and the actor discards the return value), so the race outcome is
// semantically a no-op. Returning false here matches the
// "row already existed" branch of the success path.
_logger.LogDebug(
ex,
"InsertIfNotExistsAsync swallowed duplicate-key violation (error {SqlErrorNumber}) for NotificationId {NotificationId}; treating as no-op.",
ex.Number,
n.NotificationId);
return false;
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<Notification>> GetDueAsync(
DateTimeOffset now, int batchSize, CancellationToken cancellationToken = default)
{
return await _context.Notifications
.Where(n => n.Status == NotificationStatus.Pending
|| (n.Status == NotificationStatus.Retrying
&& n.NextAttemptAt != null
&& n.NextAttemptAt <= now))
.OrderBy(n => n.CreatedAt)
.Take(batchSize)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<Notification?> GetByIdAsync(string notificationId, CancellationToken cancellationToken = default)
=> await _context.Notifications.FindAsync(new object[] { notificationId }, cancellationToken);
/// <inheritdoc />
public async Task UpdateAsync(Notification n, CancellationToken cancellationToken = default)
{
_context.Notifications.Update(n);
await _context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<(IReadOnlyList<Notification> Rows, int TotalCount)> QueryAsync(
NotificationOutboxFilter filter, int pageNumber, int pageSize, CancellationToken cancellationToken = default)
{
var query = _context.Notifications.AsQueryable();
if (filter.Status is { } status)
{
query = query.Where(n => n.Status == status);
}
if (filter.Type is { } type)
{
query = query.Where(n => n.Type == type);
}
if (!string.IsNullOrEmpty(filter.SourceSiteId))
{
query = query.Where(n => n.SourceSiteId == filter.SourceSiteId);
}
// Task 16: SourceNode is exact-match like SourceSiteId. Rows with NULL
// SourceNode (legacy / unconfigured) are excluded when the filter is set.
if (!string.IsNullOrEmpty(filter.SourceNode))
{
query = query.Where(n => n.SourceNode == filter.SourceNode);
}
if (!string.IsNullOrEmpty(filter.ListName))
{
query = query.Where(n => n.ListName == filter.ListName);
}
if (!string.IsNullOrEmpty(filter.SubjectKeyword))
{
query = query.Where(n => n.Subject.Contains(filter.SubjectKeyword));
}
if (filter.StuckOnly && filter.StuckCutoff is { } stuckCutoff)
{
query = query.Where(n =>
(n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying)
&& n.CreatedAt < stuckCutoff);
}
if (filter.From is { } from)
{
query = query.Where(n => n.CreatedAt >= from);
}
if (filter.To is { } to)
{
query = query.Where(n => n.CreatedAt <= to);
}
var totalCount = await query.CountAsync(cancellationToken);
var rows = await query
.OrderByDescending(n => n.CreatedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
return (rows, totalCount);
}
/// <inheritdoc />
public async Task<int> DeleteTerminalOlderThanAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
{
return await _context.Notifications
.Where(n => TerminalStatuses.Contains(n.Status) && n.CreatedAt < cutoff)
.ExecuteDeleteAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<NotificationKpiSnapshot> ComputeKpisAsync(
DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var queueDepth = await _context.Notifications
.CountAsync(n => n.Status == NotificationStatus.Pending
|| n.Status == NotificationStatus.Retrying, cancellationToken);
var stuckCount = await _context.Notifications
.CountAsync(n => (n.Status == NotificationStatus.Pending
|| n.Status == NotificationStatus.Retrying)
&& n.CreatedAt < stuckCutoff, cancellationToken);
var parkedCount = await _context.Notifications
.CountAsync(n => n.Status == NotificationStatus.Parked, cancellationToken);
var deliveredLastInterval = await _context.Notifications
.CountAsync(n => n.Status == NotificationStatus.Delivered
&& n.DeliveredAt != null
&& n.DeliveredAt >= deliveredSince, cancellationToken);
// Oldest non-terminal CreatedAt. The DateTimeOffset value converter makes a SQL
// Min aggregate awkward, so order ascending and take the first instead.
var nonTerminal = _context.Notifications
.Where(n => n.Status == NotificationStatus.Pending
|| n.Status == NotificationStatus.Retrying);
TimeSpan? oldestPendingAge = null;
if (await nonTerminal.AnyAsync(cancellationToken))
{
var oldestCreatedAt = await nonTerminal
.OrderBy(n => n.CreatedAt)
.Select(n => n.CreatedAt)
.FirstAsync(cancellationToken);
oldestPendingAge = now - oldestCreatedAt;
}
return new NotificationKpiSnapshot(
QueueDepth: queueDepth,
StuckCount: stuckCount,
ParkedCount: parkedCount,
DeliveredLastInterval: deliveredLastInterval,
OldestPendingAge: oldestPendingAge);
}
/// <inheritdoc />
public async Task<IReadOnlyList<SiteNotificationKpiSnapshot>> ComputePerSiteKpisAsync(
DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var queueDepth = await CountBySiteAsync(
n => n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying,
cancellationToken);
var stuck = await CountBySiteAsync(
n => (n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying)
&& n.CreatedAt < stuckCutoff,
cancellationToken);
var parked = await CountBySiteAsync(
n => n.Status == NotificationStatus.Parked, cancellationToken);
var delivered = await CountBySiteAsync(
n => n.Status == NotificationStatus.Delivered
&& n.DeliveredAt != null && n.DeliveredAt >= deliveredSince,
cancellationToken);
// Oldest non-terminal CreatedAt per site. A SQL Min over the DateTimeOffset
// converter is awkward (see ComputeKpisAsync), so project the non-terminal
// (site, created) pairs — the live queue, which stays bounded — and reduce
// in memory.
var oldest = (await _context.Notifications
.Where(n => n.Status == NotificationStatus.Pending
|| n.Status == NotificationStatus.Retrying)
.Select(n => new { n.SourceSiteId, n.CreatedAt })
.ToListAsync(cancellationToken))
.GroupBy(x => x.SourceSiteId)
.ToDictionary(g => g.Key, g => g.Min(x => x.CreatedAt));
var siteIds = queueDepth.Keys
.Concat(stuck.Keys).Concat(parked.Keys).Concat(delivered.Keys)
.Distinct()
.OrderBy(s => s, StringComparer.Ordinal);
return siteIds.Select(site => new SiteNotificationKpiSnapshot(
SourceSiteId: site,
QueueDepth: queueDepth.GetValueOrDefault(site),
StuckCount: stuck.GetValueOrDefault(site),
ParkedCount: parked.GetValueOrDefault(site),
DeliveredLastInterval: delivered.GetValueOrDefault(site),
OldestPendingAge: oldest.TryGetValue(site, out var createdAt)
? now - createdAt
: null)).ToList();
}
/// <summary>Counts notification rows matching <paramref name="predicate"/>, grouped by source site.</summary>
private async Task<Dictionary<string, int>> CountBySiteAsync(
System.Linq.Expressions.Expression<Func<Notification, bool>> predicate,
CancellationToken cancellationToken)
{
return await _context.Notifications
.Where(predicate)
.GroupBy(n => n.SourceSiteId)
.Select(g => new { Site = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Site, x => x.Count, cancellationToken);
}
/// <inheritdoc />
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
=> await _context.SaveChangesAsync(cancellationToken);
}
@@ -0,0 +1,94 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
public class NotificationRepository : INotificationRepository
{
private readonly ScadaBridgeDbContext _context;
/// <summary>Initializes a new instance of the NotificationRepository class.</summary>
/// <param name="context">The database context.</param>
public NotificationRepository(ScadaBridgeDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
/// <inheritdoc />
public async Task<NotificationList?> GetNotificationListByIdAsync(int id, CancellationToken cancellationToken = default)
=> await _context.Set<NotificationList>().FindAsync(new object[] { id }, cancellationToken);
/// <inheritdoc />
public async Task<IReadOnlyList<NotificationList>> GetAllNotificationListsAsync(CancellationToken cancellationToken = default)
=> await _context.Set<NotificationList>().Include(n => n.Recipients).ToListAsync(cancellationToken);
/// <inheritdoc />
public async Task<NotificationList?> GetListByNameAsync(string name, CancellationToken cancellationToken = default)
=> await _context.Set<NotificationList>().FirstOrDefaultAsync(l => l.Name == name, cancellationToken);
/// <inheritdoc />
public async Task AddNotificationListAsync(NotificationList list, CancellationToken cancellationToken = default)
=> await _context.Set<NotificationList>().AddAsync(list, cancellationToken);
/// <inheritdoc />
public Task UpdateNotificationListAsync(NotificationList list, CancellationToken cancellationToken = default)
{ _context.Set<NotificationList>().Update(list); return Task.CompletedTask; }
/// <inheritdoc />
public async Task DeleteNotificationListAsync(int id, CancellationToken cancellationToken = default)
{
var entity = await GetNotificationListByIdAsync(id, cancellationToken);
if (entity != null) _context.Set<NotificationList>().Remove(entity);
}
/// <inheritdoc />
public async Task<NotificationRecipient?> GetRecipientByIdAsync(int id, CancellationToken cancellationToken = default)
=> await _context.Set<NotificationRecipient>().FindAsync(new object[] { id }, cancellationToken);
/// <inheritdoc />
public async Task<IReadOnlyList<NotificationRecipient>> GetRecipientsByListIdAsync(int notificationListId, CancellationToken cancellationToken = default)
=> await _context.Set<NotificationRecipient>().Where(r => r.NotificationListId == notificationListId).ToListAsync(cancellationToken);
/// <inheritdoc />
public async Task AddRecipientAsync(NotificationRecipient recipient, CancellationToken cancellationToken = default)
=> await _context.Set<NotificationRecipient>().AddAsync(recipient, cancellationToken);
/// <inheritdoc />
public Task UpdateRecipientAsync(NotificationRecipient recipient, CancellationToken cancellationToken = default)
{ _context.Set<NotificationRecipient>().Update(recipient); return Task.CompletedTask; }
/// <inheritdoc />
public async Task DeleteRecipientAsync(int id, CancellationToken cancellationToken = default)
{
var entity = await GetRecipientByIdAsync(id, cancellationToken);
if (entity != null) _context.Set<NotificationRecipient>().Remove(entity);
}
/// <inheritdoc />
public async Task<SmtpConfiguration?> GetSmtpConfigurationByIdAsync(int id, CancellationToken cancellationToken = default)
=> await _context.Set<SmtpConfiguration>().FindAsync(new object[] { id }, cancellationToken);
/// <inheritdoc />
public async Task<IReadOnlyList<SmtpConfiguration>> GetAllSmtpConfigurationsAsync(CancellationToken cancellationToken = default)
=> await _context.Set<SmtpConfiguration>().ToListAsync(cancellationToken);
/// <inheritdoc />
public async Task AddSmtpConfigurationAsync(SmtpConfiguration configuration, CancellationToken cancellationToken = default)
=> await _context.Set<SmtpConfiguration>().AddAsync(configuration, cancellationToken);
/// <inheritdoc />
public Task UpdateSmtpConfigurationAsync(SmtpConfiguration configuration, CancellationToken cancellationToken = default)
{ _context.Set<SmtpConfiguration>().Update(configuration); return Task.CompletedTask; }
/// <inheritdoc />
public async Task DeleteSmtpConfigurationAsync(int id, CancellationToken cancellationToken = default)
{
var entity = await GetSmtpConfigurationByIdAsync(id, cancellationToken);
if (entity != null) _context.Set<SmtpConfiguration>().Remove(entity);
}
/// <inheritdoc />
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
=> await _context.SaveChangesAsync(cancellationToken);
}
@@ -0,0 +1,109 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
public class SecurityRepository : ISecurityRepository
{
private readonly ScadaBridgeDbContext _context;
/// <summary>
/// Initializes a new instance of the SecurityRepository.
/// </summary>
/// <param name="context">The database context.</param>
public SecurityRepository(ScadaBridgeDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
// LdapGroupMapping
/// <inheritdoc />
public async Task<LdapGroupMapping?> GetMappingByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.LdapGroupMappings.FindAsync(new object[] { id }, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<LdapGroupMapping>> GetAllMappingsAsync(CancellationToken cancellationToken = default)
{
return await _context.LdapGroupMappings.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<LdapGroupMapping>> GetMappingsByRoleAsync(string role, CancellationToken cancellationToken = default)
{
return await _context.LdapGroupMappings
.Where(m => m.Role == role)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddMappingAsync(LdapGroupMapping mapping, CancellationToken cancellationToken = default)
{
await _context.LdapGroupMappings.AddAsync(mapping, cancellationToken);
}
/// <inheritdoc />
public Task UpdateMappingAsync(LdapGroupMapping mapping, CancellationToken cancellationToken = default)
{
_context.LdapGroupMappings.Update(mapping);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteMappingAsync(int id, CancellationToken cancellationToken = default)
{
var mapping = await _context.LdapGroupMappings.FindAsync(new object[] { id }, cancellationToken);
if (mapping != null)
{
_context.LdapGroupMappings.Remove(mapping);
}
}
// SiteScopeRule
/// <inheritdoc />
public async Task<SiteScopeRule?> GetScopeRuleByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.SiteScopeRules.FindAsync(new object[] { id }, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<SiteScopeRule>> GetScopeRulesForMappingAsync(int ldapGroupMappingId, CancellationToken cancellationToken = default)
{
return await _context.SiteScopeRules
.Where(r => r.LdapGroupMappingId == ldapGroupMappingId)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddScopeRuleAsync(SiteScopeRule rule, CancellationToken cancellationToken = default)
{
await _context.SiteScopeRules.AddAsync(rule, cancellationToken);
}
/// <inheritdoc />
public Task UpdateScopeRuleAsync(SiteScopeRule rule, CancellationToken cancellationToken = default)
{
_context.SiteScopeRules.Update(rule);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteScopeRuleAsync(int id, CancellationToken cancellationToken = default)
{
var rule = await _context.SiteScopeRules.FindAsync(new object[] { id }, cancellationToken);
if (rule != null)
{
_context.SiteScopeRules.Remove(rule);
}
}
/// <inheritdoc />
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
return await _context.SaveChangesAsync(cancellationToken);
}
}
@@ -0,0 +1,349 @@
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
/// <summary>
/// EF Core implementation of <see cref="ISiteCallAuditRepository"/>. See the
/// interface for the monotonic-upsert contract; this class adds notes on the
/// data-access strategy used by each method.
/// </summary>
public class SiteCallAuditRepository : ISiteCallAuditRepository
{
// SQL Server duplicate-key error numbers, identical to the AuditLogRepository
// race-fix: 2601 = unique-index violation, 2627 = PK/unique-constraint
// violation. The IF NOT EXISTS … INSERT pattern has a check-then-act window
// and the loser surfaces as one of these; monotonic-upsert semantics demand
// we swallow them.
private const int SqlErrorUniqueIndexViolation = 2601;
private const int SqlErrorPrimaryKeyViolation = 2627;
// Monotonic status ordering. Lower rank wins on tie (same-rank upserts are
// no-ops, including terminal-over-terminal). Spec from Bundle B3 plan:
// Submitted < Forwarded < Attempted == Skipped < Delivered == Failed == Parked == Discarded.
private static readonly Dictionary<string, int> StatusRank = new(StringComparer.Ordinal)
{
["Submitted"] = 0,
["Forwarded"] = 1,
["Attempted"] = 2,
["Skipped"] = 2,
["Delivered"] = 3,
["Failed"] = 3,
["Parked"] = 3,
["Discarded"] = 3,
};
private readonly ScadaBridgeDbContext _context;
private readonly ILogger<SiteCallAuditRepository> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="SiteCallAuditRepository"/> class.
/// </summary>
/// <param name="context">The EF Core database context.</param>
/// <param name="logger">Optional logger for diagnostic information.</param>
public SiteCallAuditRepository(ScadaBridgeDbContext context, ILogger<SiteCallAuditRepository>? logger = null)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_logger = logger ?? NullLogger<SiteCallAuditRepository>.Instance;
}
/// <inheritdoc />
public async Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default)
{
if (siteCall is null)
{
throw new ArgumentNullException(nameof(siteCall));
}
var idText = siteCall.TrackedOperationId.Value.ToString("D");
var incomingRank = GetRankOrThrow(siteCall.Status);
// Step 1: insert-if-not-exists. Like AuditLogRepository.InsertIfNotExistsAsync
// this is check-then-act so a duplicate-key violation may surface under
// concurrent inserts on the same id — caught + logged at Debug.
//
// SourceNode-stamping (Task 14): the column is included in the INSERT
// column list / VALUES so a fresh row carries the originating node
// name (node-a/node-b for site rows). A null SourceNode (legacy hosts
// / unstamped reconciled rows) writes NULL straight through.
try
{
await _context.Database.ExecuteSqlInterpolatedAsync(
$@"IF NOT EXISTS (SELECT 1 FROM dbo.SiteCalls WHERE TrackedOperationId = {idText})
INSERT INTO dbo.SiteCalls
(TrackedOperationId, Channel, Target, SourceSite, SourceNode, Status, RetryCount,
LastError, HttpStatus, CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc, IngestedAtUtc)
VALUES
({idText}, {siteCall.Channel}, {siteCall.Target}, {siteCall.SourceSite}, {siteCall.SourceNode}, {siteCall.Status}, {siteCall.RetryCount},
{siteCall.LastError}, {siteCall.HttpStatus}, {siteCall.CreatedAtUtc}, {siteCall.UpdatedAtUtc}, {siteCall.TerminalAtUtc}, {siteCall.IngestedAtUtc});",
ct);
}
catch (SqlException ex) when (
ex.Number == SqlErrorUniqueIndexViolation
|| ex.Number == SqlErrorPrimaryKeyViolation)
{
_logger.LogDebug(
ex,
"SiteCallAuditRepository.UpsertAsync swallowed duplicate-key violation (error {SqlErrorNumber}) for TrackedOperationId {TrackedOperationId}; falling through to monotonic update.",
ex.Number,
idText);
}
// Step 2: monotonic update. The CASE expression maps the stored Status
// string to the same rank table the caller uses; we only mutate if the
// incoming rank is strictly greater. Same-rank (including
// terminal-over-terminal) is a no-op — first-write-wins at each rank.
//
// SourceNode-stamping (Task 14): SourceNode is updated via
// COALESCE(@SourceNode, SourceNode). The operator returns @SourceNode
// when it is non-null, otherwise the stored value — so the column
// behaves protectively: a later packet that carries a null
// SourceNode (e.g. a reconciliation pull from an unstamped node)
// NEVER blanks out a value the first stamping packet set. A later
// packet that DOES carry a non-null SourceNode replaces the previous
// value — combined with the monotonic-rank guard this is
// "last-non-null-wins on rank advance", which lets a missing
// SourceNode be filled in later if Submit happened to be unstamped
// and an Attempt/Resolve carries the node identity. Within one
// lifecycle every packet should carry the same SourceNode value (one
// execution, one node) so the "overwrite" path is in practice
// idempotent.
await _context.Database.ExecuteSqlInterpolatedAsync(
$@"UPDATE dbo.SiteCalls
SET Status = {siteCall.Status},
RetryCount = {siteCall.RetryCount},
LastError = {siteCall.LastError},
HttpStatus = {siteCall.HttpStatus},
UpdatedAtUtc = {siteCall.UpdatedAtUtc},
TerminalAtUtc = {siteCall.TerminalAtUtc},
IngestedAtUtc = {siteCall.IngestedAtUtc},
SourceNode = COALESCE({siteCall.SourceNode}, SourceNode)
WHERE TrackedOperationId = {idText}
AND {incomingRank} > (CASE Status
WHEN 'Submitted' THEN 0
WHEN 'Forwarded' THEN 1
WHEN 'Attempted' THEN 2
WHEN 'Skipped' THEN 2
WHEN 'Delivered' THEN 3
WHEN 'Failed' THEN 3
WHEN 'Parked' THEN 3
WHEN 'Discarded' THEN 3
ELSE -1
END);",
ct);
}
/// <inheritdoc />
public async Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default)
{
return await _context.Set<SiteCall>().FindAsync(new object?[] { id }, ct);
}
/// <inheritdoc />
public async Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default)
{
if (filter is null)
{
throw new ArgumentNullException(nameof(filter));
}
if (paging is null)
{
throw new ArgumentNullException(nameof(paging));
}
// FormattableString interpolation parameterises every value (no concatenation)
// so this is injection-safe. EF Core resolves the parameter values, the
// composed sql is shaped to SQL Server's grammar and projected into the
// SiteCall entity via FromSqlInterpolated. The CASE expressions wrap each
// optional predicate so a null filter field degrades to a no-op (matches
// every row) instead of branching at C# level into N variants.
var afterCreated = paging.AfterCreatedAtUtc;
var afterIdString = paging.AfterId?.Value.ToString("D");
var hasCursor = afterCreated is not null && afterIdString is not null;
var fromUtc = filter.FromUtc;
var toUtc = filter.ToUtc;
var stuckCutoff = filter.StuckCutoffUtc;
// The stuck predicate (TerminalAtUtc IS NULL AND CreatedAtUtc < cutoff)
// is pushed into SQL here — both columns are plain (no value converter)
// and compose with the keyset cursor, so a StuckOnly page is honest:
// never under-filled with a non-null next cursor. Mirrors how
// NotificationOutboxRepository.QueryAsync applies NotificationOutboxFilter.StuckCutoff.
//
// SELECT-list maintenance: EF Core's FromSqlInterpolated requires every
// entity-tracked column to appear in the result set. Adding a new column
// to the SiteCall entity means extending the list below too — otherwise
// every read trips "The required column 'X' was not present" at runtime.
FormattableString sql = $@"
SELECT TOP ({paging.PageSize})
TrackedOperationId, Channel, Target, SourceSite, SourceNode, Status, RetryCount,
LastError, HttpStatus, CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc, IngestedAtUtc
FROM dbo.SiteCalls
WHERE ({filter.Channel} IS NULL OR Channel = {filter.Channel})
AND ({filter.SourceSite} IS NULL OR SourceSite = {filter.SourceSite})
AND ({filter.SourceNode} IS NULL OR SourceNode = {filter.SourceNode})
AND ({filter.Status} IS NULL OR Status = {filter.Status})
AND ({filter.Target} IS NULL OR Target = {filter.Target})
AND ({fromUtc} IS NULL OR CreatedAtUtc >= {fromUtc})
AND ({toUtc} IS NULL OR CreatedAtUtc <= {toUtc})
AND ({stuckCutoff} IS NULL OR (TerminalAtUtc IS NULL AND CreatedAtUtc < {stuckCutoff}))
AND ({(hasCursor ? 1 : 0)} = 0
OR CreatedAtUtc < {afterCreated}
OR (CreatedAtUtc = {afterCreated} AND TrackedOperationId < {afterIdString}))
ORDER BY CreatedAtUtc DESC, TrackedOperationId DESC;";
var rows = await _context.Set<SiteCall>()
.FromSqlInterpolated(sql)
.AsNoTracking()
.ToListAsync(ct);
return rows;
}
/// <inheritdoc />
public async Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default)
{
return await _context.Database.ExecuteSqlInterpolatedAsync(
$"DELETE FROM dbo.SiteCalls WHERE TerminalAtUtc IS NOT NULL AND TerminalAtUtc < {olderThanUtc};",
ct);
}
// Terminal status string literals for the interval-throughput KPIs. The
// Status column is a plain varchar (no value converter), so these compare
// directly in translated SQL.
//
// NOTE on the "buffered/non-terminal" definition: the SiteCalls operational
// mirror stores AuditStatus-derived strings (Attempted/Delivered/Parked/
// Failed/...), NOT the tracking-lifecycle Pending/Retrying names the spec's
// KPI section uses. There is therefore no Status string that means
// "buffered". The schema-honest predicate for "non-terminal / buffered" is
// TerminalAtUtc IS NULL — consistent with PurgeTerminalAsync's terminal
// predicate and with the SiteCall entity's own contract ("TerminalAtUtc ...
// null while still active"). All buffered / stuck / oldest-pending counts
// below key off TerminalAtUtc, not Status.
private const string StatusParked = "Parked";
private const string StatusDelivered = "Delivered";
private const string StatusFailed = "Failed";
/// <inheritdoc />
public async Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
{
var now = DateTime.UtcNow;
var bufferedCount = await _context.SiteCalls
.CountAsync(s => s.TerminalAtUtc == null, ct);
var parkedCount = await _context.SiteCalls
.CountAsync(s => s.Status == StatusParked, ct);
var failedLastInterval = await _context.SiteCalls
.CountAsync(s => s.Status == StatusFailed
&& s.TerminalAtUtc != null
&& s.TerminalAtUtc >= intervalSince, ct);
var deliveredLastInterval = await _context.SiteCalls
.CountAsync(s => s.Status == StatusDelivered
&& s.TerminalAtUtc != null
&& s.TerminalAtUtc >= intervalSince, ct);
var stuckCount = await _context.SiteCalls
.CountAsync(s => s.TerminalAtUtc == null && s.CreatedAtUtc < stuckCutoff, ct);
var nonTerminal = _context.SiteCalls.Where(s => s.TerminalAtUtc == null);
TimeSpan? oldestPendingAge = null;
if (await nonTerminal.AnyAsync(ct))
{
var oldestCreatedAt = await nonTerminal.MinAsync(s => s.CreatedAtUtc, ct);
oldestPendingAge = now - oldestCreatedAt;
}
return new SiteCallKpiSnapshot(
BufferedCount: bufferedCount,
ParkedCount: parkedCount,
FailedLastInterval: failedLastInterval,
DeliveredLastInterval: deliveredLastInterval,
OldestPendingAge: oldestPendingAge,
StuckCount: stuckCount);
}
/// <inheritdoc />
public async Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
{
var now = DateTime.UtcNow;
var buffered = await CountBySiteAsync(s => s.TerminalAtUtc == null, ct);
var parked = await CountBySiteAsync(s => s.Status == StatusParked, ct);
var failed = await CountBySiteAsync(
s => s.Status == StatusFailed
&& s.TerminalAtUtc != null && s.TerminalAtUtc >= intervalSince, ct);
var delivered = await CountBySiteAsync(
s => s.Status == StatusDelivered
&& s.TerminalAtUtc != null && s.TerminalAtUtc >= intervalSince, ct);
var stuck = await CountBySiteAsync(
s => s.TerminalAtUtc == null && s.CreatedAtUtc < stuckCutoff, ct);
// Oldest non-terminal CreatedAtUtc per site — a server-side GROUP BY MIN.
var oldest = (await _context.SiteCalls
.Where(s => s.TerminalAtUtc == null)
.GroupBy(s => s.SourceSite)
.Select(g => new { Site = g.Key, Oldest = g.Min(s => s.CreatedAtUtc) })
.ToListAsync(ct))
.ToDictionary(x => x.Site, x => x.Oldest);
var siteIds = buffered.Keys
.Concat(parked.Keys).Concat(failed.Keys)
.Concat(delivered.Keys).Concat(stuck.Keys)
.Distinct()
.OrderBy(s => s, StringComparer.Ordinal);
return siteIds.Select(site => new SiteCallSiteKpiSnapshot(
SourceSite: site,
BufferedCount: buffered.GetValueOrDefault(site),
ParkedCount: parked.GetValueOrDefault(site),
FailedLastInterval: failed.GetValueOrDefault(site),
DeliveredLastInterval: delivered.GetValueOrDefault(site),
OldestPendingAge: oldest.TryGetValue(site, out var createdAt)
? now - createdAt
: null,
StuckCount: stuck.GetValueOrDefault(site))).ToList();
}
/// <summary>Counts <c>SiteCalls</c> rows matching <paramref name="predicate"/>, grouped by source site.</summary>
private async Task<Dictionary<string, int>> CountBySiteAsync(
System.Linq.Expressions.Expression<Func<SiteCall, bool>> predicate,
CancellationToken ct)
{
return await _context.SiteCalls
.Where(predicate)
.GroupBy(s => s.SourceSite)
.Select(g => new { Site = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Site, x => x.Count, ct);
}
private static int GetRankOrThrow(string status)
{
if (!StatusRank.TryGetValue(status, out var rank))
{
throw new ArgumentException(
$"Unknown SiteCall status '{status}'. Expected one of: {string.Join(", ", StatusRank.Keys)}.",
nameof(status));
}
return rank;
}
}
@@ -0,0 +1,143 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
/// <summary>
/// EF Core implementation of ISiteRepository for site and data connection management.
/// </summary>
public class SiteRepository : ISiteRepository
{
private readonly ScadaBridgeDbContext _dbContext;
/// <summary>
/// Initializes a new instance of the SiteRepository.
/// </summary>
/// <param name="dbContext">The database context.</param>
public SiteRepository(ScadaBridgeDbContext dbContext)
{
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
}
// --- Sites ---
/// <inheritdoc />
public async Task<Site?> GetSiteByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _dbContext.Sites.FindAsync([id], cancellationToken);
}
/// <inheritdoc />
public async Task<Site?> GetSiteByIdentifierAsync(string siteIdentifier, CancellationToken cancellationToken = default)
{
return await _dbContext.Sites
.FirstOrDefaultAsync(s => s.SiteIdentifier == siteIdentifier, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Site>> GetAllSitesAsync(CancellationToken cancellationToken = default)
{
return await _dbContext.Sites.OrderBy(s => s.Name).ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddSiteAsync(Site site, CancellationToken cancellationToken = default)
{
await _dbContext.Sites.AddAsync(site, cancellationToken);
}
/// <inheritdoc />
public Task UpdateSiteAsync(Site site, CancellationToken cancellationToken = default)
{
_dbContext.Sites.Update(site);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteSiteAsync(int id, CancellationToken cancellationToken = default)
{
var entity = _dbContext.Sites.Local.FirstOrDefault(s => s.Id == id);
if (entity != null)
{
_dbContext.Sites.Remove(entity);
}
else
{
var stub = new Site("stub", "stub") { Id = id };
_dbContext.Sites.Attach(stub);
_dbContext.Sites.Remove(stub);
}
return Task.CompletedTask;
}
// --- Data Connections ---
/// <inheritdoc />
public async Task<DataConnection?> GetDataConnectionByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _dbContext.DataConnections.FindAsync([id], cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<DataConnection>> GetAllDataConnectionsAsync(CancellationToken cancellationToken = default)
{
return await _dbContext.DataConnections.OrderBy(c => c.Name).ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
{
return await _dbContext.DataConnections
.Where(c => c.SiteId == siteId)
.OrderBy(c => c.Name)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default)
{
await _dbContext.DataConnections.AddAsync(connection, cancellationToken);
}
/// <inheritdoc />
public Task UpdateDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default)
{
_dbContext.DataConnections.Update(connection);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteDataConnectionAsync(int id, CancellationToken cancellationToken = default)
{
var entity = _dbContext.DataConnections.Local.FirstOrDefault(c => c.Id == id);
if (entity != null)
{
_dbContext.DataConnections.Remove(entity);
}
else
{
var stub = new DataConnection("stub", "stub", 0) { Id = id };
_dbContext.DataConnections.Attach(stub);
_dbContext.DataConnections.Remove(stub);
}
return Task.CompletedTask;
}
// --- Instances (for deletion constraint checks) ---
/// <inheritdoc />
public async Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
{
return await _dbContext.Instances
.Where(i => i.SiteId == siteId)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
return await _dbContext.SaveChangesAsync(cancellationToken);
}
}
@@ -0,0 +1,577 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
public class TemplateEngineRepository : ITemplateEngineRepository
{
private readonly ScadaBridgeDbContext _context;
/// <summary>
/// Initializes a new instance of the TemplateEngineRepository class.
/// </summary>
/// <param name="context">The database context used to access template and instance data.</param>
public TemplateEngineRepository(ScadaBridgeDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
// Template
/// <inheritdoc />
public async Task<Template?> GetTemplateByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.Templates
.Include(t => t.Attributes)
.Include(t => t.Alarms)
.Include(t => t.Scripts)
.Include(t => t.Compositions)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.Id == id, cancellationToken);
}
/// <inheritdoc />
public async Task<Template?> GetTemplateWithChildrenAsync(int id, CancellationToken cancellationToken = default)
{
return await GetTemplateByIdAsync(id, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Template>> GetTemplatesWithChildrenAsync(
IEnumerable<string> names, CancellationToken cancellationToken = default)
{
// Transport-008: bulk lookup replaces the per-name N+1 in
// BundleImporter.PreviewAsync. Filter out null / empty / duplicate
// names before the query so EF emits a clean, deduplicated IN clause.
if (names is null) return Array.Empty<Template>();
var distinct = names
.Where(n => !string.IsNullOrEmpty(n))
.Distinct(StringComparer.Ordinal)
.ToArray();
if (distinct.Length == 0) return Array.Empty<Template>();
return await _context.Templates
.Where(t => distinct.Contains(t.Name))
.Include(t => t.Attributes)
.Include(t => t.Alarms)
.Include(t => t.Scripts)
.Include(t => t.Compositions)
.AsSplitQuery()
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Template>> GetAllTemplatesAsync(CancellationToken cancellationToken = default)
{
return await _context.Templates
.Include(t => t.Attributes)
.Include(t => t.Alarms)
.Include(t => t.Scripts)
.Include(t => t.Compositions)
.AsSplitQuery()
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Template>> GetTemplatesComposingAsync(int composedTemplateId, CancellationToken cancellationToken = default)
{
return await _context.Templates
.Where(t => t.Compositions.Any(c => c.ComposedTemplateId == composedTemplateId))
.Include(t => t.Attributes)
.Include(t => t.Scripts)
.Include(t => t.Compositions)
.AsSplitQuery()
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddTemplateAsync(Template template, CancellationToken cancellationToken = default)
{
await _context.Templates.AddAsync(template, cancellationToken);
}
/// <inheritdoc />
public Task UpdateTemplateAsync(Template template, CancellationToken cancellationToken = default)
{
_context.Templates.Update(template);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteTemplateAsync(int id, CancellationToken cancellationToken = default)
{
var template = await _context.Templates.FindAsync(new object[] { id }, cancellationToken);
if (template != null)
{
_context.Templates.Remove(template);
}
}
// TemplateAttribute
/// <inheritdoc />
public async Task<TemplateAttribute?> GetTemplateAttributeByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.TemplateAttributes.FindAsync(new object[] { id }, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TemplateAttribute>> GetAttributesByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
{
return await _context.TemplateAttributes
.Where(a => a.TemplateId == templateId)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddTemplateAttributeAsync(TemplateAttribute attribute, CancellationToken cancellationToken = default)
{
await _context.TemplateAttributes.AddAsync(attribute, cancellationToken);
}
/// <inheritdoc />
public Task UpdateTemplateAttributeAsync(TemplateAttribute attribute, CancellationToken cancellationToken = default)
{
_context.TemplateAttributes.Update(attribute);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteTemplateAttributeAsync(int id, CancellationToken cancellationToken = default)
{
var attribute = await _context.TemplateAttributes.FindAsync(new object[] { id }, cancellationToken);
if (attribute != null)
{
_context.TemplateAttributes.Remove(attribute);
}
}
// TemplateAlarm
/// <inheritdoc />
public async Task<TemplateAlarm?> GetTemplateAlarmByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.TemplateAlarms.FindAsync(new object[] { id }, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TemplateAlarm>> GetAlarmsByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
{
return await _context.TemplateAlarms
.Where(a => a.TemplateId == templateId)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddTemplateAlarmAsync(TemplateAlarm alarm, CancellationToken cancellationToken = default)
{
await _context.TemplateAlarms.AddAsync(alarm, cancellationToken);
}
/// <inheritdoc />
public Task UpdateTemplateAlarmAsync(TemplateAlarm alarm, CancellationToken cancellationToken = default)
{
_context.TemplateAlarms.Update(alarm);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteTemplateAlarmAsync(int id, CancellationToken cancellationToken = default)
{
var alarm = await _context.TemplateAlarms.FindAsync(new object[] { id }, cancellationToken);
if (alarm != null)
{
_context.TemplateAlarms.Remove(alarm);
}
}
// TemplateScript
/// <inheritdoc />
public async Task<TemplateScript?> GetTemplateScriptByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.TemplateScripts.FindAsync(new object[] { id }, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TemplateScript>> GetScriptsByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
{
return await _context.TemplateScripts
.Where(s => s.TemplateId == templateId)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddTemplateScriptAsync(TemplateScript script, CancellationToken cancellationToken = default)
{
await _context.TemplateScripts.AddAsync(script, cancellationToken);
}
/// <inheritdoc />
public Task UpdateTemplateScriptAsync(TemplateScript script, CancellationToken cancellationToken = default)
{
_context.TemplateScripts.Update(script);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteTemplateScriptAsync(int id, CancellationToken cancellationToken = default)
{
var script = await _context.TemplateScripts.FindAsync(new object[] { id }, cancellationToken);
if (script != null)
{
_context.TemplateScripts.Remove(script);
}
}
// TemplateComposition
/// <inheritdoc />
public async Task<TemplateComposition?> GetTemplateCompositionByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.TemplateCompositions.FindAsync(new object[] { id }, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TemplateComposition>> GetCompositionsByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
{
return await _context.TemplateCompositions
.Where(c => c.TemplateId == templateId)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddTemplateCompositionAsync(TemplateComposition composition, CancellationToken cancellationToken = default)
{
await _context.TemplateCompositions.AddAsync(composition, cancellationToken);
}
/// <inheritdoc />
public Task UpdateTemplateCompositionAsync(TemplateComposition composition, CancellationToken cancellationToken = default)
{
_context.TemplateCompositions.Update(composition);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteTemplateCompositionAsync(int id, CancellationToken cancellationToken = default)
{
var composition = await _context.TemplateCompositions.FindAsync(new object[] { id }, cancellationToken);
if (composition != null)
{
_context.TemplateCompositions.Remove(composition);
}
}
// Instance
/// <inheritdoc />
public async Task<Instance?> GetInstanceByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.Instances
.Include(i => i.AttributeOverrides)
.Include(i => i.AlarmOverrides)
.Include(i => i.ConnectionBindings)
.AsSplitQuery()
.FirstOrDefaultAsync(i => i.Id == id, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Instance>> GetAllInstancesAsync(CancellationToken cancellationToken = default)
{
return await _context.Instances
.Include(i => i.AttributeOverrides)
.Include(i => i.AlarmOverrides)
.Include(i => i.ConnectionBindings)
.AsSplitQuery()
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Instance>> GetInstancesByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
{
return await _context.Instances
.Where(i => i.TemplateId == templateId)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
{
return await _context.Instances
.Where(i => i.SiteId == siteId)
.Include(i => i.AttributeOverrides)
.Include(i => i.AlarmOverrides)
.Include(i => i.ConnectionBindings)
.AsSplitQuery()
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<Instance?> GetInstanceByUniqueNameAsync(string uniqueName, CancellationToken cancellationToken = default)
{
return await _context.Instances
.Include(i => i.AttributeOverrides)
.Include(i => i.AlarmOverrides)
.Include(i => i.ConnectionBindings)
.AsSplitQuery()
.FirstOrDefaultAsync(i => i.UniqueName == uniqueName, cancellationToken);
}
/// <inheritdoc />
public async Task AddInstanceAsync(Instance instance, CancellationToken cancellationToken = default)
{
await _context.Instances.AddAsync(instance, cancellationToken);
}
/// <inheritdoc />
public Task UpdateInstanceAsync(Instance instance, CancellationToken cancellationToken = default)
{
_context.Instances.Update(instance);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteInstanceAsync(int id, CancellationToken cancellationToken = default)
{
var instance = await _context.Instances.FindAsync(new object[] { id }, cancellationToken);
if (instance != null)
{
_context.Instances.Remove(instance);
}
}
// InstanceAttributeOverride
/// <inheritdoc />
public async Task<IReadOnlyList<InstanceAttributeOverride>> GetOverridesByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
{
return await _context.InstanceAttributeOverrides
.Where(o => o.InstanceId == instanceId)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddInstanceAttributeOverrideAsync(InstanceAttributeOverride attributeOverride, CancellationToken cancellationToken = default)
{
await _context.InstanceAttributeOverrides.AddAsync(attributeOverride, cancellationToken);
}
/// <inheritdoc />
public Task UpdateInstanceAttributeOverrideAsync(InstanceAttributeOverride attributeOverride, CancellationToken cancellationToken = default)
{
_context.InstanceAttributeOverrides.Update(attributeOverride);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteInstanceAttributeOverrideAsync(int id, CancellationToken cancellationToken = default)
{
var attributeOverride = await _context.InstanceAttributeOverrides.FindAsync(new object[] { id }, cancellationToken);
if (attributeOverride != null)
{
_context.InstanceAttributeOverrides.Remove(attributeOverride);
}
}
// InstanceAlarmOverride
/// <inheritdoc />
public async Task<IReadOnlyList<InstanceAlarmOverride>> GetAlarmOverridesByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
{
return await _context.InstanceAlarmOverrides
.Where(o => o.InstanceId == instanceId)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<InstanceAlarmOverride?> GetAlarmOverrideAsync(int instanceId, string alarmCanonicalName, CancellationToken cancellationToken = default)
{
return await _context.InstanceAlarmOverrides
.FirstOrDefaultAsync(
o => o.InstanceId == instanceId && o.AlarmCanonicalName == alarmCanonicalName,
cancellationToken);
}
/// <inheritdoc />
public async Task AddInstanceAlarmOverrideAsync(InstanceAlarmOverride alarmOverride, CancellationToken cancellationToken = default)
{
await _context.InstanceAlarmOverrides.AddAsync(alarmOverride, cancellationToken);
}
/// <inheritdoc />
public Task UpdateInstanceAlarmOverrideAsync(InstanceAlarmOverride alarmOverride, CancellationToken cancellationToken = default)
{
_context.InstanceAlarmOverrides.Update(alarmOverride);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteInstanceAlarmOverrideAsync(int id, CancellationToken cancellationToken = default)
{
var alarmOverride = await _context.InstanceAlarmOverrides.FindAsync(new object[] { id }, cancellationToken);
if (alarmOverride != null)
{
_context.InstanceAlarmOverrides.Remove(alarmOverride);
}
}
// InstanceConnectionBinding
/// <inheritdoc />
public async Task<IReadOnlyList<InstanceConnectionBinding>> GetBindingsByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
{
return await _context.InstanceConnectionBindings
.Where(b => b.InstanceId == instanceId)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddInstanceConnectionBindingAsync(InstanceConnectionBinding binding, CancellationToken cancellationToken = default)
{
await _context.InstanceConnectionBindings.AddAsync(binding, cancellationToken);
}
/// <inheritdoc />
public Task UpdateInstanceConnectionBindingAsync(InstanceConnectionBinding binding, CancellationToken cancellationToken = default)
{
_context.InstanceConnectionBindings.Update(binding);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteInstanceConnectionBindingAsync(int id, CancellationToken cancellationToken = default)
{
var binding = await _context.InstanceConnectionBindings.FindAsync(new object[] { id }, cancellationToken);
if (binding != null)
{
_context.InstanceConnectionBindings.Remove(binding);
}
}
// Area
/// <inheritdoc />
public async Task<Area?> GetAreaByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.Areas
.Include(a => a.Children)
.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Area>> GetAreasBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
{
return await _context.Areas
.Where(a => a.SiteId == siteId)
.Include(a => a.Children)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddAreaAsync(Area area, CancellationToken cancellationToken = default)
{
await _context.Areas.AddAsync(area, cancellationToken);
}
/// <inheritdoc />
public Task UpdateAreaAsync(Area area, CancellationToken cancellationToken = default)
{
_context.Areas.Update(area);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteAreaAsync(int id, CancellationToken cancellationToken = default)
{
var area = await _context.Areas.FindAsync(new object[] { id }, cancellationToken);
if (area != null)
{
_context.Areas.Remove(area);
}
}
// SharedScript
/// <inheritdoc />
public async Task<SharedScript?> GetSharedScriptByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.SharedScripts.FindAsync(new object[] { id }, cancellationToken);
}
/// <inheritdoc />
public async Task<SharedScript?> GetSharedScriptByNameAsync(string name, CancellationToken cancellationToken = default)
{
return await _context.SharedScripts
.FirstOrDefaultAsync(s => s.Name == name, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<SharedScript>> GetAllSharedScriptsAsync(CancellationToken cancellationToken = default)
{
return await _context.SharedScripts.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddSharedScriptAsync(SharedScript sharedScript, CancellationToken cancellationToken = default)
{
await _context.SharedScripts.AddAsync(sharedScript, cancellationToken);
}
/// <inheritdoc />
public Task UpdateSharedScriptAsync(SharedScript sharedScript, CancellationToken cancellationToken = default)
{
_context.SharedScripts.Update(sharedScript);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteSharedScriptAsync(int id, CancellationToken cancellationToken = default)
{
var sharedScript = await _context.SharedScripts.FindAsync(new object[] { id }, cancellationToken);
if (sharedScript != null)
{
_context.SharedScripts.Remove(sharedScript);
}
}
// TemplateFolder
/// <inheritdoc />
public async Task<TemplateFolder?> GetFolderByIdAsync(int id, CancellationToken cancellationToken = default)
=> await _context.TemplateFolders.FindAsync(new object[] { id }, cancellationToken);
/// <inheritdoc />
public async Task<IReadOnlyList<TemplateFolder>> GetAllFoldersAsync(CancellationToken cancellationToken = default)
=> await _context.TemplateFolders.ToListAsync(cancellationToken);
/// <inheritdoc />
public async Task AddFolderAsync(TemplateFolder folder, CancellationToken cancellationToken = default)
=> await _context.TemplateFolders.AddAsync(folder, cancellationToken);
/// <inheritdoc />
public Task UpdateFolderAsync(TemplateFolder folder, CancellationToken cancellationToken = default)
{
_context.TemplateFolders.Update(folder);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteFolderAsync(int id, CancellationToken cancellationToken = default)
{
var folder = await _context.TemplateFolders.FindAsync(new object[] { id }, cancellationToken);
if (folder != null)
{
_context.TemplateFolders.Remove(folder);
}
}
/// <inheritdoc />
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
return await _context.SaveChangesAsync(cancellationToken);
}
}