feat(instance): native-alarm-source-override CSV bulk import (deferred #12) + doc fixes
Closes the operator parity gap the deferred-work register tracked as #12: native alarm source overrides could only be set one-at-a-time, while attribute overrides had a CSV bulk path. Adds an all-or-nothing CSV import for native sources. - Commons: extract the RFC-4180 line splitter into a shared CsvLineSplitter (refactor OverrideCsvParser onto it — no behavior change, pinned by its tests); new NativeAlarmSourceOverrideCsvParser (header SourceName,Connection, SourceReference,Filter; blank = inherited). - Commons: NativeAlarmSourceOverrideEntry + bulk SetInstanceNativeAlarmSource- OverridesCommand (auto-registered via the reflection command registry). - ManagementService: Deployer-gated handler — flattens once, validates every source resolves + is unlocked + no duplicates up front, then upserts the whole batch under a single SaveChanges (true all-or-nothing txn). Added to the frozen authorization matrix; dispatch-coverage guard passes. - CLI: `instance native-alarm-source import --instance-id --file` (parity with `instance import-overrides`) + README. - Tests: native parser (Commons), CLI parse/entry mapping, 3 bulk-handler tests (happy path single-commit, locked-source reject, unresolved-source reject). Also corrects a stale XML-doc line in ScriptRuntimeContext (WaitForAttribute quality-gated mode is shipped, not "planned") and updates the deferred-work register: marks #7/#13/#15/#16/#20 verified-resolved, #12 as CLI/API-shipped with only the Central UI upload affordance still pending. Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -62,3 +62,26 @@ public record DeleteInstanceNativeAlarmSourceOverrideCommand(
|
||||
string SourceCanonicalName);
|
||||
|
||||
public record ListInstanceNativeAlarmSourceOverridesCommand(int InstanceId);
|
||||
|
||||
/// <summary>
|
||||
/// One entry in a bulk native-alarm-source-override apply: the source binding's
|
||||
/// canonical name plus the three optional retarget fields (null keeps the inherited
|
||||
/// value). Mirrors <see cref="SetInstanceNativeAlarmSourceOverrideCommand"/>'s fields
|
||||
/// for a single source.
|
||||
/// </summary>
|
||||
public record NativeAlarmSourceOverrideEntry(
|
||||
string SourceCanonicalName,
|
||||
string? ConnectionNameOverride,
|
||||
string? SourceReferenceOverride,
|
||||
string? ConditionFilterOverride);
|
||||
|
||||
/// <summary>
|
||||
/// Bulk, all-or-nothing apply of per-instance native-alarm-source overrides — the
|
||||
/// native-source parity analogue of <see cref="SetInstanceOverridesCommand"/>. Backs
|
||||
/// the CLI <c>instance native-alarm-source import --file</c> CSV path. Every entry's
|
||||
/// source must resolve for the instance and be unlocked; if any entry is invalid the
|
||||
/// whole batch is rejected before any write.
|
||||
/// </summary>
|
||||
public record SetInstanceNativeAlarmSourceOverridesCommand(
|
||||
int InstanceId,
|
||||
IReadOnlyList<NativeAlarmSourceOverrideEntry> Overrides);
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Shared, dependency-free RFC-4180-ish splitter for a single physical CSV line.
|
||||
/// Extracted so the instance-override CSV parsers (<see cref="OverrideCsvParser"/>
|
||||
/// and <see cref="NativeAlarmSourceOverrideCsvParser"/>) apply identical quoting
|
||||
/// rules without duplicating the subtle field-state machine.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Quoting rules:
|
||||
/// <list type="bullet">
|
||||
/// <item>A field is <i>quoted</i> only if its first non-whitespace char is a
|
||||
/// <c>"</c> (leading whitespace before the opening quote is allowed and ignored).
|
||||
/// Inside a quoted field, commas are literal and <c>""</c> is an escaped single
|
||||
/// quote; the closing <c>"</c> must be the last non-whitespace char of the field
|
||||
/// (trailing whitespace after the close is allowed and ignored).</item>
|
||||
/// <item>A <c>"</c> appearing anywhere else in an unquoted field (i.e. after
|
||||
/// non-whitespace content) is a <b>literal</b> character and is preserved.</item>
|
||||
/// <item>Unquoted fields are whitespace-trimmed; quoted field values are kept
|
||||
/// verbatim.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public static class CsvLineSplitter
|
||||
{
|
||||
/// <summary>
|
||||
/// Splits a single physical CSV line into its fields, applying the quoting rules
|
||||
/// described on <see cref="CsvLineSplitter"/>. Returns <c>true</c> with the split
|
||||
/// <paramref name="fields"/> on success; returns <c>false</c> when a quoted field
|
||||
/// is opened but never closed before end-of-line (the caller emits a per-line
|
||||
/// "unterminated" error).
|
||||
/// </summary>
|
||||
/// <param name="line">The physical line to split (no trailing newline).</param>
|
||||
/// <param name="fields">The split fields on success; an empty list on failure.</param>
|
||||
/// <returns><c>true</c> when the line split cleanly; <c>false</c> on an unterminated quoted field.</returns>
|
||||
public static bool TrySplit(string line, out List<string> fields)
|
||||
{
|
||||
fields = new List<string>();
|
||||
var field = new System.Text.StringBuilder();
|
||||
var inQuotes = false; // currently between an opening and closing quote
|
||||
var quoted = false; // this field opened with a quote → keep value verbatim
|
||||
var sawContent = false; // any non-whitespace char seen in the current field yet
|
||||
|
||||
for (var i = 0; i < line.Length; i++)
|
||||
{
|
||||
var c = line[i];
|
||||
|
||||
if (inQuotes)
|
||||
{
|
||||
if (c == '"')
|
||||
{
|
||||
// Doubled quote inside a quoted field → a single literal quote.
|
||||
if (i + 1 < line.Length && line[i + 1] == '"')
|
||||
{
|
||||
field.Append('"');
|
||||
i++;
|
||||
}
|
||||
else
|
||||
{
|
||||
inQuotes = false; // closing quote; only trailing whitespace may follow
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
field.Append(c);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (c)
|
||||
{
|
||||
case ',':
|
||||
fields.Add(Finalize(field, quoted));
|
||||
field.Clear();
|
||||
inQuotes = false;
|
||||
quoted = false;
|
||||
sawContent = false;
|
||||
break;
|
||||
case '"' when !sawContent:
|
||||
// Opening quote: first non-whitespace char of the field. Any
|
||||
// leading whitespace seen so far is part of the (ignored) prefix.
|
||||
field.Clear();
|
||||
inQuotes = true;
|
||||
quoted = true;
|
||||
sawContent = true;
|
||||
break;
|
||||
default:
|
||||
// After a quoted field has closed, only whitespace may appear
|
||||
// before the next delimiter — it is ignored, not appended.
|
||||
if (quoted)
|
||||
break;
|
||||
|
||||
// A '"' here (sawContent already true) falls through as a literal.
|
||||
if (!char.IsWhiteSpace(c))
|
||||
sawContent = true;
|
||||
field.Append(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (inQuotes)
|
||||
return false; // opened a quoted field that was never closed
|
||||
|
||||
fields.Add(Finalize(field, quoted));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string Finalize(System.Text.StringBuilder field, bool quoted)
|
||||
{
|
||||
var text = field.ToString();
|
||||
return quoted ? text : text.Trim(); // only unquoted whitespace is trimmed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
/// <summary>
|
||||
/// One parsed per-instance native-alarm-source-override CSV row: the source
|
||||
/// binding's canonical name plus the three optional retarget fields. A blank
|
||||
/// (null) <see cref="Connection"/>/<see cref="SourceReference"/>/<see cref="Filter"/>
|
||||
/// means "keep the inherited value" — the same blank-is-inherited semantics as the
|
||||
/// CLI <c>instance native-alarm-source set</c> command. <see cref="LineNumber"/> is
|
||||
/// the 1-based source line (the header is line 1) so downstream errors can point
|
||||
/// back at the operator's file.
|
||||
/// </summary>
|
||||
public sealed record NativeAlarmSourceOverrideCsvRow(
|
||||
string SourceName, string? Connection, string? SourceReference, string? Filter, int LineNumber);
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of parsing a native-alarm-source-override CSV: the successfully-parsed
|
||||
/// <see cref="Rows"/> plus per-line <see cref="Errors"/>. Parsing never throws —
|
||||
/// malformed rows are reported and excluded, valid rows still flow through.
|
||||
/// Downstream callers validate source names against the flattened instance; this
|
||||
/// parser is purely syntactic.
|
||||
/// </summary>
|
||||
public sealed record NativeAlarmSourceOverrideCsvParseResult(
|
||||
IReadOnlyList<NativeAlarmSourceOverrideCsvRow> Rows, IReadOnlyList<string> Errors);
|
||||
|
||||
/// <summary>
|
||||
/// Pure, dependency-free, quote-aware parser turning per-instance
|
||||
/// native-alarm-source-override CSV text into structured rows plus per-line errors.
|
||||
/// Callers supply the text (no file I/O). The header row is required and
|
||||
/// case-insensitive (<c>SourceName,Connection,SourceReference,Filter</c>). Fields
|
||||
/// follow the shared <see cref="CsvLineSplitter"/> RFC-4180 quoting rules — the same
|
||||
/// splitter <see cref="OverrideCsvParser"/> uses. The parity analogue of
|
||||
/// <see cref="OverrideCsvParser"/> for native alarm source retargets.
|
||||
/// </summary>
|
||||
public static class NativeAlarmSourceOverrideCsvParser
|
||||
{
|
||||
private const string HeaderError =
|
||||
"Missing or invalid header row. Expected 'SourceName,Connection,SourceReference,Filter'.";
|
||||
|
||||
private const int ExpectedColumns = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Parses native-alarm-source-override CSV <paramref name="csvText"/>. Returns
|
||||
/// parsed rows and any per-line errors; never throws. On a missing/unrecognized
|
||||
/// header returns zero rows and a single header error.
|
||||
/// </summary>
|
||||
/// <param name="csvText">The raw CSV text to parse.</param>
|
||||
/// <returns>The parsed rows and any per-line errors.</returns>
|
||||
public static NativeAlarmSourceOverrideCsvParseResult Parse(string csvText)
|
||||
{
|
||||
var rows = new List<NativeAlarmSourceOverrideCsvRow>();
|
||||
var errors = new List<string>();
|
||||
|
||||
// Split into physical lines; \r\n and \r are normalized to \n boundaries.
|
||||
var lines = (csvText ?? string.Empty).Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
|
||||
|
||||
var headerSeen = false;
|
||||
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var lineNumber = i + 1;
|
||||
var rawLine = lines[i];
|
||||
|
||||
// Skip fully-blank lines (whitespace-only included) without error.
|
||||
if (string.IsNullOrWhiteSpace(rawLine))
|
||||
continue;
|
||||
|
||||
if (!CsvLineSplitter.TrySplit(rawLine, out var fields))
|
||||
{
|
||||
errors.Add($"Line {lineNumber}: Unterminated quoted field.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!headerSeen)
|
||||
{
|
||||
if (!TryMatchHeader(fields))
|
||||
{
|
||||
errors.Add(HeaderError);
|
||||
return new NativeAlarmSourceOverrideCsvParseResult(rows, errors);
|
||||
}
|
||||
|
||||
headerSeen = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fields.Count != ExpectedColumns)
|
||||
{
|
||||
errors.Add(
|
||||
$"Line {lineNumber}: expected {ExpectedColumns} columns but found {fields.Count}.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var sourceName = fields[0];
|
||||
if (string.IsNullOrWhiteSpace(sourceName))
|
||||
{
|
||||
errors.Add($"Line {lineNumber}: SourceName must not be blank.");
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.Add(new NativeAlarmSourceOverrideCsvRow(
|
||||
sourceName,
|
||||
NullIfEmpty(fields[1]),
|
||||
NullIfEmpty(fields[2]),
|
||||
NullIfEmpty(fields[3]),
|
||||
lineNumber));
|
||||
}
|
||||
|
||||
if (!headerSeen)
|
||||
errors.Add(HeaderError);
|
||||
|
||||
return new NativeAlarmSourceOverrideCsvParseResult(rows, errors);
|
||||
}
|
||||
|
||||
/// <summary>Matches the required 4-column header (case-insensitive).</summary>
|
||||
private static bool TryMatchHeader(IReadOnlyList<string> fields) =>
|
||||
fields.Count == ExpectedColumns &&
|
||||
HeaderEquals(fields[0], "SourceName") &&
|
||||
HeaderEquals(fields[1], "Connection") &&
|
||||
HeaderEquals(fields[2], "SourceReference") &&
|
||||
HeaderEquals(fields[3], "Filter");
|
||||
|
||||
private static bool HeaderEquals(string field, string expected) =>
|
||||
string.Equals(field, expected, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string? NullIfEmpty(string field) => field.Length == 0 ? null : field;
|
||||
}
|
||||
@@ -60,7 +60,7 @@ public static class OverrideCsvParser
|
||||
if (string.IsNullOrWhiteSpace(rawLine))
|
||||
continue;
|
||||
|
||||
if (!SplitFields(rawLine, out var fields))
|
||||
if (!CsvLineSplitter.TrySplit(rawLine, out var fields))
|
||||
{
|
||||
errors.Add($"Line {lineNumber}: Unterminated quoted field.");
|
||||
continue;
|
||||
@@ -134,101 +134,4 @@ public static class OverrideCsvParser
|
||||
string.Equals(field, expected, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string? NullIfEmpty(string field) => field.Length == 0 ? null : field;
|
||||
|
||||
/// <summary>
|
||||
/// RFC-4180-ish field splitter for a single physical line. Quoting rules:
|
||||
/// <list type="bullet">
|
||||
/// <item>A field is <i>quoted</i> only if its first non-whitespace char is a
|
||||
/// <c>"</c> (leading whitespace before the opening quote is allowed and
|
||||
/// ignored). Inside a quoted field, commas are literal and <c>""</c> is an
|
||||
/// escaped single quote; the closing <c>"</c> must be the last non-whitespace
|
||||
/// char of the field (trailing whitespace after the close is allowed and
|
||||
/// ignored).</item>
|
||||
/// <item>A <c>"</c> appearing anywhere else in an unquoted field (i.e. after
|
||||
/// non-whitespace content) is a <b>literal</b> character and is preserved.</item>
|
||||
/// <item>Unquoted fields are whitespace-trimmed; quoted field values are kept
|
||||
/// verbatim.</item>
|
||||
/// </list>
|
||||
/// Returns <c>true</c> with the split <paramref name="fields"/> on success;
|
||||
/// returns <c>false</c> when a quoted field is opened but never closed before
|
||||
/// end-of-line (the caller emits a per-line "unterminated" error).
|
||||
/// </summary>
|
||||
private static bool SplitFields(string line, out List<string> fields)
|
||||
{
|
||||
fields = new List<string>();
|
||||
var field = new System.Text.StringBuilder();
|
||||
var inQuotes = false; // currently between an opening and closing quote
|
||||
var quoted = false; // this field opened with a quote → keep value verbatim
|
||||
var sawContent = false; // any non-whitespace char seen in the current field yet
|
||||
|
||||
for (var i = 0; i < line.Length; i++)
|
||||
{
|
||||
var c = line[i];
|
||||
|
||||
if (inQuotes)
|
||||
{
|
||||
if (c == '"')
|
||||
{
|
||||
// Doubled quote inside a quoted field → a single literal quote.
|
||||
if (i + 1 < line.Length && line[i + 1] == '"')
|
||||
{
|
||||
field.Append('"');
|
||||
i++;
|
||||
}
|
||||
else
|
||||
{
|
||||
inQuotes = false; // closing quote; only trailing whitespace may follow
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
field.Append(c);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (c)
|
||||
{
|
||||
case ',':
|
||||
fields.Add(Finalize(field, quoted));
|
||||
field.Clear();
|
||||
inQuotes = false;
|
||||
quoted = false;
|
||||
sawContent = false;
|
||||
break;
|
||||
case '"' when !sawContent:
|
||||
// Opening quote: first non-whitespace char of the field. Any
|
||||
// leading whitespace seen so far is part of the (ignored) prefix.
|
||||
field.Clear();
|
||||
inQuotes = true;
|
||||
quoted = true;
|
||||
sawContent = true;
|
||||
break;
|
||||
default:
|
||||
// After a quoted field has closed, only whitespace may appear
|
||||
// before the next delimiter — it is ignored, not appended.
|
||||
if (quoted)
|
||||
break;
|
||||
|
||||
// A '"' here (sawContent already true) falls through as a literal.
|
||||
if (!char.IsWhiteSpace(c))
|
||||
sawContent = true;
|
||||
field.Append(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (inQuotes)
|
||||
return false; // opened a quoted field that was never closed
|
||||
|
||||
fields.Add(Finalize(field, quoted));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string Finalize(System.Text.StringBuilder field, bool quoted)
|
||||
{
|
||||
var text = field.ToString();
|
||||
return quoted ? text : text.Trim(); // only unquoted whitespace is trimmed
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user