From b238228d8b6106e54b61fe9b0406ed174dc9ad9b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 15:10:32 -0400 Subject: [PATCH 01/25] docs: design for structured multi-value (List) attributes Add a first-class DataType.List + ElementDataType companion so object attributes can store homogeneous scalar lists (e.g. MoveInWorkOrderNumbers, MoveInPartNumbers) across all four lifecycle paths: script write/read, static authored default, OPC UA array read, OPC UA array write. Canonical JSON value codec; whole-list override; element type fixed by base; idempotent migration widening Value to nvarchar(max) + adding ElementDataType. Approved via brainstorming. --- .../2026-06-16-multivalue-attribute-design.md | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 docs/plans/2026-06-16-multivalue-attribute-design.md diff --git a/docs/plans/2026-06-16-multivalue-attribute-design.md b/docs/plans/2026-06-16-multivalue-attribute-design.md new file mode 100644 index 00000000..a12e8712 --- /dev/null +++ b/docs/plans/2026-06-16-multivalue-attribute-design.md @@ -0,0 +1,216 @@ +# Structured Multi-Value Attribute — Design + +**Date:** 2026-06-16 +**Status:** Approved (brainstorming) — ready for implementation plan +**Branch:** `feature/multivalue-attribute` + +## Problem + +ScadaBridge's `DataType` enum (`Boolean·Int32·Float·Double·String·DateTime·Binary`) has no +collection type. An attribute is persisted as a single `string? Value` plus a `DataType` +discriminator. So a Galaxy/object attribute that is conceptually a list of values — e.g. +`MoveInWorkOrderNumbers`, `MoveInPartNumbers` (string arrays arriving via the +`IpsenMESMoveIn` inbound-API method) — can only be stored by collapsing it to a single +`DataType.String` blob. We want these stored as a **first-class structured multi-value +attribute** that round-trips through every path. + +## Scope (decided) + +- **Element types:** a *homogeneous* list of any existing **scalar** `DataType` — + `String, Int32, Float, Double, Boolean, DateTime`. **Excluded:** `Binary` elements and + nested lists (no lists-of-lists, no lists-of-objects). +- **Lifecycle paths — all four in scope:** + 1. Script writes the attribute at runtime and reads it back (the `IpsenMESMoveIn` case); + persists as a static attribute (site SQLite, survives failover). + 2. Statically authored default in a template, optionally overridden per instance, via + Central UI and CLI. + 3. Read in from an OPC UA array node (DCL subscription → live attribute value). + 4. Written out to an OPC UA array node via the DCL write path. + +## Decision summary (defaults locked in) + +| Decision | Choice | +|---|---| +| Type modeling | One `DataType.List` member + nullable `ElementDataType` companion | +| Value encoding | JSON array, invariant culture (round-trippable) | +| Display encoding | `ValueFormatter.FormatDisplayValue` stays comma-joined, display-only | +| Element types | 6 scalars; no `Binary`, no nesting | +| Override granularity | Whole-list replacement (no per-element override) | +| Element type mutability | Fixed by base attribute; not overridable on derived/instance | +| Bad OPC UA element | Coerce/validate; mismatch → Bad quality + log (non-fatal) | +| Empty vs unset | `null` = unset/cleared; `"[]"` = explicit empty list | +| gRPC wire | Existing `string value` field carries the canonical JSON (additive) | + +## Architecture + +The key insight from the blast-radius survey: most of the runtime is already +collection-capable. `ScriptParameters` already converts to typed `List`/`T[]`; the OPC UA +write path wraps values in a `Variant` (which supports arrays natively); +`AttributeValueChanged` already carries `object?`. The real work concentrates in **(a)** the +type model, **(b)** a canonical encode/decode codec, **(c)** the InstanceActor / script-accessor +boundary, **(d)** validation, and **(e)** the UI/CLI editors. + +### 1. Type model + +- Add **one** enum member: `DataType.List` + (`Commons/Types/Enums/DataType.cs`). +- Add nullable `ElementDataType` (`DataType?`) to: + - `Commons/Entities/Templates/TemplateAttribute.cs` + - `Commons/Entities/Instances/InstanceAttributeOverride.cs` + - `Commons/Types/Flattening/FlattenedConfiguration.cs` → `ResolvedAttribute` +- **Invariant:** `ElementDataType` required when `DataType == List`, null otherwise; element + type must be one of the 6 allowed scalars. + +### 2. Canonical value codec — new `AttributeValueCodec` + +One round-trippable codec used everywhere a value is **stored or transmitted**: + +- `Encode(object? value)`: + - scalar → invariant-culture string (**identical to today's behavior**; existing scalars + unchanged) + - non-string `IEnumerable` → JSON array, invariant culture + (`["WO-1","WO-2"]`, `[1,2,3]`, `["2026-06-16T00:00:00Z"]`) +- `Decode(string? value, DataType dataType, DataType? elementType)`: + - `List` → typed `List` (reuse the existing `ScriptParameters` element-conversion + machinery where practical) + - scalar → string (unchanged) +- `null` value = unset/cleared; `"[]"` = explicit empty list. + +`ValueFormatter.FormatDisplayValue` (comma-joined) remains **display-only** — diff text, +log lines, KPI strings — and is *not* the persisted/wire form. + +### 3. Persistence & migration + +- One EF Core migration, **written idempotent** (per the open follow-up #70 lesson — + guard inserts/alters so re-running against a partially-migrated DB is safe): + - add `ElementDataType nvarchar(50) NULL` to `TemplateAttributes` and + `InstanceAttributeOverrides` + - widen `Value` / `OverrideValue` from `nvarchar(4000)` → `nvarchar(max)` (lists can + exceed 4000) +- Site **SQLite** static-attribute store: no schema change (already a string column) — it + simply stores the canonical JSON. +- EF config: `TemplateConfiguration.cs`, `InstanceConfiguration.cs` map the new column + (`HasConversion().HasMaxLength(50)` for `ElementDataType`). + +### 4. Flatten / resolve + +- `TemplateEngine/Flattening/FlatteningService.cs` carries `ElementDataType` into + `ResolvedAttribute` next to `DataType`. +- Inheritance / compose / override shape unchanged. +- **Override replaces the whole list** (no per-element merge). +- A derived template or instance override **cannot change `ElementDataType`** — it is fixed + by the base attribute, enforced like the existing `LockedInDerived` path. + +### 5. Site runtime (the core change) + +- **`SiteRuntime/Scripts/ScopeAccessors.cs:56,73`** — `AttributeAccessor` set / `SetAsync`: + replace `value?.ToString()` with `AttributeValueCodec.Encode(value)`. (Today a + `List` would `.ToString()` to `"System.Collections.Generic.List`1[...]"` — the + central bug this fixes.) Scalars produce the same string as before. +- **`SiteRuntime/Actors/InstanceActor.cs:116-121`** — on load, for `List` attributes + `Decode` the JSON `Value` into a typed `List` and store *that* in `_attributes` + (scalars unchanged). +- **`HandleSetStaticAttribute`** — for `List` attributes: decode the canonical string → + typed list for `_attributes`, validate element types, persist the canonical JSON to + SQLite, stream the canonical JSON. +- **`HandleGetAttribute`** — unchanged; returns the live `_attributes` object, now a real + `List` for list attributes. Scripts read a first-class list. + +### 6. Data Connection Layer (OPC UA read + write) + +- **Read:** an OPC UA array node yields a CLR array → attribute value object. Coerce / + validate element type against `ElementDataType`; on mismatch → **Bad quality** + log, + never crash the actor. +- **Write:** `DataConnectionLayer/Adapters/RealOpcUaClient.cs:619-628` already wraps values + in `new Variant(value)`, which supports arrays natively; a `List`/`T[]` writes as an + OPC UA array. Minimal change beyond passing the typed list through. + +### 7. Validation (pre-deploy semantic) + +`TemplateEngine/Validation/SemanticValidator.cs` + `ValidationService.cs`: + +- `ElementDataType` present and a valid scalar when `DataType == List`; absent otherwise. +- Authored default `Value` (if present) must parse as a JSON array whose elements match + `ElementDataType`. +- List attributes **rejected as operands** in numeric alarm triggers (HiLo / + RangeViolation) and binary triggers — extends the existing `NumericDataTypes` guard. + +### 8. Streaming / DebugView + +- `Communication/Actors/StreamRelayActor.cs:48` — encode via `AttributeValueCodec` (JSON for + lists) instead of `FormatDisplayValue`. Additive: lists are a new type, so no existing wire + consumer breaks; the proto `string value` field (`sitestream.proto:57`) is unchanged. +- Central DebugView renders the canonical string; for lists it shows the JSON array. + *Optional polish:* chip rendering of list elements. + +### 9. Central UI + +- **`CentralUI/Components/Pages/Design/TemplateEdit.razor`** — attribute form: when + type = `List`, reveal an `ElementDataType` dropdown and a **list editor** (repeatable + add/remove rows) bound to the JSON value; inline per-element validation. +- **`CentralUI/Components/Pages/Deployment/InstanceConfigure.razor`** — override panel: the + same list editor for overriding a list attribute. + +### 10. CLI + +- **`CLI/Commands/TemplateCommands.cs`** (and the instance-override command): add + `--element-type `; `--value` accepts a JSON array (`'["a","b"]'`). Optional + ergonomic alternative: repeatable `--value`. Validate element type before submit. +- `ManagementService/ManagementActor.cs` add/update attribute handlers: parse + validate + `ElementDataType`, validate `Value` against it before persisting. + +### 11. Transport (import/export) + +- `Transport/Serialization/EntityDtos.cs` `TemplateAttributeDto` already carries the + `DataType` enum + `string? Value`; add `ElementDataType`. Enum/value round-trip is + otherwise automatic. `BundleImporter` carries the new field through. + +## Error handling + +- Malformed JSON in a persisted `Value`: caught at deploy-time validation; if it ever + reaches runtime decode, log + treat the attribute as Bad quality — never crash the + Instance Actor (audit/best-effort principle). +- OPC UA element-type mismatch on read: Bad quality + log (non-fatal). +- Script writes a non-list to a list attribute (or vice versa): rejected at + `HandleSetStaticAttribute` validation, surfaced to the script as an error. + +## Testing strategy + +- **Codec round-trip:** every element type; empty list; embedded commas/quotes/escaping; + `DateTime`; culture-invariance. +- **Flatten:** override replaces whole list; element-type lock enforced. +- **Validation:** good/bad authored defaults; trigger-operand rejection; missing/extra + `ElementDataType`. +- **InstanceActor:** set/get typed list; SQLite persistence survives simulated failover. +- **DCL:** OPC UA array read coercion; Bad quality on element mismatch; `Variant` array + write. +- **Streaming:** `StreamRelayActor` emits canonical JSON for a list attribute. +- **CLI:** `--element-type` + JSON `--value` parse/validate. +- **UI:** list-editor component behavior (if harness present). + +## Out of scope (deferred) + +- Nested objects / lists-of-records; heterogeneous lists. +- `Binary` element lists. +- Per-element overrides; element-level alarm triggers. + +## Blast-radius reference (file:area) + +| Area | Key locations | Change size | +|---|---|---| +| Enum + entities | `DataType.cs`, `TemplateAttribute.cs`, `InstanceAttributeOverride.cs`, `FlattenedConfiguration.cs` | small | +| Codec (new) | `Commons/Types/AttributeValueCodec.cs` (new) | small–med | +| EF + migration | `TemplateConfiguration.cs`, `InstanceConfiguration.cs`, new migration | small | +| Flatten | `FlatteningService.cs:177` | small | +| Runtime boundary | `ScopeAccessors.cs:56,73`, `InstanceActor.cs:116-121,246-315` | **med (core)** | +| DCL | `RealOpcUaClient.cs` read coercion (write already array-capable) | small–med | +| Validation | `SemanticValidator.cs:18-21,130-193`, `ValidationService.cs` | small–med | +| Streaming | `StreamRelayActor.cs:48` | small | +| UI | `TemplateEdit.razor`, `InstanceConfigure.razor` | **med (largest)** | +| CLI + mgmt | `TemplateCommands.cs`, `ManagementActor.cs:1441-1461` | small–med | +| Transport | `EntityDtos.cs:77-83`, `BundleImporter.cs:2302` | small | + +**Note:** the Inbound API type system (`ParameterDefinition` with `Object`/`List`, +`InboundApiSchema`) is genuinely separate from attribute `DataType` and is **not** modified; +its `ScriptParameters.ConvertToList/ConvertToArray` conversion helpers are reusable by the +codec. From 09d7319958c9d8d70219392c512648b2ca1f8c98 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 15:16:06 -0400 Subject: [PATCH 02/25] docs: implementation plan for structured multi-value (List) attributes 15 tasks (MV-1..MV-15) with classifications, dependencies, and TDD steps: type model, AttributeValueCodec, idempotent migration, flatten, validation, runtime encode/decode, DCL array coercion, stream encode, management, CLI, transport, two UI editors, and integration verification. --- docs/plans/2026-06-16-multivalue-attribute.md | 626 ++++++++++++++++++ ...6-06-16-multivalue-attribute.md.tasks.json | 23 + 2 files changed, 649 insertions(+) create mode 100644 docs/plans/2026-06-16-multivalue-attribute.md create mode 100644 docs/plans/2026-06-16-multivalue-attribute.md.tasks.json diff --git a/docs/plans/2026-06-16-multivalue-attribute.md b/docs/plans/2026-06-16-multivalue-attribute.md new file mode 100644 index 00000000..8605725c --- /dev/null +++ b/docs/plans/2026-06-16-multivalue-attribute.md @@ -0,0 +1,626 @@ +# Structured Multi-Value (List) Attribute — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task. + +**Goal:** Add a first-class `DataType.List` attribute type (a homogeneous list of any scalar element type) that round-trips through authoring, flatten, site runtime, OPC UA read/write, streaming, UI, and CLI. + +**Architecture:** One new `DataType.List` enum member + a nullable `ElementDataType` companion on the attribute entities and `ResolvedAttribute`. A single round-trippable `AttributeValueCodec` (JSON array, invariant culture) encodes/decodes list values everywhere they are stored or transmitted; scalars keep their current string behavior unchanged. The script-accessor `.ToString()` boundary and the InstanceActor in-memory store are the core runtime changes. + +**Tech Stack:** C#/.NET 10, Akka.NET 1.5, EF Core 10 (MS SQL + SQLite), gRPC, Blazor Server, System.CommandLine CLI. + +**Design doc:** `docs/plans/2026-06-16-multivalue-attribute-design.md` (approved). +**Branch:** `feature/multivalue-attribute` (off main; design committed `b238228`). + +**Conventions for every task:** +- TDD: write the failing test, see it fail, implement, see it pass, commit. +- Targeted builds/tests only — build the affected project(s) and run the filtered test(s); a full-solution build runs once in the final task. +- Build a project: `dotnet build src//.csproj` +- Run filtered tests: `dotnet test tests//.csproj --filter ` +- Allowed element types (the 6 scalars): `String, Int32, Float, Double, Boolean, DateTime`. **Not** `Binary`, **not** nested `List`. + +--- + +### Task MV-1: Type model — enum member + ElementDataType companion + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** none (foundation; everything else depends on it) + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/DataType.cs` +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Templates/TemplateAttribute.cs` +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Instances/InstanceAttributeOverride.cs` +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs` (the `ResolvedAttribute` record, ~line 57) + +**Step 1 — Add the enum member.** Append `List` as the last member of `DataType` (append-only; do not reorder — the enum is persisted by name via `HasConversion`, but appended-last is the safe convention): + +```csharp +public enum DataType +{ + Boolean, + Int32, + Float, + Double, + String, + DateTime, + Binary, + List +} +``` + +**Step 2 — Add `ElementDataType` to the two entities and the resolved record.** + +`TemplateAttribute.cs` (after the `DataType DataType` property, ~line 26): +```csharp +/// +/// For attributes: the scalar type of each +/// element (String, Int32, Float, Double, Boolean, DateTime). Null for scalar +/// attributes. The element type is fixed by the base attribute and cannot be +/// changed on a derived template or instance override. +/// +public DataType? ElementDataType { get; set; } +``` + +`InstanceAttributeOverride.cs` (after `OverrideValue`, ~line 12): add the same `public DataType? ElementDataType { get; set; }` property (add `using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;`). + +`FlattenedConfiguration.cs` — `ResolvedAttribute` record (after `DataType` at ~line 68). NOTE: `ResolvedAttribute.DataType` is a **string**; keep `ElementDataType` a nullable string here for symmetry with that record's existing style: +```csharp +/// For List attributes: the element scalar type name; null otherwise. +public string? ElementDataType { get; init; } +``` + +**Step 3 — Build the project.** +Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj` +Expected: 0 errors (purely additive). + +**Step 4 — Commit.** +```bash +git add src/ZB.MOM.WW.ScadaBridge.Commons +git commit -m "feat(commons): add DataType.List + ElementDataType companion for multi-value attributes" +``` + +**Acceptance:** Commons compiles; `DataType.List` exists; both entities and `ResolvedAttribute` carry the nullable element-type field. + +--- + +### Task MV-2: AttributeValueCodec (round-trippable JSON encode/decode) + tests + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** MV-3, MV-4 +**Blocked by:** MV-1 + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs` +- Create: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs` + +This codec is the single canonical encoder used for: persisted attribute `Value`, the gRPC wire value, and decode-on-load. `ValueFormatter` stays display-only and is untouched. + +**Step 1 — Write failing tests.** Cover: scalar passthrough (string returned as-is; int/double/bool/DateTime → invariant string), list encode (`List` → `["a","b"]`), embedded comma/quote escaping, empty list → `"[]"`, null → null, DateTime list round-trips ISO-8601, culture-invariance (set `CultureInfo.CurrentCulture` to `de-DE` and assert `Encode(1.5)` is `"1.5"`), decode round-trip for each element type, and decode of malformed JSON throws `FormatException` (caught by callers). + +```csharp +using System.Globalization; +using ZB.MOM.WW.ScadaBridge.Commons.Types; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using Xunit; + +public class AttributeValueCodecTests +{ + [Fact] + public void Encode_StringList_ProducesJsonArray() => + Assert.Equal("[\"WO-1\",\"WO-2\"]", + AttributeValueCodec.Encode(new List { "WO-1", "WO-2" })); + + [Fact] + public void Encode_Scalar_String_ReturnedAsIs() => + Assert.Equal("hello", AttributeValueCodec.Encode("hello")); + + [Fact] + public void Encode_Scalar_Double_IsInvariant() + { + CultureInfo.CurrentCulture = new CultureInfo("de-DE"); + Assert.Equal("1.5", AttributeValueCodec.Encode(1.5)); + } + + [Fact] + public void Encode_EmptyList_IsBracketPair() => + Assert.Equal("[]", AttributeValueCodec.Encode(new List())); + + [Fact] + public void Encode_StringWithComma_IsEscaped() => + Assert.Equal("[\"ACME, Inc.\"]", + AttributeValueCodec.Encode(new List { "ACME, Inc." })); + + [Fact] + public void RoundTrip_Int32List() + { + var json = AttributeValueCodec.Encode(new List { 1, 2, 3 }); + var back = (IList)AttributeValueCodec.Decode(json, DataType.List, DataType.Int32)!; + Assert.Equal(new[] { 1, 2, 3 }, back); + } + + [Fact] + public void Decode_Scalar_ReturnsString() => + Assert.Equal("42", AttributeValueCodec.Decode("42", DataType.Int32, null)); + + [Fact] + public void Decode_MalformedJson_Throws() => + Assert.Throws(() => + AttributeValueCodec.Decode("not json", DataType.List, DataType.String)); +} +``` + +**Step 2 — Run, expect FAIL** (`AttributeValueCodec` not defined). +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ZB.MOM.WW.ScadaBridge.Commons.Tests.csproj --filter AttributeValueCodecTests` + +**Step 3 — Implement.** Use `System.Text.Json` with invariant formatting. For decode, reuse the element-conversion idea from `ScriptParameters.ConvertScalar` (`src/ZB.MOM.WW.ScadaBridge.Commons/Types/ScriptParameters.cs:161`) — keep the codec self-contained (small private scalar-parser switch on `DataType`). + +```csharp +using System.Collections; +using System.Globalization; +using System.Text.Json; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Types; + +/// +/// Canonical, round-trippable codec for attribute values. Scalars encode to an +/// invariant-culture string (identical to the historical representation); List +/// attributes encode to a JSON array. Used wherever a value is stored or +/// transmitted (DB Value column, site SQLite, gRPC wire). +/// remains a separate, display-only (comma-joined) formatter. +/// +public static class AttributeValueCodec +{ + private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = false }; + + /// Encodes a value to its canonical string form. + public static string? Encode(object? value) + { + switch (value) + { + case null: return null; + case string s: return s; // already canonical + case IFormattable f: return f.ToString(null, CultureInfo.InvariantCulture); + case IEnumerable e: + var items = e.Cast() + .Select(x => x is IFormattable xf + ? xf.ToString(null, CultureInfo.InvariantCulture) + : x?.ToString()); + return JsonSerializer.Serialize(items, JsonOpts); + default: return value.ToString(); + } + } + + /// + /// Decodes a canonical string. For returns a typed + /// List<T>; for scalars returns the string unchanged. Throws + /// on malformed list JSON or an un-parseable element. + /// + public static object? Decode(string? value, DataType dataType, DataType? elementType) + { + if (dataType != DataType.List) return value; // scalar: unchanged + if (string.IsNullOrEmpty(value)) return null; + if (elementType is null) + throw new FormatException("List attribute requires an element type."); + + string?[] raw; + try { raw = JsonSerializer.Deserialize(value) ?? []; } + catch (JsonException ex) { throw new FormatException("Malformed list JSON.", ex); } + + var clrType = ElementClrType(elementType.Value); + var listType = typeof(List<>).MakeGenericType(clrType); + var result = (IList)Activator.CreateInstance(listType)!; + foreach (var item in raw) + result.Add(ParseScalar(item, elementType.Value)); + return result; + } + + private static Type ElementClrType(DataType t) => t switch + { + DataType.String => typeof(string), + DataType.Int32 => typeof(int), + DataType.Float => typeof(float), + DataType.Double => typeof(double), + DataType.Boolean => typeof(bool), + DataType.DateTime => typeof(DateTime), + _ => throw new FormatException($"Unsupported list element type '{t}'.") + }; + + private static object? ParseScalar(string? s, DataType t) + { + if (s is null) throw new FormatException("List elements may not be null."); + var c = CultureInfo.InvariantCulture; + try + { + return t switch + { + DataType.String => s, + DataType.Int32 => int.Parse(s, c), + DataType.Float => float.Parse(s, c), + DataType.Double => double.Parse(s, c), + DataType.Boolean => bool.Parse(s), + DataType.DateTime => DateTime.Parse(s, c, DateTimeStyles.RoundtripKind), + _ => throw new FormatException($"Unsupported list element type '{t}'.") + }; + } + catch (Exception ex) when (ex is FormatException or OverflowException) + { + throw new FormatException($"List element '{s}' is not a valid {t}.", ex); + } + } + + /// True if the type may be a List element scalar. + public static bool IsValidElementType(DataType t) => + t is DataType.String or DataType.Int32 or DataType.Float + or DataType.Double or DataType.Boolean or DataType.DateTime; +} +``` + +**Step 4 — Run tests, expect PASS.** + +**Step 5 — Commit.** +```bash +git add src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs +git commit -m "feat(commons): AttributeValueCodec for canonical list value encode/decode" +``` + +**Acceptance:** all codec tests pass; scalars unchanged; lists round-trip; malformed JSON throws `FormatException`. + +--- + +### Task MV-3: EF mapping + idempotent migration + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** MV-2, MV-4 +**Blocked by:** MV-1 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/TemplateConfiguration.cs:111-122` +- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/InstanceConfiguration.cs:102-103` +- Create: a new migration under `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/` + +**Step 1 — EF config.** In `TemplateConfiguration.Configure`: widen `Value` and map the new column: +```csharp +builder.Property(a => a.Value); // remove .HasMaxLength(4000) → defaults to nvarchar(max) + +builder.Property(a => a.ElementDataType) + .HasConversion() + .HasMaxLength(50); +``` +In `InstanceConfiguration` (the `InstanceAttributeOverride` config): drop `.HasMaxLength(4000)` from `OverrideValue`, and add the same `ElementDataType` mapping. + +**Step 2 — Generate the migration.** +```bash +dotnet ef migrations add AddListAttributeElementType \ + --project src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase \ + --startup-project src/ZB.MOM.WW.ScadaBridge.Host +``` + +**Step 3 — Make `Up`/`Down` idempotent** (per open follow-up #70 — re-running against a partially-migrated prod DB must not throw). Wrap the generated `AddColumn`/`AlterColumn` calls with existence guards via `migrationBuilder.Sql(...)`: +```csharp +migrationBuilder.Sql(@" +IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE Name='ElementDataType' AND Object_ID=Object_ID('TemplateAttributes')) + ALTER TABLE [TemplateAttributes] ADD [ElementDataType] nvarchar(50) NULL;"); +migrationBuilder.Sql(@" +IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE Name='ElementDataType' AND Object_ID=Object_ID('InstanceAttributeOverrides')) + ALTER TABLE [InstanceAttributeOverrides] ADD [ElementDataType] nvarchar(50) NULL;"); +migrationBuilder.Sql("ALTER TABLE [TemplateAttributes] ALTER COLUMN [Value] nvarchar(max) NULL;"); +migrationBuilder.Sql("ALTER TABLE [InstanceAttributeOverrides] ALTER COLUMN [OverrideValue] nvarchar(max) NULL;"); +``` +Replace the auto-generated `AddColumn`/`AlterColumn` statements with the guarded SQL above (keep the `.Designer.cs` snapshot the tool generated — only the `Up`/`Down` body becomes guarded SQL). Provide a `Down` that drops the columns if present and restores `nvarchar(4000)`. + +**Step 4 — Verify no model drift.** +Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.csproj` +Then confirm there are no pending model changes: +```bash +dotnet ef migrations has-pending-model-changes \ + --project src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase \ + --startup-project src/ZB.MOM.WW.ScadaBridge.Host +``` +Expected: "No changes have been made to the model since the last migration" (or exit 0). + +**Step 5 — Commit.** +```bash +git add src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase +git commit -m "feat(db): migration for ElementDataType + widen attribute Value to nvarchar(max) (idempotent)" +``` + +**Acceptance:** project builds; no pending model changes; migration `Up`/`Down` are guarded/idempotent. + +--- + +### Task MV-4: Flatten carries ElementDataType into ResolvedAttribute + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** MV-2, MV-3 +**Blocked by:** MV-1 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs:177` (and any other site that constructs a `ResolvedAttribute`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs` + +**Step 1 — Failing test:** flatten a template whose attribute is `DataType.List` / `ElementDataType=String` and assert the `ResolvedAttribute` has `DataType=="List"` and `ElementDataType=="String"`; assert an instance override of that attribute keeps `ElementDataType` (override replaces value, not element type). + +**Step 2 — Run, expect FAIL.** + +**Step 3 — Implement:** wherever `ResolvedAttribute` is built (e.g. `DataType = attr.DataType.ToString()`), also set `ElementDataType = attr.ElementDataType?.ToString()`. Grep for `new ResolvedAttribute` and `DataType = ` in `FlatteningService.cs` to find every construction site and the override-merge path. + +**Step 4 — Run, expect PASS.** + +**Step 5 — Commit:** `feat(template): carry ElementDataType through flatten/override`. + +--- + +### Task MV-5: Semantic validation for List attributes + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** MV-6, MV-8, MV-9 (different files) +**Blocked by:** MV-1, MV-2 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs:18-21,130-193` +- Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs` +- Test: `tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SemanticValidatorTests.cs` + +**Rules to enforce (write a failing test for each first):** +1. `DataType.List` requires a non-null `ElementDataType` that is a valid element scalar (`AttributeValueCodec.IsValidElementType`); a scalar attribute must have `ElementDataType == null`. Violation → validation Error. +2. An authored default `Value` on a List attribute, if present, must `AttributeValueCodec.Decode` without throwing (catch `FormatException` → Error with the element type and message). +3. A List attribute used as an operand in a numeric trigger (HiLo / RangeViolation) or a binary trigger → Error. Extend the existing `NumericDataTypes` operand check (~line 130-193): a `List` operand is never numeric and never a valid binary operand. + +**Steps:** failing tests → run (FAIL) → implement the three checks → run (PASS) → commit `feat(validation): semantic checks for List attributes (element type, default value, trigger operands)`. + +--- + +### Task MV-6: Script-accessor encode boundary + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** MV-8, MV-9 +**Blocked by:** MV-2 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs:56,73` +- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/...` (accessor or runtime-context test; create if absent) + +**Problem:** `AttributeAccessor` set/`SetAsync` currently do `value?.ToString()`, which turns a `List` into `"System.Collections.Generic.List`1[System.String]"`. + +**Step 1 — Failing test:** a fake/seam `ScriptRuntimeContext.SetAttribute(name, encoded)` capturing the encoded string; assert setting a `List{"a","b"}` sends `["a","b"]` and setting a scalar `"x"` still sends `"x"`. + +**Step 2 — Run, expect FAIL.** + +**Step 3 — Implement:** replace both `.ToString()` sites with the codec: +```csharp +set => _ctx.SetAttribute(Resolve(key), AttributeValueCodec.Encode(value) ?? string.Empty) + .GetAwaiter().GetResult(); +// and in SetAsync: +=> _ctx.SetAttribute(Resolve(key), AttributeValueCodec.Encode(value) ?? string.Empty); +``` +Add `using ZB.MOM.WW.ScadaBridge.Commons.Types;`. + +**Step 4 — Run, expect PASS.** + +**Step 5 — Commit:** `fix(siteruntime): encode list attribute writes via AttributeValueCodec (was .ToString())`. + +--- + +### Task MV-7: InstanceActor decode (load + static set + override merge) + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** none +**Blocked by:** MV-1, MV-2, MV-4, MV-6 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs:114-122` (load), `:307-320` (`HandleSetStaticAttributeCore`), and the static-override merge on load (grep `GetStaticOverridesAsync` usage) +- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs` + +**Goal:** `_attributes` holds a typed `List` for List attributes so `HandleGetAttribute` returns a real list to scripts, and the canonical JSON string is what gets persisted + streamed. + +**Step 1 — Failing tests** (use the existing InstanceActor test harness / TestKit): +- Load a flattened config with a List attribute default `["a","b"]` → `GetAttributeRequest` returns an `IEnumerable` of `{"a","b"}` (not the raw string). +- `SetStaticAttributeCommand` with `Value = "[\"x\",\"y\"]"` on a List attribute → subsequent get returns `{"x","y"}`; assert `SetStaticOverrideAsync` was called with the JSON string `["x","y"]` (persisted form is canonical JSON). +- A persisted SQLite override `["p","q"]` applied on load decodes to a list. +- Malformed stored value → attribute loads with Bad quality, actor does not throw (wrap `Decode` in try/catch → log + set quality Bad). + +**Step 2 — Run, expect FAIL.** + +**Step 3 — Implement.** +- Add a small private helper on the actor: + ```csharp + private object? DecodeAttributeValue(ResolvedAttribute attr, string? raw) + { + var dataType = Enum.Parse(attr.DataType, ignoreCase: true); + var elementType = string.IsNullOrEmpty(attr.ElementDataType) + ? (DataType?)null + : Enum.Parse(attr.ElementDataType, ignoreCase: true); + try { return AttributeValueCodec.Decode(raw, dataType, elementType); } + catch (FormatException ex) + { + _logger.LogWarning(ex, "Attribute '{Attr}' on '{Instance}' has an undecodable value; marking Bad quality", + attr.CanonicalName, _instanceUniqueName); + return null; // caller sets quality Bad + } + } + ``` +- Load loop (`:116-121`): `_attributes[attr.CanonicalName] = DecodeAttributeValue(attr, attr.Value);` and set quality `Bad` when a List value failed to decode (non-null raw but null result). +- Apply the same decode when merging persisted static overrides (the `GetStaticOverridesAsync` merge). +- `HandleSetStaticAttributeCore` (`:309`): look up the `ResolvedAttribute` for `command.AttributeName`; store `DecodeAttributeValue(resolved, command.Value)` in `_attributes` (so reads are typed) while continuing to **persist and publish `command.Value`** (the canonical JSON string) unchanged. The published `AttributeValueChanged` keeps carrying the canonical string — `StreamRelayActor` (MV-9) handles encoding uniformly. + +**Step 4 — Run, expect PASS.** + +**Step 5 — Commit:** `feat(siteruntime): decode List attributes to typed lists in InstanceActor (load/set/override)`. + +**Acceptance:** scripts read List attributes as `List`; persisted + streamed form is canonical JSON; undecodable values degrade to Bad quality without crashing the actor. + +--- + +### Task MV-8: DCL OPC UA array read coercion + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** MV-5, MV-6, MV-9 +**Blocked by:** MV-1, MV-2 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs` (read/subscription value handling) — and the InstanceActor handler that ingests a DCL value for a data-sourced attribute (grep for where incoming tag values update `_attributes` / publish `AttributeValueChanged`). +- Test: `tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/...` + +**Goal:** when a data-sourced attribute is declared `DataType.List`, an incoming OPC UA array value becomes a typed list with each element coerced to `ElementDataType`; element-type mismatch → Bad quality + log (non-fatal). The **write** path needs no change (`RealOpcUaClient.WriteValueAsync` already wraps in `Variant`, which serializes a `List`/`T[]` as an OPC UA array — add a test asserting an array value writes without exception). + +**Steps:** failing tests (array read → typed list of correct element type; mismatched element → Bad quality; array write via Variant succeeds) → run (FAIL) → implement coercion using `AttributeValueCodec`/`Convert` per element → run (PASS) → commit `feat(dcl): coerce OPC UA array reads to typed list attributes; Bad quality on element mismatch`. + +--- + +### Task MV-9: StreamRelayActor canonical-JSON encode + +**Classification:** small +**Estimated implement time:** ~2 min +**Parallelizable with:** MV-5, MV-6, MV-8 +**Blocked by:** MV-2 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/StreamRelayActor.cs:48` +- Test: `tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/...` + +**Step 1 — Failing test:** relay an `AttributeValueChanged` whose `Value` is a `List{"a","b"}` → assert the produced `AttributeValueUpdate.Value == "[\"a\",\"b\"]"`; relay a scalar string `"x"` → assert `Value == "x"` (unchanged). + +**Step 2 — Run, expect FAIL.** + +**Step 3 — Implement:** replace `Value = ValueFormatter.FormatDisplayValue(msg.Value),` with `Value = AttributeValueCodec.Encode(msg.Value) ?? string.Empty,` (add the `using`). This is additive — List is a new type, no existing wire consumer relies on comma-joined lists; the proto `string value` field is unchanged. + +**Step 4 — Run, expect PASS.** + +**Step 5 — Commit:** `feat(comm): stream List attribute values as canonical JSON`. + +--- + +### Task MV-10: ManagementActor add/update attribute handlers + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** MV-11 not (blocks it); MV-12/13 share contract +**Blocked by:** MV-1, MV-2, MV-5 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs:~1441-1461` (add/update attribute), `:526` (the `DataType.ToString()` serialization for read-back) +- Modify: the management command/message contract that carries an attribute (add `ElementDataType` — additive field; grep for the `AddAttribute`/`UpdateAttribute` command records in `Commons/Messages/...`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/...` + +**Goal:** the add/update-attribute path accepts an optional element-type, validates `(DataType, ElementDataType, Value)` via the MV-5 rules + `AttributeValueCodec`, and persists both columns. Read-back includes `ElementDataType`. + +**Steps:** failing tests (add a List attribute with element type String + JSON default → persisted with both columns; add List without element type → rejected; add List with bad default → rejected) → run (FAIL) → implement parse/validate/persist → run (PASS) → commit `feat(mgmt): accept + validate ElementDataType on attribute add/update`. + +--- + +### Task MV-11: CLI element-type + JSON value + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** MV-12, MV-13 +**Blocked by:** MV-10 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs:140-199` (attribute add/update) and the instance-override command if present +- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/README.md` (document `--element-type` + JSON `--value`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/...` (option parsing) + +**Goal:** add `--element-type `; `--value` accepts a JSON array for List attributes. Validate element type client-side before sending; surface server validation errors. + +**Steps:** failing test (parse `--data-type List --element-type String --value '["a","b"]'` → request carries List + String + JSON) → run (FAIL) → implement option + plumb to the management call → run (PASS) → commit `feat(cli): --element-type and JSON --value for List attributes`. + +--- + +### Task MV-12: Transport DTO + importer field + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** MV-4, MV-9, MV-11, MV-13 +**Blocked by:** MV-1 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/...` or `src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs:77-83` (`TemplateAttributeDto`) +- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs:~2300-2306` +- Test: `tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/...` (round-trip export→import of a List attribute) + +**Goal:** the export/import DTO carries `ElementDataType`; a List attribute survives an export→import round-trip. Old bundles without the field import as scalars (null element type) — assert backward-compat. + +**Steps:** failing round-trip test → run (FAIL) → add `ElementDataType` to the DTO + importer mapping → run (PASS) → commit `feat(transport): round-trip ElementDataType for List attributes`. + +--- + +### Task MV-13: Central UI — TemplateEdit list editor + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** MV-11, MV-12, MV-14 +**Blocked by:** MV-10 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TemplateEdit.razor:82,484-491,556-566` +- (Optional) Create: a small `AttributeListEditor.razor` shared component under `CentralUI/Components/Shared/` +- Test/verify: Playwright fixture if the harness covers TemplateEdit; otherwise manual verification noted in the task. + +Use the **frontend-design** skill for the editor UI (clean corporate Bootstrap, no third-party component frameworks — per CLAUDE.md). + +**Goal:** when the attribute `DataType` dropdown = `List`, reveal an `ElementDataType` dropdown (the 6 scalars) and a repeatable add/remove row editor bound to the JSON value. Inline per-element validation by element type. The dropdown already enumerates `Enum.GetValues()`, so `List` appears automatically — gate the element-type + list editor on `_attrDataType == DataType.List`. + +**Steps:** implement the conditional editor; bind to `AttributeValueCodec.Encode` of the rows for submit; decode existing JSON into rows on edit; build `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/...`; verify (Playwright or manual) → commit `feat(ui): List attribute editor in TemplateEdit`. + +--- + +### Task MV-14: Central UI — InstanceConfigure override list editor + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** MV-13 +**Blocked by:** MV-10 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor` +- Reuse: the `AttributeListEditor.razor` component from MV-13 if created. + +**Goal:** overriding a List attribute on an instance uses the same list editor (whole-list replacement; element type shown read-only — fixed by base). Clearing the override removes it. + +**Steps:** implement using the shared editor; element-type dropdown is read-only here; build CentralUI; verify → commit `feat(ui): List attribute override editor in InstanceConfigure`. + +--- + +### Task MV-15: Integration verification + docs/README sync + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none +**Blocked by:** all (MV-1 … MV-14) + +**Files:** +- Modify: `README.md` and/or `docs/requirements/Component-*.md` if a DataType/attribute capability note needs updating (Template Engine / Commons). +- Modify: `docs/plans/2026-06-16-multivalue-attribute-design.md` — mark Status complete. + +**Steps:** +1. Full-solution build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — expect 0 errors. +2. Run the feature's targeted test classes across the touched test projects (codec, flatten, validation, InstanceActor, DCL, comm, transport, CLI). +3. End-to-end smoke (optional, if exercising the cluster): `bash docker/deploy.sh`, then via CLI create a template with a List attribute (`--data-type List --element-type String --value '["WO-1","WO-2"]'`), deploy an instance, and confirm the value flows to the DebugView as `["WO-1","WO-2"]`. +4. Update docs; sync the design-decision note if the high-level requirements track the DataType set. +5. Commit: `docs: mark multi-value attribute feature complete; sync README/component notes`. + +**Acceptance:** full build green; all targeted tests pass; (if run) the value round-trips end-to-end through the cluster. + +--- + +## Parallelization summary + +- **Wave 1:** MV-1 (foundation, solo). +- **Wave 2 (after MV-1):** MV-2, MV-3, MV-4, MV-12 in parallel (disjoint files). +- **Wave 3 (after MV-2):** MV-5, MV-6, MV-8, MV-9 in parallel; MV-7 after MV-6. +- **Wave 4 (after MV-5):** MV-10; then MV-11, MV-13, MV-14 in parallel after MV-10. +- **Wave 5:** MV-15 (final, solo). + +## Risk notes + +- **MV-3** (migration) and **MV-7** (actor model) are the high-risk tasks — full review chain. +- The migration must be idempotent (open follow-up #70 is the cautionary precedent). +- The gRPC change (MV-9) is additive — `List` is a brand-new type, so no existing wire consumer breaks; the proto field is unchanged. diff --git a/docs/plans/2026-06-16-multivalue-attribute.md.tasks.json b/docs/plans/2026-06-16-multivalue-attribute.md.tasks.json new file mode 100644 index 00000000..0ead10c0 --- /dev/null +++ b/docs/plans/2026-06-16-multivalue-attribute.md.tasks.json @@ -0,0 +1,23 @@ +{ + "planPath": "docs/plans/2026-06-16-multivalue-attribute.md", + "designDoc": "docs/plans/2026-06-16-multivalue-attribute-design.md", + "branch": "feature/multivalue-attribute", + "tasks": [ + {"id": 77, "ref": "MV-1", "subject": "Type model — DataType.List + ElementDataType companion", "class": "standard", "status": "pending"}, + {"id": 78, "ref": "MV-2", "subject": "AttributeValueCodec + tests", "class": "standard", "status": "pending", "blockedBy": [77]}, + {"id": 79, "ref": "MV-3", "subject": "EF mapping + idempotent migration", "class": "high-risk", "status": "pending", "blockedBy": [77]}, + {"id": 80, "ref": "MV-4", "subject": "Flatten carries ElementDataType", "class": "small", "status": "pending", "blockedBy": [77]}, + {"id": 81, "ref": "MV-5", "subject": "Semantic validation for List attributes", "class": "standard", "status": "pending", "blockedBy": [77, 78]}, + {"id": 82, "ref": "MV-6", "subject": "Script-accessor encode boundary", "class": "small", "status": "pending", "blockedBy": [78]}, + {"id": 83, "ref": "MV-7", "subject": "InstanceActor decode (load + set + override)", "class": "high-risk", "status": "pending", "blockedBy": [77, 78, 80, 82]}, + {"id": 84, "ref": "MV-8", "subject": "DCL OPC UA array read coercion", "class": "standard", "status": "pending", "blockedBy": [77, 78]}, + {"id": 85, "ref": "MV-9", "subject": "StreamRelayActor canonical-JSON encode", "class": "small", "status": "pending", "blockedBy": [78]}, + {"id": 86, "ref": "MV-10", "subject": "ManagementActor add/update attribute handlers", "class": "standard", "status": "pending", "blockedBy": [77, 78, 81]}, + {"id": 87, "ref": "MV-11", "subject": "CLI --element-type + JSON --value", "class": "standard", "status": "pending", "blockedBy": [86]}, + {"id": 88, "ref": "MV-12", "subject": "Transport DTO + importer field", "class": "small", "status": "pending", "blockedBy": [77]}, + {"id": 89, "ref": "MV-13", "subject": "Central UI — TemplateEdit list editor", "class": "standard", "status": "pending", "blockedBy": [86]}, + {"id": 90, "ref": "MV-14", "subject": "Central UI — InstanceConfigure override list editor", "class": "standard", "status": "pending", "blockedBy": [86]}, + {"id": 91, "ref": "MV-15", "subject": "Integration verification + docs/README sync", "class": "standard", "status": "pending", "blockedBy": [77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90]} + ], + "lastUpdated": "2026-06-16" +} From 70fa0e739725fde71681ffa45afef67d015af4fe Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 15:18:12 -0400 Subject: [PATCH 03/25] feat(commons): add DataType.List + ElementDataType companion for multi-value attributes --- .../Entities/Instances/InstanceAttributeOverride.cs | 9 +++++++++ .../Entities/Templates/TemplateAttribute.cs | 7 +++++++ .../Types/Enums/DataType.cs | 3 ++- .../Types/Flattening/FlattenedConfiguration.cs | 2 ++ 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Instances/InstanceAttributeOverride.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Instances/InstanceAttributeOverride.cs index 0d129454..d0cc4e6f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Instances/InstanceAttributeOverride.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Instances/InstanceAttributeOverride.cs @@ -1,3 +1,5 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; + namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; public class InstanceAttributeOverride @@ -10,6 +12,13 @@ public class InstanceAttributeOverride public string AttributeName { get; set; } /// Gets or sets the override value, or null to clear a previous override. public string? OverrideValue { get; set; } + /// + /// For attributes: the scalar type of each + /// element (String, Int32, Float, Double, Boolean, DateTime). Null for scalar + /// attributes. The element type is fixed by the base attribute and cannot be + /// changed on a derived template or instance override. + /// + public DataType? ElementDataType { get; set; } /// Initializes a new for the given attribute name. /// The name of the attribute to override. diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Templates/TemplateAttribute.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Templates/TemplateAttribute.cs index 4e7c0e4c..9aad12a8 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Templates/TemplateAttribute.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Templates/TemplateAttribute.cs @@ -25,6 +25,13 @@ public class TemplateAttribute /// public DataType DataType { get; set; } /// + /// For attributes: the scalar type of each + /// element (String, Int32, Float, Double, Boolean, DateTime). Null for scalar + /// attributes. The element type is fixed by the base attribute and cannot be + /// changed on a derived template or instance override. + /// + public DataType? ElementDataType { get; set; } + /// /// Gets or sets a value indicating whether the attribute is locked from override. /// public bool IsLocked { get; set; } diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/DataType.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/DataType.cs index 0a26af7d..f84d46d7 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/DataType.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/DataType.cs @@ -8,5 +8,6 @@ public enum DataType Double, String, DateTime, - Binary + Binary, + List } diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs index 448d1313..74e6d9f9 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs @@ -66,6 +66,8 @@ public sealed record ResolvedAttribute public string? Value { get; init; } /// Gets the data type name. public string DataType { get; init; } = string.Empty; + /// For List attributes: the element scalar type name; null otherwise. + public string? ElementDataType { get; init; } /// Gets whether the attribute is locked. public bool IsLocked { get; init; } /// Gets the attribute description. From 8bd8079a7fef68ba19359fe37858c7afc8791f61 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 15:21:56 -0400 Subject: [PATCH 04/25] feat(commons): AttributeValueCodec for canonical list value encode/decode --- .../Types/AttributeValueCodec.cs | 99 +++++++++++ .../Types/AttributeValueCodecTests.cs | 161 ++++++++++++++++++ 2 files changed, 260 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs new file mode 100644 index 00000000..c4abd822 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs @@ -0,0 +1,99 @@ +using System.Collections; +using System.Globalization; +using System.Text.Json; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Types; + +/// +/// Canonical, round-trippable codec for attribute values. Scalars encode to an +/// invariant-culture string (identical to the historical representation); List +/// attributes encode to a JSON array. Used wherever a value is stored or +/// transmitted (DB Value column, site SQLite, gRPC wire). +/// remains a separate, display-only (comma-joined) formatter. +/// +public static class AttributeValueCodec +{ + private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = false }; + + /// Encodes a value to its canonical string form. + public static string? Encode(object? value) + { + switch (value) + { + case null: return null; + case string s: return s; // already canonical + case IFormattable f: return f.ToString(null, CultureInfo.InvariantCulture); + case IEnumerable e: + var items = e.Cast() + .Select(x => x is IFormattable xf + ? xf.ToString(null, CultureInfo.InvariantCulture) + : x?.ToString()); + return JsonSerializer.Serialize(items, JsonOpts); + default: return value.ToString(); + } + } + + /// + /// Decodes a canonical string. For returns a typed + /// List<T>; for scalars returns the string unchanged. Throws + /// on malformed list JSON or an un-parseable element. + /// + public static object? Decode(string? value, DataType dataType, DataType? elementType) + { + if (dataType != DataType.List) return value; // scalar: unchanged + if (string.IsNullOrEmpty(value)) return null; + if (elementType is null) + throw new FormatException("List attribute requires an element type."); + + string?[] raw; + try { raw = JsonSerializer.Deserialize(value) ?? []; } + catch (JsonException ex) { throw new FormatException("Malformed list JSON.", ex); } + + var clrType = ElementClrType(elementType.Value); + var listType = typeof(List<>).MakeGenericType(clrType); + var result = (IList)Activator.CreateInstance(listType)!; + foreach (var item in raw) + result.Add(ParseScalar(item, elementType.Value)); + return result; + } + + private static Type ElementClrType(DataType t) => t switch + { + DataType.String => typeof(string), + DataType.Int32 => typeof(int), + DataType.Float => typeof(float), + DataType.Double => typeof(double), + DataType.Boolean => typeof(bool), + DataType.DateTime => typeof(DateTime), + _ => throw new FormatException($"Unsupported list element type '{t}'.") + }; + + private static object? ParseScalar(string? s, DataType t) + { + if (s is null) throw new FormatException("List elements may not be null."); + var c = CultureInfo.InvariantCulture; + try + { + return t switch + { + DataType.String => s, + DataType.Int32 => int.Parse(s, c), + DataType.Float => float.Parse(s, c), + DataType.Double => double.Parse(s, c), + DataType.Boolean => bool.Parse(s), + DataType.DateTime => DateTime.Parse(s, c, DateTimeStyles.RoundtripKind), + _ => throw new FormatException($"Unsupported list element type '{t}'.") + }; + } + catch (Exception ex) when (ex is FormatException or OverflowException) + { + throw new FormatException($"List element '{s}' is not a valid {t}.", ex); + } + } + + /// True if the type may be a List element scalar. + public static bool IsValidElementType(DataType t) => + t is DataType.String or DataType.Int32 or DataType.Float + or DataType.Double or DataType.Boolean or DataType.DateTime; +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs new file mode 100644 index 00000000..ac4dce9e --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs @@ -0,0 +1,161 @@ +using System.Globalization; +using ZB.MOM.WW.ScadaBridge.Commons.Types; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types; + +/// +/// Tests for — the canonical, round-trippable +/// codec used wherever an attribute value is stored or transmitted. Scalars must +/// encode to their historical invariant-culture string; List attributes encode to +/// a JSON array and decode back to a typed List<T>. +/// +public class AttributeValueCodecTests +{ + [Fact] + public void Encode_StringList_ProducesJsonArray() => + Assert.Equal("[\"WO-1\",\"WO-2\"]", + AttributeValueCodec.Encode(new List { "WO-1", "WO-2" })); + + [Fact] + public void Encode_Scalar_String_ReturnedAsIs() => + Assert.Equal("hello", AttributeValueCodec.Encode("hello")); + + [Fact] + public void Encode_Scalar_Double_IsInvariant() + { + var original = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("de-DE"); + Assert.Equal("1.5", AttributeValueCodec.Encode(1.5)); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } + + [Fact] + public void Encode_Null_ReturnsNull() => + Assert.Null(AttributeValueCodec.Encode(null)); + + [Fact] + public void Encode_EmptyList_IsBracketPair() => + Assert.Equal("[]", AttributeValueCodec.Encode(new List())); + + [Fact] + public void Encode_StringWithComma_IsEscaped() => + Assert.Equal("[\"ACME, Inc.\"]", + AttributeValueCodec.Encode(new List { "ACME, Inc." })); + + [Fact] + public void Encode_DoubleList_IsInvariant() + { + var original = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("de-DE"); + Assert.Equal("[\"1.5\",\"2.5\"]", + AttributeValueCodec.Encode(new List { 1.5, 2.5 })); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } + + [Fact] + public void RoundTrip_Int32List() + { + var json = AttributeValueCodec.Encode(new List { 1, 2, 3 }); + var back = (IList)AttributeValueCodec.Decode(json, DataType.List, DataType.Int32)!; + Assert.Equal(new[] { 1, 2, 3 }, back); + } + + [Fact] + public void RoundTrip_DoubleList_IsCultureInvariant() + { + var original = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("de-DE"); + var json = AttributeValueCodec.Encode(new List { 1.5, 2.5 }); + var back = (IList)AttributeValueCodec.Decode(json, DataType.List, DataType.Double)!; + Assert.Equal(new[] { 1.5, 2.5 }, back); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } + + [Fact] + public void RoundTrip_BoolList() + { + var json = AttributeValueCodec.Encode(new List { true, false, true }); + var back = (IList)AttributeValueCodec.Decode(json, DataType.List, DataType.Boolean)!; + Assert.Equal(new[] { true, false, true }, back); + } + + [Fact] + public void RoundTrip_DateTimeList_Iso8601() + { + var a = new DateTime(2026, 6, 15, 13, 45, 30, DateTimeKind.Utc); + var b = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var json = AttributeValueCodec.Encode(new List { a, b }); + var back = (IList)AttributeValueCodec.Decode(json, DataType.List, DataType.DateTime)!; + Assert.Equal(new[] { a, b }, back); + } + + [Fact] + public void Decode_StringArray_ProducesListOfString() + { + var back = (IList)AttributeValueCodec.Decode("[\"a\",\"b\"]", DataType.List, DataType.String)!; + Assert.Equal(new[] { "a", "b" }, back); + } + + [Fact] + public void Decode_Scalar_ReturnsString() => + Assert.Equal("42", AttributeValueCodec.Decode("42", DataType.Int32, null)); + + [Fact] + public void Decode_List_NullValue_ReturnsNull() => + Assert.Null(AttributeValueCodec.Decode(null, DataType.List, DataType.String)); + + [Fact] + public void Decode_List_EmptyValue_ReturnsNull() => + Assert.Null(AttributeValueCodec.Decode("", DataType.List, DataType.String)); + + [Fact] + public void Decode_MalformedJson_Throws() => + Assert.Throws(() => + AttributeValueCodec.Decode("not json", DataType.List, DataType.String)); + + [Fact] + public void Decode_UnparseableElement_Throws() => + Assert.Throws(() => + AttributeValueCodec.Decode("[\"abc\"]", DataType.List, DataType.Int32)); + + [Fact] + public void Decode_NullElement_Throws() => + Assert.Throws(() => + AttributeValueCodec.Decode("[null]", DataType.List, DataType.String)); + + [Fact] + public void Decode_List_WithoutElementType_Throws() => + Assert.Throws(() => + AttributeValueCodec.Decode("[\"a\"]", DataType.List, null)); + + [Theory] + [InlineData(DataType.String, true)] + [InlineData(DataType.Int32, true)] + [InlineData(DataType.Float, true)] + [InlineData(DataType.Double, true)] + [InlineData(DataType.Boolean, true)] + [InlineData(DataType.DateTime, true)] + [InlineData(DataType.Binary, false)] + [InlineData(DataType.List, false)] + public void IsValidElementType_MatchesScalarSet(DataType t, bool expected) => + Assert.Equal(expected, AttributeValueCodec.IsValidElementType(t)); +} From 4a4b3d677d54a3ae06bf7da9a4b0d42b590ab9d8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 15:22:29 -0400 Subject: [PATCH 05/25] feat(db): migration for ElementDataType + widen attribute Value to nvarchar(max) (idempotent) --- .../Configurations/InstanceConfiguration.cs | 8 +- .../Configurations/TemplateConfiguration.cs | 8 +- ...31_AddListAttributeElementType.Designer.cs | 1739 +++++++++++++++++ ...60616192131_AddListAttributeElementType.cs | 41 + .../ScadaBridgeDbContextModelSnapshot.cs | 14 +- 5 files changed, 1802 insertions(+), 8 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260616192131_AddListAttributeElementType.Designer.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260616192131_AddListAttributeElementType.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/InstanceConfiguration.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/InstanceConfiguration.cs index f9c4b038..db0d6fe6 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/InstanceConfiguration.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/InstanceConfiguration.cs @@ -99,8 +99,12 @@ public class InstanceAttributeOverrideConfiguration : IEntityTypeConfiguration o.OverrideValue) - .HasMaxLength(4000); + // nvarchar(max): List attribute values (JSON arrays) can exceed 4000 chars. + builder.Property(o => o.OverrideValue); + + builder.Property(o => o.ElementDataType) + .HasConversion() + .HasMaxLength(50); builder.HasIndex(o => new { o.InstanceId, o.AttributeName }).IsUnique(); } diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/TemplateConfiguration.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/TemplateConfiguration.cs index d4ac008c..e8f35766 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/TemplateConfiguration.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/TemplateConfiguration.cs @@ -108,8 +108,8 @@ public class TemplateAttributeConfiguration : IEntityTypeConfiguration a.Value) - .HasMaxLength(4000); + // nvarchar(max): List attribute values (JSON arrays) can exceed 4000 chars. + builder.Property(a => a.Value); builder.Property(a => a.Description) .HasMaxLength(2000); @@ -121,6 +121,10 @@ public class TemplateAttributeConfiguration : IEntityTypeConfiguration() .HasMaxLength(50); + builder.Property(a => a.ElementDataType) + .HasConversion() + .HasMaxLength(50); + builder.HasIndex(a => new { a.TemplateId, a.Name }).IsUnique(); } } diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260616192131_AddListAttributeElementType.Designer.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260616192131_AddListAttributeElementType.Designer.cs new file mode 100644 index 00000000..0a62077e --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260616192131_AddListAttributeElementType.Designer.cs @@ -0,0 +1,1739 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; + +#nullable disable + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations +{ + [DbContext(typeof(ScadaBridgeDbContext))] + [Migration("20260616192131_AddListAttributeElementType")] + partial class AddListAttributeElementType + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("AfterStateJson") + .HasColumnType("nvarchar(max)"); + + b.Property("BundleImportId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("User") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("BundleImportId") + .HasDatabaseName("IX_AuditLogEntries_BundleImportId"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.HasIndex("User"); + + b.ToTable("AuditLogEntries"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.SiteCall", b => + { + b.Property("TrackedOperationId") + .HasMaxLength(36) + .IsUnicode(false) + .HasColumnType("varchar(36)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceSite") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .IsRequired() + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.Property("TerminalAtUtc") + .HasColumnType("datetime2"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("TrackedOperationId"); + + b.HasIndex("SourceSite", "CreatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Source_Created"); + + b.HasIndex("Status", "UpdatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Status_Updated"); + + b.ToTable("SiteCalls", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DeployedConfigSnapshots"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("InstanceId"); + + b.ToTable("DeploymentRecords"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.SystemArtifactDeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PerSiteStatus") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.ToTable("SystemArtifactDeploymentRecords"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.DatabaseConnectionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DatabaseConnectionDefinitions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthConfiguration") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("EndpointUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ExternalSystemDefinitions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalSystemDefinitionId") + .HasColumnType("int"); + + b.Property("HttpMethod") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSystemDefinitionId", "Name") + .IsUnique(); + + b.ToTable("ExternalSystemMethods"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi.ApiMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Script") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TimeoutSeconds") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiMethods"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentAreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentAreaId"); + + b.HasIndex("SiteId", "ParentAreaId", "Name") + .IsUnique() + .HasFilter("[ParentAreaId] IS NOT NULL"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("UniqueName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("SiteId", "UniqueName") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlarmCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("PriorityLevelOverride") + .HasColumnType("int"); + + b.Property("TriggerConfigurationOverride") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AlarmCanonicalName") + .IsUnique(); + + b.ToTable("InstanceAlarmOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ElementDataType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("OverrideValue") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceAttributeOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DataConnectionId") + .HasColumnType("int"); + + b.Property("DataSourceReferenceOverride") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DataConnectionId"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceConnectionBindings"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceNativeAlarmSourceOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConditionFilterOverride") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ConnectionNameOverride") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("SourceCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("SourceReferenceOverride") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "SourceCanonicalName") + .IsUnique(); + + b.ToTable("InstanceNativeAlarmSourceOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.Notification", b => + { + b.Property("NotificationId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeliveredAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ListName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NextAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("OriginExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("OriginParentExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("ResolvedTargets") + .HasColumnType("nvarchar(max)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SiteEnqueuedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SourceInstanceId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceScript") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceSiteId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TypeData") + .HasColumnType("nvarchar(max)"); + + b.HasKey("NotificationId"); + + b.HasIndex("SourceSiteId", "CreatedAt"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationLists"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotificationListId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NotificationListId"); + + b.ToTable("NotificationRecipients"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.SmtpConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("Credentials") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("FromAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaxConcurrentConnections") + .HasColumnType("int"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TlsMode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("SmtpConfigurations"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts.SharedScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedScripts"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.LdapGroupMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("LdapGroupName") + .IsUnique(); + + b.ToTable("LdapGroupMappings"); + + b.HasData( + new + { + Id = 1, + LdapGroupName = "SCADA-Admins", + Role = "Administrator" + }, + new + { + Id = 2, + LdapGroupName = "SCADA-Designers", + Role = "Designer" + }, + new + { + Id = 3, + LdapGroupName = "SCADA-Deploy-All", + Role = "Deployer" + }, + new + { + Id = 4, + LdapGroupName = "SCADA-Deploy-SiteA", + Role = "Deployer" + }, + new + { + Id = 5, + LdapGroupName = "SCADA-Viewers", + Role = "Viewer" + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.SiteScopeRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupMappingId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.HasIndex("LdapGroupMappingId", "SiteId") + .IsUnique(); + + b.ToTable("SiteScopeRules"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BackupConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("FailoverRetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PrimaryConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("DataConnections"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("GrpcNodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("GrpcNodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SiteIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SiteIdentifier") + .IsUnique(); + + b.ToTable("Sites"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FolderId") + .HasColumnType("int"); + + b.Property("IsDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OwnerCompositionId") + .HasColumnType("int"); + + b.Property("ParentTemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("Name") + .IsUnique() + .HasFilter("[IsDerived] = 0"); + + b.HasIndex("ParentTemplateId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OnTriggerScriptId") + .HasColumnType("int"); + + b.Property("PriorityLevel") + .HasColumnType("int"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAlarms"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DataSourceReference") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ElementDataType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAttributes"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateComposition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComposedTemplateId") + .HasColumnType("int"); + + b.Property("InstanceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ComposedTemplateId"); + + b.HasIndex("TemplateId", "InstanceName") + .IsUnique(); + + b.ToTable("TemplateCompositions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentFolderId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentFolderId", "Name") + .IsUnique() + .HasFilter("[ParentFolderId] IS NOT NULL"); + + b.ToTable("TemplateFolders"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateNativeAlarmSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConditionFilter") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ConnectionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceReference") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateNativeAlarmSources"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ExecutionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("MinTimeBetweenRuns") + .HasColumnType("time"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateScripts"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities.AuditLogRow", b => + { + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime2"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Actor") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)") + .HasColumnName("Category"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DetailsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("ExecutionId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("uniqueidentifier") + .HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier)", true); + + b.Property("IngestedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2(7)") + .HasComputedColumnSql("CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7))", false); + + b.Property("Kind") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)") + .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.kind')", true); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .IsUnicode(false) + .HasColumnType("varchar(16)"); + + b.Property("ParentExecutionId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("uniqueidentifier") + .HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier)", true); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceSiteId") + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)") + .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.sourceSiteId')", true); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)") + .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.status')", true); + + b.Property("Target") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("EventId", "OccurredAtUtc"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("IX_AuditLog_CorrelationId") + .HasFilter("[CorrelationId] IS NOT NULL"); + + b.HasIndex("EventId") + .IsUnique() + .HasDatabaseName("UX_AuditLog_EventId"); + + b.HasIndex("ExecutionId") + .HasDatabaseName("IX_AuditLog_Execution"); + + b.HasIndex("OccurredAtUtc") + .IsDescending() + .HasDatabaseName("IX_AuditLog_OccurredAtUtc"); + + b.HasIndex("ParentExecutionId") + .HasDatabaseName("IX_AuditLog_ParentExecution"); + + b.HasIndex("SourceNode", "OccurredAtUtc") + .HasDatabaseName("IX_AuditLog_Node_Occurred"); + + b.HasIndex("SourceSiteId", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Site_Occurred"); + + b.HasIndex("Target", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Target_Occurred") + .HasFilter("[Target] IS NOT NULL"); + + b.HasIndex("Channel", "Status", "OccurredAtUtc") + .IsDescending(false, false, true) + .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred"); + + b.ToTable("AuditLog", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemDefinition", null) + .WithMany() + .HasForeignKey("ExternalSystemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", null) + .WithMany("Children") + .HasForeignKey("ParentAreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", null) + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("AlarmOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("AttributeOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", null) + .WithMany() + .HasForeignKey("DataConnectionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("ConnectionBindings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceNativeAlarmSourceOverride", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("NativeAlarmSourceOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", null) + .WithMany("Recipients") + .HasForeignKey("NotificationListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.SiteScopeRule", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.LdapGroupMapping", null) + .WithMany() + .HasForeignKey("LdapGroupMappingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ParentTemplateId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAlarm", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Alarms") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAttribute", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Attributes") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateComposition", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ComposedTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Compositions") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("ParentFolderId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateNativeAlarmSource", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("NativeAlarmSources") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateScript", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Scripts") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.Navigation("AlarmOverrides"); + + b.Navigation("AttributeOverrides"); + + b.Navigation("ConnectionBindings"); + + b.Navigation("NativeAlarmSourceOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", b => + { + b.Navigation("Recipients"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.Navigation("Alarms"); + + b.Navigation("Attributes"); + + b.Navigation("Compositions"); + + b.Navigation("NativeAlarmSources"); + + b.Navigation("Scripts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260616192131_AddListAttributeElementType.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260616192131_AddListAttributeElementType.cs new file mode 100644 index 00000000..1317ac6c --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260616192131_AddListAttributeElementType.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations +{ + /// + public partial class AddListAttributeElementType : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Idempotent DDL: every statement is guarded so this migration is safe + // to re-run against a partially-migrated DB (the #70 crash-loop lesson). + migrationBuilder.Sql(@" +IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE Name='ElementDataType' AND Object_ID=Object_ID('TemplateAttributes')) + ALTER TABLE [TemplateAttributes] ADD [ElementDataType] nvarchar(50) NULL;"); + migrationBuilder.Sql(@" +IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE Name='ElementDataType' AND Object_ID=Object_ID('InstanceAttributeOverrides')) + ALTER TABLE [InstanceAttributeOverrides] ADD [ElementDataType] nvarchar(50) NULL;"); + // ALTER COLUMN is naturally idempotent: re-running widens an already-widened column. + migrationBuilder.Sql("ALTER TABLE [TemplateAttributes] ALTER COLUMN [Value] nvarchar(max) NULL;"); + migrationBuilder.Sql("ALTER TABLE [InstanceAttributeOverrides] ALTER COLUMN [OverrideValue] nvarchar(max) NULL;"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Idempotent reverse DDL: drop the new columns only if present, then + // restore the value columns to their original nvarchar(4000) width. + migrationBuilder.Sql(@" +IF EXISTS (SELECT 1 FROM sys.columns WHERE Name='ElementDataType' AND Object_ID=Object_ID('TemplateAttributes')) + ALTER TABLE [TemplateAttributes] DROP COLUMN [ElementDataType];"); + migrationBuilder.Sql(@" +IF EXISTS (SELECT 1 FROM sys.columns WHERE Name='ElementDataType' AND Object_ID=Object_ID('InstanceAttributeOverrides')) + ALTER TABLE [InstanceAttributeOverrides] DROP COLUMN [ElementDataType];"); + migrationBuilder.Sql("ALTER TABLE [TemplateAttributes] ALTER COLUMN [Value] nvarchar(4000) NULL;"); + migrationBuilder.Sql("ALTER TABLE [InstanceAttributeOverrides] ALTER COLUMN [OverrideValue] nvarchar(4000) NULL;"); + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs index 763d6861..f4ccbfa7 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs @@ -563,12 +563,15 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations .HasMaxLength(200) .HasColumnType("nvarchar(200)"); + b.Property("ElementDataType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + b.Property("InstanceId") .HasColumnType("int"); b.Property("OverrideValue") - .HasMaxLength(4000) - .HasColumnType("nvarchar(4000)"); + .HasColumnType("nvarchar(max)"); b.HasKey("Id"); @@ -1164,6 +1167,10 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations .HasMaxLength(2000) .HasColumnType("nvarchar(2000)"); + b.Property("ElementDataType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + b.Property("IsInherited") .HasColumnType("bit"); @@ -1182,8 +1189,7 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations .HasColumnType("int"); b.Property("Value") - .HasMaxLength(4000) - .HasColumnType("nvarchar(4000)"); + .HasColumnType("nvarchar(max)"); b.HasKey("Id"); From e7e34b26f19b5764e1cfdbb600766a8d946ddec2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 15:23:39 -0400 Subject: [PATCH 06/25] feat(transport): round-trip ElementDataType for List attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add DataType? ElementDataType to TemplateAttributeDto (optional, default null for backward-compat with old bundles). Map it in both directions in EntitySerializer (export + FromBundleContent) and in all three TemplateAttribute construction sites in BundleImporter (BuildTemplate, SyncTemplateAttributesAsync add-path, and SyncTemplateAttributesAsync update-path including change-detection). Two new round-trip tests in EntitySerializerTests confirm List attributes survive export→import and that old DTOs with null ElementDataType import cleanly. --- .../Import/BundleImporter.cs | 6 +- .../Serialization/EntityDtos.cs | 3 +- .../Serialization/EntitySerializer.cs | 4 +- .../Serialization/EntitySerializerTests.cs | 67 +++++++++++++++++++ 4 files changed, 77 insertions(+), 3 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs index 1d76adb7..5b9f968e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs @@ -1045,6 +1045,7 @@ public sealed class BundleImporter : IBundleImporter IsLocked = a.IsLocked, Description = a.Description, DataSourceReference = a.DataSourceReference, + ElementDataType = a.ElementDataType, }); } foreach (var al in dto.Alarms) @@ -1122,7 +1123,8 @@ public sealed class BundleImporter : IBundleImporter current.DataType != attrDto.DataType || current.IsLocked != attrDto.IsLocked || !string.Equals(current.Description, attrDto.Description, StringComparison.Ordinal) || - !string.Equals(current.DataSourceReference, attrDto.DataSourceReference, StringComparison.Ordinal); + !string.Equals(current.DataSourceReference, attrDto.DataSourceReference, StringComparison.Ordinal) || + current.ElementDataType != attrDto.ElementDataType; if (!changed) continue; current.Value = attrDto.Value; @@ -1130,6 +1132,7 @@ public sealed class BundleImporter : IBundleImporter current.IsLocked = attrDto.IsLocked; current.Description = attrDto.Description; current.DataSourceReference = attrDto.DataSourceReference; + current.ElementDataType = attrDto.ElementDataType; await _templateRepo.UpdateTemplateAttributeAsync(current, ct).ConfigureAwait(false); await _auditService.LogAsync( user, @@ -1158,6 +1161,7 @@ public sealed class BundleImporter : IBundleImporter IsLocked = attrDto.IsLocked, Description = attrDto.Description, DataSourceReference = attrDto.DataSourceReference, + ElementDataType = attrDto.ElementDataType, }; ex.Attributes.Add(newAttr); await _auditService.LogAsync( diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs index 4ffa0fba..cf80088c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs @@ -80,7 +80,8 @@ public sealed record TemplateAttributeDto( DataType DataType, bool IsLocked, string? Description, - string? DataSourceReference); + string? DataSourceReference, + DataType? ElementDataType = null); public sealed record TemplateAlarmDto( string Name, diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs index 3ee64a07..22c7c31b 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs @@ -51,7 +51,8 @@ public sealed class EntitySerializer DataType: a.DataType, IsLocked: a.IsLocked, Description: a.Description, - DataSourceReference: a.DataSourceReference)).ToList(), + DataSourceReference: a.DataSourceReference, + ElementDataType: a.ElementDataType)).ToList(), Alarms: t.Alarms.Select(a => new TemplateAlarmDto( Name: a.Name, Description: a.Description, @@ -203,6 +204,7 @@ public sealed class EntitySerializer IsLocked = a.IsLocked, Description = a.Description, DataSourceReference = a.DataSourceReference, + ElementDataType = a.ElementDataType, }); } foreach (var al in dto.Alarms) diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs index a4604310..b4fefbb0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs @@ -223,4 +223,71 @@ public sealed class EntitySerializerTests var sys = Assert.Single(aggregate.ExternalSystems); Assert.Null(sys.AuthConfiguration); } + + [Fact] + public void Roundtrip_List_attribute_preserves_ElementDataType() + { + // A template with a DataType.List attribute whose element type is String. + var template = new Template("Pump") { Id = 1 }; + template.Attributes.Add(new TemplateAttribute("Tags") + { + Id = 1, + TemplateId = 1, + DataType = DataType.List, + ElementDataType = DataType.String, + Value = "[\"a\",\"b\"]", + IsLocked = false, + }); + + var aggregate = MakeEmptyAggregate() with { Templates = new[] { template } }; + + var sut = new EntitySerializer(); + var dto = sut.ToBundleContent(aggregate); + + // Export side: DTO must carry ElementDataType. + var dtoTemplate = Assert.Single(dto.Templates); + var dtoAttr = Assert.Single(dtoTemplate.Attributes); + Assert.Equal(DataType.List, dtoAttr.DataType); + Assert.Equal(DataType.String, dtoAttr.ElementDataType); + + // Import side: entity reconstructed from DTO preserves the value. + var roundTripped = sut.FromBundleContent(dto); + var rtTemplate = Assert.Single(roundTripped.Templates); + var rtAttr = Assert.Single(rtTemplate.Attributes); + Assert.Equal(DataType.List, rtAttr.DataType); + Assert.Equal(DataType.String, rtAttr.ElementDataType); + Assert.Equal("[\"a\",\"b\"]", rtAttr.Value); + } + + [Fact] + public void Roundtrip_scalar_attribute_with_null_ElementDataType_remains_null() + { + // Backward-compat: an old bundle DTO with null ElementDataType must not throw + // and must produce a scalar attribute with null ElementDataType. + var template = new Template("Sensor") { Id = 1 }; + template.Attributes.Add(new TemplateAttribute("Pressure") + { + Id = 1, + TemplateId = 1, + DataType = DataType.Double, + ElementDataType = null, + Value = "42.0", + IsLocked = false, + }); + + var aggregate = MakeEmptyAggregate() with { Templates = new[] { template } }; + + var sut = new EntitySerializer(); + var dto = sut.ToBundleContent(aggregate); + + var dtoTemplate = Assert.Single(dto.Templates); + var dtoAttr = Assert.Single(dtoTemplate.Attributes); + Assert.Equal(DataType.Double, dtoAttr.DataType); + Assert.Null(dtoAttr.ElementDataType); + + var roundTripped = sut.FromBundleContent(dto); + var rtAttr = Assert.Single(Assert.Single(roundTripped.Templates).Attributes); + Assert.Equal(DataType.Double, rtAttr.DataType); + Assert.Null(rtAttr.ElementDataType); + } } From 02aff2436e5039c34932ec6c4613ae48d6ddad99 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 15:24:31 -0400 Subject: [PATCH 07/25] feat(template): carry ElementDataType through flatten/override --- .../Flattening/FlatteningService.cs | 1 + .../Flattening/FlatteningServiceTests.cs | 63 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs index 7b45d22a..c99af3e4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs @@ -175,6 +175,7 @@ public class FlatteningService CanonicalName = attr.Name, Value = attr.Value, DataType = attr.DataType.ToString(), + ElementDataType = attr.ElementDataType?.ToString(), IsLocked = attr.IsLocked, Description = attr.Description, DataSourceReference = attr.DataSourceReference, diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs index 47e92e8a..7f06d7cc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs @@ -788,4 +788,67 @@ public class FlatteningServiceTests Assert.Equal("ns=2;s=Tank07", result.Value.NativeAlarmSources[0].SourceReference); Assert.Equal("Override", result.Value.NativeAlarmSources[0].Source); } + + // ── MV-4: ElementDataType carried through flatten/override ──────────── + + [Fact] + public void Flatten_ListAttribute_ElementDataTypeCarriedToResolvedAttribute() + { + // A template with a List attribute whose ElementDataType is String. + // The flattened result must carry DataType == "List" and ElementDataType == "String". + var template = CreateTemplate(1, "Base"); + template.Attributes.Add(new TemplateAttribute("Tags") + { + DataType = DataType.List, + ElementDataType = DataType.String, + Value = null + }); + + var instance = CreateInstance(); + var result = _sut.Flatten( + instance, + [template], + new Dictionary>(), + new Dictionary>(), + new Dictionary()); + + Assert.True(result.IsSuccess); + var attr = result.Value.Attributes.First(a => a.CanonicalName == "Tags"); + Assert.Equal("List", attr.DataType); + Assert.Equal("String", attr.ElementDataType); + } + + [Fact] + public void Flatten_ListAttributeWithInstanceOverride_ElementDataTypePreservedThroughOverride() + { + // An instance override replaces the VALUE of a List attribute. + // The ElementDataType must survive the override path unchanged. + var template = CreateTemplate(1, "Base"); + template.Attributes.Add(new TemplateAttribute("Tags") + { + DataType = DataType.List, + ElementDataType = DataType.String, + Value = "[\"a\",\"b\"]" + }); + + var instance = CreateInstance(); + instance.AttributeOverrides.Add(new InstanceAttributeOverride("Tags") + { + OverrideValue = "[\"x\",\"y\",\"z\"]" + }); + + var result = _sut.Flatten( + instance, + [template], + new Dictionary>(), + new Dictionary>(), + new Dictionary()); + + Assert.True(result.IsSuccess); + var attr = result.Value.Attributes.First(a => a.CanonicalName == "Tags"); + Assert.Equal("[\"x\",\"y\",\"z\"]", attr.Value); // override applied + Assert.Equal("Override", attr.Source); // came from override path + Assert.Equal("List", attr.DataType); // type unchanged + Assert.Equal("String", attr.ElementDataType); // element type preserved + } } From 492b41f0fd6ad164fbbd8ce6cecc77702a5b7760 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 15:33:27 -0400 Subject: [PATCH 08/25] fix(multivalue): Wave-2 review fixes (MV-2/MV-4/MV-12) - MV-2: guard unsupported element type before parse (no misleading re-wrap); add Float round-trip test - MV-4: carry ElementDataType through the two validation-flatten ResolvedAttribute sites (ManagementActor.HandleValidateTemplate, BundleImporter.BuildFlattenedConfigForValidation) so MV-5 validation sees element type via every entry point - MV-12: include ElementDataType in TemplateAttribute add/update audit payloads + fix stale docstring --- .../Types/AttributeValueCodec.cs | 2 ++ .../ManagementActor.cs | 1 + .../Import/BundleImporter.cs | 7 +++++-- .../Types/AttributeValueCodecTests.cs | 8 ++++++++ 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs index c4abd822..ecc4cbea 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs @@ -72,6 +72,8 @@ public static class AttributeValueCodec private static object? ParseScalar(string? s, DataType t) { if (s is null) throw new FormatException("List elements may not be null."); + if (!IsValidElementType(t)) + throw new FormatException($"Unsupported list element type '{t}'."); var c = CultureInfo.InvariantCulture; try { diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index 72dd1eaa..8456c69e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -524,6 +524,7 @@ public class ManagementActor : ReceiveActor CanonicalName = a.Name, Value = a.Value, DataType = a.DataType.ToString(), + ElementDataType = a.ElementDataType?.ToString(), IsLocked = a.IsLocked, DataSourceReference = a.DataSourceReference }).ToList(), diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs index 5b9f968e..a93d34bc 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs @@ -1083,8 +1083,8 @@ public sealed class BundleImporter : IBundleImporter /// enumerates for the "Template overwritten" action. /// /// Update detection compares every scalar field (Value, DataType, - /// IsLocked, Description, DataSourceReference) — no field change → no - /// audit row, so an idempotent overwrite produces no noise. + /// ElementDataType, IsLocked, Description, DataSourceReference) — no field + /// change → no audit row, so an idempotent overwrite produces no noise. /// /// private async Task SyncTemplateAttributesAsync( @@ -1146,6 +1146,7 @@ public sealed class BundleImporter : IBundleImporter AttributeName = current.Name, current.Value, current.DataType, + current.ElementDataType, current.IsLocked, current.Description, current.DataSourceReference, @@ -1176,6 +1177,7 @@ public sealed class BundleImporter : IBundleImporter AttributeName = newAttr.Name, newAttr.Value, newAttr.DataType, + newAttr.ElementDataType, newAttr.IsLocked, newAttr.Description, newAttr.DataSourceReference, @@ -2304,6 +2306,7 @@ public sealed class BundleImporter : IBundleImporter CanonicalName = a.Name, Value = a.Value, DataType = a.DataType.ToString(), + ElementDataType = a.ElementDataType?.ToString(), IsLocked = a.IsLocked, Description = a.Description, DataSourceReference = a.DataSourceReference, diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs index ac4dce9e..a4aed551 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs @@ -49,6 +49,14 @@ public class AttributeValueCodecTests Assert.Equal("[\"ACME, Inc.\"]", AttributeValueCodec.Encode(new List { "ACME, Inc." })); + [Fact] + public void RoundTrip_FloatList() + { + var json = AttributeValueCodec.Encode(new List { 1.5f, 2.25f, -3.75f }); + var back = (IList)AttributeValueCodec.Decode(json, DataType.List, DataType.Float)!; + Assert.Equal(new[] { 1.5f, 2.25f, -3.75f }, back); + } + [Fact] public void Encode_DoubleList_IsInvariant() { From ba414cbb683d84510b090d5f6034cdeed7b14c91 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 15:37:33 -0400 Subject: [PATCH 09/25] feat(comm): stream List attribute values as canonical JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace ValueFormatter.FormatDisplayValue with AttributeValueCodec.Encode in StreamRelayActor so List attribute values cross the gRPC wire as a JSON array (e.g. ["a","b"]) rather than a comma-joined display string. Scalars and null values are unaffected. Tests cover List→JSON, scalar string pass-through, and null→empty-string. --- .../Actors/StreamRelayActor.cs | 2 +- .../Grpc/StreamRelayActorTests.cs | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/StreamRelayActor.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/StreamRelayActor.cs index ecb11452..4103640d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/StreamRelayActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/StreamRelayActor.cs @@ -45,7 +45,7 @@ public class StreamRelayActor : ReceiveActor InstanceUniqueName = msg.InstanceUniqueName, AttributePath = msg.AttributePath, AttributeName = msg.AttributeName, - Value = ValueFormatter.FormatDisplayValue(msg.Value), + Value = AttributeValueCodec.Encode(msg.Value) ?? string.Empty, Quality = MapQuality(msg.Quality), Timestamp = Timestamp.FromDateTimeOffset(msg.Timestamp) } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/StreamRelayActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/StreamRelayActorTests.cs index 416a475b..ba1fad29 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/StreamRelayActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/StreamRelayActorTests.cs @@ -204,4 +204,37 @@ public class StreamRelayActorTests : TestKit Assert.True(channel.Reader.TryRead(out var evt)); Assert.Equal("", evt.AttributeChanged.Value); } + + [Fact] + public void ListValue_EncodesAsJsonArray() + { + // List attributes must cross the wire as JSON, not as a comma-joined display string. + var channel = Channel.CreateUnbounded(); + var actor = Sys.ActorOf(Props.Create(() => + new StreamRelayActor("corr-list", channel.Writer))); + + var ts = DateTimeOffset.UtcNow; + actor.Tell(new AttributeValueChanged("Inst", "Path", "Tags", + new List { "a", "b" }, "Good", ts)); + + Thread.Sleep(500); + Assert.True(channel.Reader.TryRead(out var evt)); + Assert.Equal("[\"a\",\"b\"]", evt.AttributeChanged.Value); + } + + [Fact] + public void ScalarStringValue_PassesThroughUnchanged() + { + // Scalar strings must not be double-encoded. + var channel = Channel.CreateUnbounded(); + var actor = Sys.ActorOf(Props.Create(() => + new StreamRelayActor("corr-scalar", channel.Writer))); + + var ts = DateTimeOffset.UtcNow; + actor.Tell(new AttributeValueChanged("Inst", "Path", "Name", "x", "Good", ts)); + + Thread.Sleep(500); + Assert.True(channel.Reader.TryRead(out var evt)); + Assert.Equal("x", evt.AttributeChanged.Value); + } } From a1d464b50d42556b7c78d57aa5a65100015a980c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 15:38:00 -0400 Subject: [PATCH 10/25] fix(siteruntime): encode list attribute writes via AttributeValueCodec (was .ToString()) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace value?.ToString() with AttributeValueCodec.Encode(value) in AttributeAccessor indexer set and SetAsync, so a List{"a","b"} encodes to ["a","b"] instead of the garbage ToString representation. Add using ZB.MOM.WW.ScadaBridge.Commons.Types. Tests verify the codec contract (list→JSON array, scalar passthrough, null); full round-trip through the accessor is not viable without a live Akka ActorSystem — noted in-test with explanation. --- .../Scripts/ScopeAccessors.cs | 6 ++- .../Scripts/ScopeAccessorTests.cs | 51 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs index 95d4911e..bd78a5b5 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs @@ -1,3 +1,5 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Types; + namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; /// @@ -53,7 +55,7 @@ public class AttributeAccessor // on the DCL round-trip for data-connected attributes. The async // variants (GetAsync/SetAsync) are preferred where awaiting is possible. get => _ctx.GetAttribute(Resolve(key)).GetAwaiter().GetResult(); - set => _ctx.SetAttribute(Resolve(key), value?.ToString() ?? string.Empty).GetAwaiter().GetResult(); + set => _ctx.SetAttribute(Resolve(key), AttributeValueCodec.Encode(value) ?? string.Empty).GetAwaiter().GetResult(); } /// @@ -70,7 +72,7 @@ public class AttributeAccessor /// The value to set. /// A task that represents the asynchronous operation. public Task SetAsync(string key, object? value) - => _ctx.SetAttribute(Resolve(key), value?.ToString() ?? string.Empty); + => _ctx.SetAttribute(Resolve(key), AttributeValueCodec.Encode(value) ?? string.Empty); } /// diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs index 9492ee27..73458ab8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs @@ -1,3 +1,4 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; @@ -84,4 +85,54 @@ public class ScopeAccessorTests var temp = children["TempSensor"]; Assert.Equal("Motor.TempSensor", temp.Path); } + + // --- AttributeAccessor encoding contract ---------------------------------- + // + // AttributeAccessor.this[key].set and SetAsync both route through + // ScriptRuntimeContext.SetAttribute(name, encodedString), which requires + // a live Akka IActorRef; ScriptRuntimeContext has no virtual members and + // its constructor cannot be satisfied without a real ActorSystem, so a + // full-round-trip unit test through the accessor+context is not viable + // without a heavy Akka harness. + // + // Instead we test the encoding decision directly: AttributeAccessor is now + // documented to delegate value serialisation to AttributeValueCodec.Encode. + // These tests verify that contract at the codec level, which is exactly what + // the fix makes the accessor invoke. + + [Fact] + public void AttributeValueCodec_Encode_List_ProducesJsonArray() + { + // A List must encode to a JSON array, not the garbage + // "System.Collections.Generic.List`1[System.String]" that .ToString() produced. + var list = new List { "a", "b" }; + var encoded = AttributeValueCodec.Encode(list); + Assert.Equal("[\"a\",\"b\"]", encoded); + } + + [Fact] + public void AttributeValueCodec_Encode_Scalar_PassesThrough() + { + // A plain string scalar must be returned unchanged (byte-identical to + // the historical value?.ToString() path for strings). + var encoded = AttributeValueCodec.Encode("x"); + Assert.Equal("x", encoded); + } + + [Fact] + public void AttributeValueCodec_Encode_Null_ReturnsNull() + { + // AttributeAccessor coalesces null → "" at the call site, + // but the codec itself must return null for null input. + Assert.Null(AttributeValueCodec.Encode(null)); + } + + [Fact] + public void AttributeValueCodec_Encode_IntList_ProducesJsonArray() + { + // Integer list elements encode via InvariantCulture IFormattable. + var list = new List { 1, 2, 3 }; + var encoded = AttributeValueCodec.Encode(list); + Assert.Equal("[\"1\",\"2\",\"3\"]", encoded); + } } From 872ce2b565242db75f233785ba81017d726cbf97 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 15:38:18 -0400 Subject: [PATCH 11/25] feat(validation): semantic checks for List attributes (element type, default value, trigger operands) --- .../Validation/SemanticValidator.cs | 78 +++++++ .../Validation/ValidationService.cs | 5 +- .../Validation/SemanticValidatorTests.cs | 213 ++++++++++++++++++ 3 files changed, 295 insertions(+), 1 deletion(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs index adc54210..3e2849b2 100644 --- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs @@ -1,4 +1,6 @@ using System.Text.Json; +using ZB.MOM.WW.ScadaBridge.Commons.Types; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation; @@ -48,6 +50,11 @@ public class SemanticValidator attributeMap.TryAdd(a.CanonicalName, a); } + // List-attribute type semantics (MV-5): element-type cardinality + default + // value parseability. Trigger-operand rejection (rule 3) is handled below + // by the existing NumericDataTypes guard (List is never numeric). + ValidateListAttributes(configuration, errors); + // Collect alarm on-trigger script names for cross-call violation checks var alarmOnTriggerScripts = new HashSet(StringComparer.Ordinal); foreach (var alarm in configuration.Alarms) @@ -250,6 +257,77 @@ public class SemanticValidator return new ValidationResult { Errors = errors, Warnings = warnings }; } + /// + /// MV-5 — semantic validation of List-attribute type configuration. Two rules: + /// + /// Element-type cardinality. A attribute + /// must carry a non-empty that is + /// a valid element scalar (see ); + /// a non-List attribute must NOT carry an element type. + /// Default-value parseability. A non-empty authored default + /// on a List attribute must + /// without throwing. + /// + /// Attributes whose doesn't parse to a + /// known are skipped here (their data type is not "List", + /// so only the "no element type" half could apply, and an unparseable type is a + /// separate concern not introduced by this feature). + /// + private static void ValidateListAttributes( + FlattenedConfiguration configuration, + List errors) + { + foreach (var attr in configuration.Attributes) + { + var isList = string.Equals(attr.DataType, nameof(DataType.List), StringComparison.OrdinalIgnoreCase); + var hasElementType = !string.IsNullOrWhiteSpace(attr.ElementDataType); + + // ── Rule 1: element-type cardinality ───────────────────────────── + if (!isList) + { + if (hasElementType) + { + errors.Add(ValidationEntry.Error(ValidationCategory.MissingMetadata, + $"Attribute '{attr.CanonicalName}' has data type '{attr.DataType}' but declares an element type '{attr.ElementDataType}'; element types are only valid on List attributes.", + attr.CanonicalName)); + } + continue; // Non-List attributes have no list-specific value to check. + } + + if (!hasElementType) + { + errors.Add(ValidationEntry.Error(ValidationCategory.MissingMetadata, + $"List attribute '{attr.CanonicalName}' must declare an element type (one of String, Int32, Float, Double, Boolean, DateTime).", + attr.CanonicalName)); + continue; // Without an element type we can't validate the default value. + } + + if (!Enum.TryParse(attr.ElementDataType, ignoreCase: true, out var elementType) + || !AttributeValueCodec.IsValidElementType(elementType)) + { + errors.Add(ValidationEntry.Error(ValidationCategory.MissingMetadata, + $"List attribute '{attr.CanonicalName}' has element type '{attr.ElementDataType}', which is not a valid element scalar (one of String, Int32, Float, Double, Boolean, DateTime).", + attr.CanonicalName)); + continue; // A bad element type makes the default-value check meaningless. + } + + // ── Rule 2: default-value parseability ─────────────────────────── + if (!string.IsNullOrEmpty(attr.Value)) + { + try + { + AttributeValueCodec.Decode(attr.Value, DataType.List, elementType); + } + catch (FormatException ex) + { + errors.Add(ValidationEntry.Error(ValidationCategory.MissingMetadata, + $"List attribute '{attr.CanonicalName}' has a default value that is not a valid list of '{elementType}': {ex.Message}", + attr.CanonicalName)); + } + } + } + } + private static void ValidateCallParameters( string callerName, CallTarget call, diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs index 2dd21b01..f99517cb 100644 --- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs @@ -18,7 +18,10 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation; /// and (on the deploy path) the bound connection must exist on the target site. /// Severity is context-dependent: a non-blocking Warning at template design time /// (bindings are set later) and a deploy-gating Error when enforced (M2.8 / #23). -/// 8. Does NOT verify tag path resolution on devices +/// 8. List-attribute type semantics (MV-5, via ): +/// element-type cardinality, default-value parseability, and trigger-operand +/// rejection for List attributes. +/// 9. Does NOT verify tag path resolution on devices /// public class ValidationService { diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SemanticValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SemanticValidatorTests.cs index 672090bb..07d95be7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SemanticValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SemanticValidatorTests.cs @@ -1056,4 +1056,217 @@ public class SemanticValidatorTests var result = _sut.Validate(config); Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); } + + // ── MV-5 List attribute semantics ──────────────────────────────────────── + + [Fact] + public void Validate_ListAttribute_ValidElementTypeAndDefault_NoError() + { + // List + String element + a well-formed JSON-array default — fully valid. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Tags", + DataType = "List", + ElementDataType = "String", + Value = "[\"a\",\"b\"]" + } + ] + }; + + var result = _sut.Validate(config); + Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.MissingMetadata); + } + + [Fact] + public void Validate_ListAttribute_NullElementType_ReturnsError() + { + // List with no element type — rule 1 violation (cardinality). + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Attributes = + [ + new ResolvedAttribute { CanonicalName = "Tags", DataType = "List", ElementDataType = null } + ] + }; + + var result = _sut.Validate(config); + Assert.Contains(result.Errors, e => + e.Category == ValidationCategory.MissingMetadata && + e.EntityName == "Tags" && + e.Message.Contains("element type", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Validate_ScalarAttribute_WithElementType_ReturnsError() + { + // A non-List attribute must not carry an element type — rule 1 violation. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Attributes = + [ + new ResolvedAttribute { CanonicalName = "Status", DataType = "String", ElementDataType = "String" } + ] + }; + + var result = _sut.Validate(config); + Assert.Contains(result.Errors, e => + e.Category == ValidationCategory.MissingMetadata && + e.EntityName == "Status" && + e.Message.Contains("element type", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Validate_ListAttribute_NonScalarElementType_ReturnsError() + { + // Binary and List are not valid List element scalars — rule 1 violation. + foreach (var bad in new[] { "Binary", "List" }) + { + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Attributes = + [ + new ResolvedAttribute { CanonicalName = "Tags", DataType = "List", ElementDataType = bad } + ] + }; + + var result = _sut.Validate(config); + Assert.Contains(result.Errors, e => + e.Category == ValidationCategory.MissingMetadata && + e.EntityName == "Tags"); + } + } + + [Fact] + public void Validate_ListAttribute_MalformedJsonDefault_ReturnsError() + { + // Unterminated JSON array default — rule 2 (default parseability) violation. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Tags", + DataType = "List", + ElementDataType = "String", + Value = "[\"a\"" + } + ] + }; + + var result = _sut.Validate(config); + Assert.Contains(result.Errors, e => + e.Category == ValidationCategory.MissingMetadata && + e.EntityName == "Tags" && + e.Message.Contains("default", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Validate_ListAttribute_DefaultElementNotParseable_ReturnsError() + { + // Element type Int32 but the default carries a non-integer element — rule 2 violation. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Counts", + DataType = "List", + ElementDataType = "Int32", + Value = "[\"x\"]" + } + ] + }; + + var result = _sut.Validate(config); + Assert.Contains(result.Errors, e => + e.Category == ValidationCategory.MissingMetadata && + e.EntityName == "Counts" && + e.Message.Contains("Int32")); + } + + [Fact] + public void Validate_ListAttribute_EmptyDefault_NoDefaultError() + { + // No authored default → rule 2 is inert; only the (satisfied) cardinality rule applies. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Attributes = + [ + new ResolvedAttribute { CanonicalName = "Tags", DataType = "List", ElementDataType = "String", Value = "" } + ] + }; + + var result = _sut.Validate(config); + Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.MissingMetadata); + } + + [Fact] + public void Validate_ListAttribute_AsRangeViolationOperand_ReturnsError() + { + // Rule 3 confirmation: a List attribute is non-numeric, so a RangeViolation + // trigger over it is rejected by the existing NumericDataTypes guard. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Attributes = + [ + new ResolvedAttribute { CanonicalName = "Tags", DataType = "List", ElementDataType = "Int32" } + ], + Alarms = + [ + new ResolvedAlarm + { + CanonicalName = "BadAlarm", + TriggerType = "RangeViolation", + TriggerConfiguration = "{\"attributeName\":\"Tags\"}" + } + ] + }; + + var result = _sut.Validate(config); + Assert.Contains(result.Errors, e => + e.Category == ValidationCategory.TriggerOperandType && + e.Message.Contains("non-numeric")); + } + + [Fact] + public void Validate_ListAttribute_AsHiLoOperand_ReturnsError() + { + // Rule 3 confirmation for HiLo. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Attributes = + [ + new ResolvedAttribute { CanonicalName = "Tags", DataType = "List", ElementDataType = "Double" } + ], + Alarms = + [ + new ResolvedAlarm + { + CanonicalName = "BadHiLo", + TriggerType = "HiLo", + TriggerConfiguration = "{\"attributeName\":\"Tags\",\"hi\":80}" + } + ] + }; + + var result = _sut.Validate(config); + Assert.Contains(result.Errors, e => + e.Category == ValidationCategory.TriggerOperandType && + e.Message.Contains("non-numeric")); + } } From 4765706e94bab867d4e044f1e48e80fcaa58954f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 15:39:19 -0400 Subject: [PATCH 12/25] feat(dcl): coerce OPC UA array reads to typed list attributes; Bad quality on element mismatch --- .../Actors/InstanceActor.cs | 137 +++++++++++++- .../RealOpcUaClientArrayWriteTests.cs | 56 ++++++ .../Actors/InstanceActorTests.cs | 179 ++++++++++++++++++ 3 files changed, 364 insertions(+), 8 deletions(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientArrayWriteTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs index 6588dd8b..21da0ecd 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs @@ -7,6 +7,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance; using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; +using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using ZB.MOM.WW.ScadaBridge.HealthMonitoring; @@ -433,21 +434,141 @@ public class InstanceActor : ReceiveActor if (!_tagPathToAttributes.TryGetValue(update.TagPath, out var attrNames)) return; - // Normalize array values to JSON strings so they survive Akka serialization - var value = update.Value is Array - ? System.Text.Json.JsonSerializer.Serialize(update.Value, update.Value.GetType()) - : update.Value; - // One tag path may back several attributes — update every one of them. + // Each attribute is coerced according to its own declared data type, so + // we resolve and convert per attribute rather than once for the tag. foreach (var attrName in attrNames) { - var changed = new AttributeValueChanged( + var resolved = _configuration?.Attributes + .FirstOrDefault(a => a.CanonicalName == attrName); + + // MV-8: a List-typed attribute coerces the incoming OPC UA array + // (a CLR array/IEnumerable from the SDK) into a typed List. On an + // element-type mismatch we set the attribute's quality to Bad, log a + // warning, and skip storing a value rather than crashing the actor. + if (resolved != null && IsListAttribute(resolved)) + { + if (TryCoerceListValue(resolved, update.Value, out var typedList)) + { + HandleAttributeValueChanged(new AttributeValueChanged( + _instanceUniqueName, update.TagPath, attrName, + typedList, update.Quality.ToString(), update.Timestamp)); + } + else + { + _logger.LogWarning( + "List attribute {Instance}.{Attribute} received a value that could not be coerced to List<{Element}>; marking quality Bad", + _instanceUniqueName, attrName, resolved.ElementDataType); + _attributeQualities[attrName] = "Bad"; + _attributeTimestamps[attrName] = update.Timestamp; + var currentValue = _attributes.GetValueOrDefault(attrName); + PublishAndNotifyChildren(new AttributeValueChanged( + _instanceUniqueName, update.TagPath, attrName, + currentValue, "Bad", update.Timestamp)); + } + continue; + } + + // Scalars and non-List attributes keep the historical behaviour: + // array values are normalized to JSON strings so they survive Akka + // serialization; scalars pass through unchanged. + var value = update.Value is Array + ? System.Text.Json.JsonSerializer.Serialize(update.Value, update.Value.GetType()) + : update.Value; + + HandleAttributeValueChanged(new AttributeValueChanged( _instanceUniqueName, update.TagPath, attrName, - value, update.Quality.ToString(), update.Timestamp); - HandleAttributeValueChanged(changed); + value, update.Quality.ToString(), update.Timestamp)); } } + /// True if the resolved attribute is declared as a . + private static bool IsListAttribute(ResolvedAttribute attr) => + Enum.TryParse(attr.DataType, ignoreCase: true, out var dt) + && dt == DataType.List; + + /// + /// MV-8: coerces an incoming data-sourced value (an OPC UA array / IEnumerable) + /// into a typed List<elementClrType> matching the attribute's + /// . Each element is converted + /// with invariant culture (round-trip parse for DateTime). Returns + /// on a missing/invalid element type, a non-enumerable + /// value, or any element that cannot be coerced — the caller then marks the + /// attribute quality Bad. Never throws. + /// + private bool TryCoerceListValue(ResolvedAttribute attr, object? incoming, out object? typedList) + { + typedList = null; + + if (string.IsNullOrEmpty(attr.ElementDataType) + || !Enum.TryParse(attr.ElementDataType, ignoreCase: true, out var elementType) + || !AttributeValueCodec.IsValidElementType(elementType)) + { + return false; + } + + if (incoming is not System.Collections.IEnumerable enumerable || incoming is string) + return false; + + var clrType = ListElementClrType(elementType); + var list = (System.Collections.IList)Activator.CreateInstance( + typeof(List<>).MakeGenericType(clrType))!; + + try + { + foreach (var element in enumerable) + list.Add(CoerceElement(element, elementType)); + } + catch (Exception ex) when (ex is FormatException or InvalidCastException + or OverflowException or ArgumentNullException) + { + return false; + } + + typedList = list; + return true; + } + + private static Type ListElementClrType(DataType t) => t switch + { + DataType.String => typeof(string), + DataType.Int32 => typeof(int), + DataType.Float => typeof(float), + DataType.Double => typeof(double), + DataType.Boolean => typeof(bool), + DataType.DateTime => typeof(DateTime), + _ => throw new FormatException($"Unsupported list element type '{t}'.") + }; + + private static object CoerceElement(object? element, DataType t) + { + if (element is null) + throw new FormatException("List elements may not be null."); + + var culture = System.Globalization.CultureInfo.InvariantCulture; + return t switch + { + DataType.String => Convert.ToString(element, culture) + ?? throw new FormatException("Null string element."), + DataType.Int32 => element is string si + ? int.Parse(si, culture) + : Convert.ToInt32(element, culture), + DataType.Float => element is string sf + ? float.Parse(sf, culture) + : Convert.ToSingle(element, culture), + DataType.Double => element is string sd + ? double.Parse(sd, culture) + : Convert.ToDouble(element, culture), + DataType.Boolean => element is string sb + ? bool.Parse(sb) + : Convert.ToBoolean(element, culture), + DataType.DateTime => element is string sdt + ? DateTime.Parse(sdt, culture, System.Globalization.DateTimeStyles.RoundtripKind) + : Convert.ToDateTime(element, culture), + _ => throw new FormatException($"Unsupported list element type '{t}'.") + }; + } + private void HandleConnectionQualityChanged(ConnectionQualityChanged qualityChanged) { _logger.LogWarning("Connection {Connection} quality changed to {Quality} for instance {Instance}", diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientArrayWriteTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientArrayWriteTests.cs new file mode 100644 index 00000000..c3babe72 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientArrayWriteTests.cs @@ -0,0 +1,56 @@ +using Opc.Ua; + +namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; + +/// +/// MV-8: the OPC UA WRITE path () +/// wraps the outgoing value in new Variant(value) and lets the OPC +/// Foundation SDK serialize it. For a structured multi-value (List) attribute +/// the value handed down is a CLR array. These tests assert that the load-bearing +/// step — wrapping an array in a — succeeds without +/// throwing, which is what the write path relies on (no separate array handling +/// is required in our code). A full device round-trip needs a live server and is +/// covered by the live OPC UA browse/read smoke tests. +/// +[Trait("Category", "Unit")] +public class RealOpcUaClientArrayWriteTests +{ + [Fact] + public void Variant_wraps_int_array_without_throwing() + { + var value = new[] { 10, 20, 30 }; + + var ex = Record.Exception(() => new Variant(value)); + + Assert.Null(ex); + var variant = new Variant(value); + Assert.Equal(BuiltInType.Int32, variant.TypeInfo.BuiltInType); + Assert.Equal(ValueRanks.OneDimension, variant.TypeInfo.ValueRank); + } + + [Fact] + public void Variant_wraps_double_array_without_throwing() + { + var value = new[] { 1.5, 2.5, 3.5 }; + + var ex = Record.Exception(() => new Variant(value)); + + Assert.Null(ex); + var variant = new Variant(value); + Assert.Equal(BuiltInType.Double, variant.TypeInfo.BuiltInType); + Assert.Equal(ValueRanks.OneDimension, variant.TypeInfo.ValueRank); + } + + [Fact] + public void Variant_wraps_string_array_without_throwing() + { + var value = new[] { "a", "b", "c" }; + + var ex = Record.Exception(() => new Variant(value)); + + Assert.Null(ex); + var variant = new Variant(value); + Assert.Equal(BuiltInType.String, variant.TypeInfo.BuiltInType); + Assert.Equal(ValueRanks.OneDimension, variant.TypeInfo.ValueRank); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs index 7ea11242..ffa04e29 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs @@ -1,4 +1,5 @@ using Akka.Actor; +using Akka.TestKit; using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -444,4 +445,182 @@ public class InstanceActorTests : TestKit, IDisposable Assert.Equal("Good", response.Quality); } } + + // ── MV-8: data-sourced List attribute coercion ───────────────────────── + + private IActorRef CreateInstanceActorWithDcl(string instanceName, FlattenedConfiguration config, TestProbe dcl) + { + var actor = ActorOf(Props.Create(() => new InstanceActor( + instanceName, + JsonSerializer.Serialize(config), + _storage, + _compilationService, + _sharedScriptLibrary, + null, + _options, + NullLogger.Instance, + dcl.Ref))); + + // On startup the actor subscribes its data-sourced tags through the DCL. + dcl.ExpectMsg(TimeSpan.FromSeconds(5)); + return actor; + } + + /// + /// MV-8: when a data-sourced attribute is declared DataType.List, an + /// incoming OPC UA array value (a CLR array surfaces from the SDK) must be + /// coerced into a typed List<int> whose elements match the + /// attribute's ElementDataType. The stored value must be a real list — not a + /// JSON string — so scripts read a typed collection. + /// + [Fact] + public void InstanceActor_DataSourcedListAttribute_CoercesArrayToTypedList() + { + const string tag = "ns=3;s=Pump.Setpoints"; + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump-List", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Setpoints", Value = null, + DataType = "List", ElementDataType = "Int32", + DataSourceReference = tag, BoundDataConnectionName = "PLC" + } + ] + }; + + var dcl = CreateTestProbe(); + var actor = CreateInstanceActorWithDcl("Pump-List", config, dcl); + + // OPC UA delivers an array value (CLR array) for the List-typed tag. + actor.Tell(new TagValueUpdate("PLC", tag, new[] { 10, 20, 30 }, QualityCode.Good, DateTimeOffset.UtcNow)); + + actor.Tell(new GetAttributeRequest("corr-list", "Pump-List", "Setpoints", DateTimeOffset.UtcNow)); + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + + Assert.True(response.Found); + Assert.Equal("Good", response.Quality); + var list = Assert.IsType>(response.Value); + Assert.Equal(new[] { 10, 20, 30 }, list); + } + + /// + /// MV-8: array elements coming in as a different CLR type (here, strings that + /// are valid integers) must still coerce to the declared element type. + /// + [Fact] + public void InstanceActor_DataSourcedListAttribute_CoercesElementTypes() + { + const string tag = "ns=3;s=Pump.Levels"; + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump-ListCoerce", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Levels", Value = null, + DataType = "List", ElementDataType = "Double", + DataSourceReference = tag, BoundDataConnectionName = "PLC" + } + ] + }; + + var dcl = CreateTestProbe(); + var actor = CreateInstanceActorWithDcl("Pump-ListCoerce", config, dcl); + + // Elements arrive as ints/strings but the attribute is List. + actor.Tell(new TagValueUpdate("PLC", tag, new object[] { 1, "2.5", 3 }, QualityCode.Good, DateTimeOffset.UtcNow)); + + actor.Tell(new GetAttributeRequest("corr-coerce", "Pump-ListCoerce", "Levels", DateTimeOffset.UtcNow)); + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + + Assert.True(response.Found); + Assert.Equal("Good", response.Quality); + var list = Assert.IsType>(response.Value); + Assert.Equal(new[] { 1.0, 2.5, 3.0 }, list); + } + + /// + /// MV-8: an element that cannot be coerced to the declared element type must + /// set the attribute quality to Bad and must NOT crash the actor (it + /// stays alive and continues to answer queries). + /// + [Fact] + public void InstanceActor_DataSourcedListAttribute_ElementMismatch_SetsBadQuality_ActorAlive() + { + const string tag = "ns=3;s=Pump.Bad"; + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump-ListBad", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Counts", Value = null, + DataType = "List", ElementDataType = "Int32", + DataSourceReference = tag, BoundDataConnectionName = "PLC" + } + ] + }; + + var dcl = CreateTestProbe(); + var actor = CreateInstanceActorWithDcl("Pump-ListBad", config, dcl); + Watch(actor); + + // "not-a-number" cannot be coerced to int → Bad quality, no crash. + actor.Tell(new TagValueUpdate("PLC", tag, new object[] { 1, "not-a-number", 3 }, QualityCode.Good, DateTimeOffset.UtcNow)); + + actor.Tell(new GetAttributeRequest("corr-bad", "Pump-ListBad", "Counts", DateTimeOffset.UtcNow)); + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + + Assert.True(response.Found); + Assert.Equal("Bad", response.Quality); + + // The actor must still be alive (no crash / restart) and serving. + ExpectNoTerminated(actor, TimeSpan.FromMilliseconds(500)); + } + + /// + /// MV-8 guard: scalar (non-List) data-sourced attributes keep the existing + /// pass-through behaviour — a scalar value is stored unchanged. + /// + [Fact] + public void InstanceActor_DataSourcedScalarAttribute_UnchangedByListPath() + { + const string tag = "ns=3;s=Pump.Speed"; + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump-Scalar", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Speed", Value = "0", DataType = "Int32", + DataSourceReference = tag, BoundDataConnectionName = "PLC" + } + ] + }; + + var dcl = CreateTestProbe(); + var actor = CreateInstanceActorWithDcl("Pump-Scalar", config, dcl); + + actor.Tell(new TagValueUpdate("PLC", tag, 1450, QualityCode.Good, DateTimeOffset.UtcNow)); + + actor.Tell(new GetAttributeRequest("corr-scalar", "Pump-Scalar", "Speed", DateTimeOffset.UtcNow)); + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + + Assert.True(response.Found); + Assert.Equal("Good", response.Quality); + Assert.Equal(1450, response.Value); + } + + private void ExpectNoTerminated(IActorRef actor, TimeSpan within) + { + // The actor is Watch()ed; assert no Terminated arrives in the window. + ExpectNoMsg(within); + Assert.False(actor.IsNobody()); + } } From 96e817a7e13d9c2aa2f3f10cf0cb831648584cc8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 15:48:25 -0400 Subject: [PATCH 13/25] fix(siteruntime): MV-8 review fixes (construct list inside try; dictionary attr lookup; test hygiene) --- .../Actors/InstanceActor.cs | 43 +++++++++++++----- .../Actors/InstanceActorTests.cs | 45 ++++++++++++++++++- 2 files changed, 76 insertions(+), 12 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs index 21da0ecd..d6424d98 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs @@ -60,6 +60,14 @@ public class InstanceActor : ReceiveActor private readonly Dictionary _latestAlarmEvents = new(); private FlattenedConfiguration? _configuration; + // MV-8: resolved attributes indexed by canonical name. The TagValueUpdate + // ingest path is the highest-frequency message this actor handles, so the + // attribute lookup must be O(1) rather than a linear scan of + // _configuration.Attributes. Built once in the constructor from the + // deserialized configuration (last-wins on duplicate canonical names, + // mirroring the rest of the actor's by-name dictionaries). + private readonly Dictionary _resolvedAttributeByName = new(); + // DCL manager actor reference for subscribing to tag values private readonly IActorRef? _dclManager; // Maps each tag path to every attribute canonical name that references it. @@ -119,6 +127,10 @@ public class InstanceActor : ReceiveActor _attributes[attr.CanonicalName] = attr.Value; _attributeQualities[attr.CanonicalName] = string.IsNullOrEmpty(attr.DataSourceReference) ? "Good" : "Uncertain"; + + // MV-8: index resolved attributes for O(1) lookup on the hot + // TagValueUpdate ingest path (last-wins on duplicate names). + _resolvedAttributeByName[attr.CanonicalName] = attr; } } @@ -439,8 +451,8 @@ public class InstanceActor : ReceiveActor // we resolve and convert per attribute rather than once for the tag. foreach (var attrName in attrNames) { - var resolved = _configuration?.Attributes - .FirstOrDefault(a => a.CanonicalName == attrName); + // MV-8: O(1) lookup off the hot ingest path (was a linear FirstOrDefault). + _resolvedAttributeByName.TryGetValue(attrName, out var resolved); // MV-8: a List-typed attribute coerces the incoming OPC UA array // (a CLR array/IEnumerable from the SDK) into a typed List. On an @@ -510,23 +522,32 @@ public class InstanceActor : ReceiveActor if (incoming is not System.Collections.IEnumerable enumerable || incoming is string) return false; - var clrType = ListElementClrType(elementType); - var list = (System.Collections.IList)Activator.CreateInstance( - typeof(List<>).MakeGenericType(clrType))!; - try { + // Construct the typed list INSIDE the try: although the six valid + // element types resolved by ListElementClrType cannot throw today, + // keeping ListElementClrType / MakeGenericType / CreateInstance inside + // the guarded block means any future change that introduces a throw + // here is caught and turned into a Bad-quality result rather than + // escaping into the actor and tripping supervision. + var clrType = ListElementClrType(elementType); + var list = (System.Collections.IList)Activator.CreateInstance( + typeof(List<>).MakeGenericType(clrType))!; + foreach (var element in enumerable) list.Add(CoerceElement(element, elementType)); + + typedList = list; + return true; } - catch (Exception ex) when (ex is FormatException or InvalidCastException - or OverflowException or ArgumentNullException) + catch (Exception ex) { + // Any coercion / construction failure → Bad quality, never a crash. + _logger.LogWarning(ex, + "Failed to coerce value to List<{Element}> for instance {Instance}; marking quality Bad", + attr.ElementDataType, _instanceUniqueName); return false; } - - typedList = list; - return true; } private static Type ListElementClrType(DataType t) => t switch diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs index ffa04e29..ae7dcfa2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs @@ -583,6 +583,49 @@ public class InstanceActorTests : TestKit, IDisposable ExpectNoTerminated(actor, TimeSpan.FromMilliseconds(500)); } + /// + /// MV-8 design contract: a failed coercion keeps the PRIOR value. A good + /// array is delivered first and stored as a typed list; a subsequent array + /// with a non-coercible element must NOT overwrite that value — the stored + /// value stays the prior list while the quality flips to Bad. + /// + [Fact] + public void InstanceActor_DataSourcedListAttribute_BadCoercion_PreservesPriorValue() + { + const string tag = "ns=3;s=Pump.Keep"; + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump-ListKeep", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Counts", Value = null, + DataType = "List", ElementDataType = "Int32", + DataSourceReference = tag, BoundDataConnectionName = "PLC" + } + ] + }; + + var dcl = CreateTestProbe(); + var actor = CreateInstanceActorWithDcl("Pump-ListKeep", config, dcl); + + // (1) A good array establishes the prior value. + actor.Tell(new TagValueUpdate("PLC", tag, new[] { 1, 2, 3 }, QualityCode.Good, DateTimeOffset.UtcNow)); + + // (2) A second array with a non-coercible element must not overwrite it. + actor.Tell(new TagValueUpdate("PLC", tag, new object[] { 4, "not-a-number", 6 }, QualityCode.Good, DateTimeOffset.UtcNow)); + + // (3) The stored value is still the prior list; quality is Bad. + actor.Tell(new GetAttributeRequest("corr-keep", "Pump-ListKeep", "Counts", DateTimeOffset.UtcNow)); + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + + Assert.True(response.Found); + Assert.Equal("Bad", response.Quality); + var list = Assert.IsType>(response.Value); + Assert.Equal(new[] { 1, 2, 3 }, list); + } + /// /// MV-8 guard: scalar (non-List) data-sourced attributes keep the existing /// pass-through behaviour — a scalar value is stored unchanged. @@ -620,7 +663,7 @@ public class InstanceActorTests : TestKit, IDisposable private void ExpectNoTerminated(IActorRef actor, TimeSpan within) { // The actor is Watch()ed; assert no Terminated arrives in the window. + // (Liveness is also proven by the preceding successful GetAttributeResponse.) ExpectNoMsg(within); - Assert.False(actor.IsNobody()); } } From 6ef6bab26ebd7c7ad184298e55785dd578aedb6f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 15:49:01 -0400 Subject: [PATCH 14/25] =?UTF-8?q?fix(validation):=20MV-5=20review=20nit=20?= =?UTF-8?q?=E2=80=94=20use=20IsNullOrWhiteSpace=20for=20List=20default-val?= =?UTF-8?q?ue=20guard=20(consistency)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Validation/SemanticValidator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs index 3e2849b2..adfee73d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs @@ -312,7 +312,7 @@ public class SemanticValidator } // ── Rule 2: default-value parseability ─────────────────────────── - if (!string.IsNullOrEmpty(attr.Value)) + if (!string.IsNullOrWhiteSpace(attr.Value)) { try { From 7f97780bb30b573ea74750ec41dbeddb791708bb Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 15:52:29 -0400 Subject: [PATCH 15/25] feat(siteruntime): decode static List attributes to typed lists in InstanceActor (load/override/set) --- .../Actors/InstanceActor.cs | 91 ++++++++- .../Actors/InstanceActorTests.cs | 174 ++++++++++++++++++ 2 files changed, 259 insertions(+), 6 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs index d6424d98..62cdafd0 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs @@ -124,13 +124,29 @@ public class InstanceActor : ReceiveActor { foreach (var attr in _configuration.Attributes) { - _attributes[attr.CanonicalName] = attr.Value; - _attributeQualities[attr.CanonicalName] = - string.IsNullOrEmpty(attr.DataSourceReference) ? "Good" : "Uncertain"; - // MV-8: index resolved attributes for O(1) lookup on the hot // TagValueUpdate ingest path (last-wins on duplicate names). _resolvedAttributeByName[attr.CanonicalName] = attr; + + // MV-7: a STATIC List attribute's default is the canonical JSON + // array string. Decode it to a typed List for in-memory reads + // so scripts see a real collection. Scalars store their raw + // string unchanged. A malformed List default decodes to null and + // is marked Bad quality rather than crashing the actor. + if (IsListAttribute(attr)) + { + var decoded = DecodeAttributeValue(attr, attr.Value); + _attributes[attr.CanonicalName] = decoded; + _attributeQualities[attr.CanonicalName] = + decoded is null && !string.IsNullOrEmpty(attr.Value) ? "Bad" + : string.IsNullOrEmpty(attr.DataSourceReference) ? "Good" : "Uncertain"; + } + else + { + _attributes[attr.CanonicalName] = attr.Value; + _attributeQualities[attr.CanonicalName] = + string.IsNullOrEmpty(attr.DataSourceReference) ? "Good" : "Uncertain"; + } } } @@ -319,7 +335,21 @@ public class InstanceActor : ReceiveActor /// private void HandleSetStaticAttributeCore(SetStaticAttributeCommand command) { - _attributes[command.AttributeName] = command.Value; + // MV-7: command.Value is the canonical form — a plain string for scalars, + // a JSON array string for List attributes. For a List attribute we store + // the DECODED typed list in memory (so scripts read a real collection) but + // persist + publish the canonical JSON string UNCHANGED below. Scalars + // store the string verbatim. (HandleSetStaticAttribute already rejected + // unknown attributes, so resolved is non-null here, but guard defensively.) + if (_resolvedAttributeByName.TryGetValue(command.AttributeName, out var resolved) + && IsListAttribute(resolved)) + { + _attributes[command.AttributeName] = DecodeAttributeValue(resolved, command.Value); + } + else + { + _attributes[command.AttributeName] = command.Value; + } // Publish attribute change to stream (WP-23) and notify children var changed = new AttributeValueChanged( @@ -499,6 +529,40 @@ public class InstanceActor : ReceiveActor Enum.TryParse(attr.DataType, ignoreCase: true, out var dt) && dt == DataType.List; + /// + /// MV-7: decodes a STATIC (authored / overridden) attribute's canonical value + /// for in-memory storage. List attributes carry a canonical JSON array string + /// (config default or persisted override) which is decoded via + /// into a typed List<T> + /// so scripts read a real collection; scalars pass through unchanged. This is + /// the authored counterpart to MV-8's (which + /// coerces live OPC UA CLR arrays). An undecodable List value (malformed JSON, + /// bad element, missing element type) degrades to + a + /// warning — the caller marks the attribute Bad quality. NEVER throws into the + /// actor. + /// + private object? DecodeAttributeValue(ResolvedAttribute attr, string? raw) + { + DataType dataType = Enum.TryParse(attr.DataType, ignoreCase: true, out var dt) + ? dt + : DataType.String; + DataType? elementType = string.IsNullOrEmpty(attr.ElementDataType) + ? null + : (Enum.TryParse(attr.ElementDataType, ignoreCase: true, out var et) ? et : null); + + try + { + return AttributeValueCodec.Decode(raw, dataType, elementType); + } + catch (FormatException ex) + { + _logger.LogWarning(ex, + "Attribute '{Attr}' on '{Instance}' has an undecodable List value; marking Bad quality", + attr.CanonicalName, _instanceUniqueName); + return null; // caller marks quality Bad + } + } + /// /// MV-8: coerces an incoming data-sourced value (an OPC UA array / IEnumerable) /// into a typed List<elementClrType> matching the attribute's @@ -825,7 +889,22 @@ public class InstanceActor : ReceiveActor foreach (var kvp in result.Overrides) { - _attributes[kvp.Key] = kvp.Value; + // MV-7: persisted override values are canonical strings — a JSON array + // string for List attributes, a plain string for scalars. Decode List + // overrides to a typed list (matching the config-default load), set + // Bad quality on a malformed stored value, and never crash the actor. + if (_resolvedAttributeByName.TryGetValue(kvp.Key, out var resolved) + && IsListAttribute(resolved)) + { + var decoded = DecodeAttributeValue(resolved, kvp.Value); + _attributes[kvp.Key] = decoded; + if (decoded is null && !string.IsNullOrEmpty(kvp.Value)) + _attributeQualities[kvp.Key] = "Bad"; + } + else + { + _attributes[kvp.Key] = kvp.Value; + } } _logger.LogDebug( diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs index ae7dcfa2..a92ac484 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs @@ -666,4 +666,178 @@ public class InstanceActorTests : TestKit, IDisposable // (Liveness is also proven by the preceding successful GetAttributeResponse.) ExpectNoMsg(within); } + + // ── MV-7: static (authored) List attribute decode ────────────────────── + + /// + /// MV-7: a STATIC List attribute carries its default as the canonical JSON + /// array string. On load the actor must decode it to a typed list so a + /// script reading the attribute receives a real collection, not the raw + /// JSON string. + /// + [Fact] + public void InstanceActor_StaticListAttribute_LoadsAsTypedList() + { + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump-StaticList", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Labels", Value = "[\"a\",\"b\"]", + DataType = "List", ElementDataType = "String" + } + ] + }; + + var actor = CreateInstanceActor("Pump-StaticList", config); + + actor.Tell(new GetAttributeRequest("corr-sl", "Pump-StaticList", "Labels", DateTimeOffset.UtcNow)); + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + + Assert.True(response.Found); + Assert.Equal("Good", response.Quality); + var list = Assert.IsType>(response.Value); + Assert.Equal(new[] { "a", "b" }, list); + } + + /// + /// MV-7: a SetStaticAttribute write on a List attribute decodes the canonical + /// JSON value into a typed list for in-memory reads, but the PERSISTED form + /// (SQLite static override) must remain the canonical JSON string — never a + /// CLR-list .ToString(). + /// + [Fact] + public async Task InstanceActor_SetStaticListAttribute_ReadsTypedList_PersistsJsonString() + { + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump-SetList", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Labels", Value = "[\"a\",\"b\"]", + DataType = "List", ElementDataType = "String" + } + ] + }; + + var actor = CreateInstanceActor("Pump-SetList", config); + + actor.Tell(new SetStaticAttributeCommand( + "corr-set-list", "Pump-SetList", "Labels", "[\"x\",\"y\"]", DateTimeOffset.UtcNow)); + var setResponse = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.True(setResponse.Success); + + // In-memory read returns a typed list. + actor.Tell(new GetAttributeRequest("corr-get-list", "Pump-SetList", "Labels", DateTimeOffset.UtcNow)); + var getResponse = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.True(getResponse.Found); + var list = Assert.IsType>(getResponse.Value); + Assert.Equal(new[] { "x", "y" }, list); + + // The persisted form is the canonical JSON string, NOT a CLR-list .ToString(). + await Task.Delay(500); + var overrides = await _storage.GetStaticOverridesAsync("Pump-SetList"); + Assert.Single(overrides); + Assert.Equal("[\"x\",\"y\"]", overrides["Labels"]); + } + + /// + /// MV-7: a persisted static override for a List attribute is a canonical JSON + /// string in SQLite; on load it must be decoded to a typed list, the same as + /// the config default. + /// + [Fact] + public async Task InstanceActor_StaticListOverride_LoadsAsTypedList() + { + await _storage.SetStaticOverrideAsync("Pump-OverrideList", "Labels", "[\"p\",\"q\"]"); + + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump-OverrideList", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Labels", Value = "[\"a\",\"b\"]", + DataType = "List", ElementDataType = "String" + } + ] + }; + + var actor = CreateInstanceActor("Pump-OverrideList", config); + + // Wait for the async override load (PipeTo) to apply. + await Task.Delay(1000); + + actor.Tell(new GetAttributeRequest("corr-ol", "Pump-OverrideList", "Labels", DateTimeOffset.UtcNow)); + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + + Assert.True(response.Found); + var list = Assert.IsType>(response.Value); + Assert.Equal(new[] { "p", "q" }, list); + } + + /// + /// MV-7: a malformed stored List value must NOT crash the actor — it loads + /// with quality Bad and the actor stays alive and answering. + /// + [Fact] + public void InstanceActor_StaticListAttribute_Malformed_LoadsBadQuality_ActorAlive() + { + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump-BadList", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Labels", Value = "[\"a\"", // truncated JSON + DataType = "List", ElementDataType = "String" + } + ] + }; + + var actor = CreateInstanceActor("Pump-BadList", config); + Watch(actor); + + actor.Tell(new GetAttributeRequest("corr-bl", "Pump-BadList", "Labels", DateTimeOffset.UtcNow)); + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + + Assert.True(response.Found); + Assert.Equal("Bad", response.Quality); + Assert.Null(response.Value); + + // The actor must still be alive (no crash / restart during construction). + ExpectNoTerminated(actor, TimeSpan.FromMilliseconds(500)); + } + + /// + /// MV-7 guard: a scalar static attribute is unaffected by the List decode + /// path — it still returns its raw string value. + /// + [Fact] + public void InstanceActor_StaticScalarAttribute_UnaffectedByListDecode() + { + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump-StaticScalar", + Attributes = + [ + new ResolvedAttribute { CanonicalName = "Label", Value = "Main Pump", DataType = "String" } + ] + }; + + var actor = CreateInstanceActor("Pump-StaticScalar", config); + + actor.Tell(new GetAttributeRequest("corr-ss", "Pump-StaticScalar", "Label", DateTimeOffset.UtcNow)); + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + + Assert.True(response.Found); + Assert.Equal("Good", response.Quality); + Assert.Equal("Main Pump", response.Value); + } } From ad6bfc8af928edadec3e63b2653d0441e774870f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 15:59:30 -0400 Subject: [PATCH 16/25] fix(siteruntime): reject SetStaticAttribute with malformed list value (no silent poison persist) --- .../Actors/InstanceActor.cs | 28 +++++- .../Actors/InstanceActorTests.cs | 88 +++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs index 62cdafd0..a2406255 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs @@ -344,7 +344,33 @@ public class InstanceActor : ReceiveActor if (_resolvedAttributeByName.TryGetValue(command.AttributeName, out var resolved) && IsListAttribute(resolved)) { - _attributes[command.AttributeName] = DecodeAttributeValue(resolved, command.Value); + // MV-7: the script path pre-encodes valid canonical JSON via ScopeAccessors, + // but the Inbound API / direct-command path can submit an arbitrary + // command.Value. A non-empty value that fails to decode (malformed JSON, + // bad element, missing element type) is poison: storing it would null the + // in-memory value yet publish "Good" quality and durably persist the bad + // JSON (which then loads as Bad next restart). Reject such writes outright. + // Note: DecodeAttributeValue returns null for BOTH a null/empty input + // (valid — clearing) AND a malformed non-empty input (invalid). Only the + // latter is rejected, hence the explicit IsNullOrWhiteSpace guard. An empty + // list "[]" decodes to a non-null empty List, so it passes through. + var decoded = DecodeAttributeValue(resolved, command.Value); + if (!string.IsNullOrWhiteSpace(command.Value) && decoded == null) + { + _logger.LogWarning( + "SetAttribute rejected — value for List attribute '{Attribute}' on instance '{Instance}' is not a valid list", + command.AttributeName, _instanceUniqueName); + Sender.Tell(new SetStaticAttributeResponse( + command.CorrelationId, + _instanceUniqueName, + command.AttributeName, + false, + $"Invalid list value for attribute '{command.AttributeName}'", + DateTimeOffset.UtcNow)); + return; + } + + _attributes[command.AttributeName] = decoded; } else { diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs index a92ac484..4c18004b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs @@ -840,4 +840,92 @@ public class InstanceActorTests : TestKit, IDisposable Assert.Equal("Good", response.Quality); Assert.Equal("Main Pump", response.Value); } + + /// + /// MV-7 (review fix): a SetStaticAttribute write whose value fails to decode as + /// a list (e.g. truncated JSON) on a List attribute must be REJECTED — reply + /// Success=false with a clear error and persist NOTHING. The script path always + /// pre-encodes valid JSON, but the Inbound API / direct-command path can submit + /// an arbitrary value, so a malformed value must not silently null the in-memory + /// value, publish "Good" quality, and durably persist a poison override. + /// + [Fact] + public async Task InstanceActor_SetStaticListAttribute_Malformed_Rejected_NotPersisted() + { + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump-BadSet", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Labels", Value = "[\"a\",\"b\"]", + DataType = "List", ElementDataType = "String" + } + ] + }; + + var actor = CreateInstanceActor("Pump-BadSet", config); + + // Submit a malformed list value (truncated JSON). + actor.Tell(new SetStaticAttributeCommand( + "corr-bad-set", "Pump-BadSet", "Labels", "[\"a\"", DateTimeOffset.UtcNow)); + var setResponse = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.False(setResponse.Success); + Assert.False(string.IsNullOrWhiteSpace(setResponse.ErrorMessage)); + + // The poison value must NOT have been persisted as a static override. + await Task.Delay(500); + var overrides = await _storage.GetStaticOverridesAsync("Pump-BadSet"); + Assert.Empty(overrides); + + // A subsequent read returns the untouched config default — not the poison value. + actor.Tell(new GetAttributeRequest("corr-bad-get", "Pump-BadSet", "Labels", DateTimeOffset.UtcNow)); + var getResponse = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.True(getResponse.Found); + var list = Assert.IsType>(getResponse.Value); + Assert.Equal(new[] { "a", "b" }, list); + } + + /// + /// MV-7 (review fix): an empty-list value "[]" decodes to a non-null empty list + /// and must be accepted (NOT mistaken for a malformed value, which also decodes + /// to null). This pins the boundary between the "clearing/empty" and "poison" + /// cases that both surface as a null decode result. + /// + [Fact] + public async Task InstanceActor_SetStaticListAttribute_EmptyList_Accepted() + { + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump-EmptySet", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Labels", Value = "[\"a\",\"b\"]", + DataType = "List", ElementDataType = "String" + } + ] + }; + + var actor = CreateInstanceActor("Pump-EmptySet", config); + + actor.Tell(new SetStaticAttributeCommand( + "corr-empty-set", "Pump-EmptySet", "Labels", "[]", DateTimeOffset.UtcNow)); + var setResponse = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.True(setResponse.Success); + + actor.Tell(new GetAttributeRequest("corr-empty-get", "Pump-EmptySet", "Labels", DateTimeOffset.UtcNow)); + var getResponse = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.True(getResponse.Found); + var list = Assert.IsType>(getResponse.Value); + Assert.Empty(list); + + // The canonical JSON "[]" is persisted unchanged. + await Task.Delay(500); + var overrides = await _storage.GetStaticOverridesAsync("Pump-EmptySet"); + Assert.Single(overrides); + Assert.Equal("[]", overrides["Labels"]); + } } From 1525670fe71b7e6aab7b0a3865a237a34fcd83ec Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 16:05:05 -0400 Subject: [PATCH 17/25] feat(mgmt): accept + validate ElementDataType on attribute add/update --- .../Messages/Management/TemplateCommands.cs | 4 +- .../ManagementActor.cs | 63 +++++++- .../ManagementActorTests.cs | 134 ++++++++++++++++++ 3 files changed, 197 insertions(+), 4 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TemplateCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TemplateCommands.cs index d34dd51a..875d3758 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TemplateCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TemplateCommands.cs @@ -8,8 +8,8 @@ public record DeleteTemplateCommand(int TemplateId); public record ValidateTemplateCommand(int TemplateId); // Template member operations -public record AddTemplateAttributeCommand(int TemplateId, string Name, string DataType, string? Value, string? Description, string? DataSourceReference, bool IsLocked); -public record UpdateTemplateAttributeCommand(int AttributeId, string Name, string DataType, string? Value, string? Description, string? DataSourceReference, bool IsLocked); +public record AddTemplateAttributeCommand(int TemplateId, string Name, string DataType, string? Value, string? Description, string? DataSourceReference, bool IsLocked, string? ElementDataType = null); +public record UpdateTemplateAttributeCommand(int AttributeId, string Name, string DataType, string? Value, string? Description, string? DataSourceReference, bool IsLocked, string? ElementDataType = null); public record DeleteTemplateAttributeCommand(int AttributeId); public record AddTemplateAlarmCommand(int TemplateId, string Name, string TriggerType, int PriorityLevel, string? Description, string? TriggerConfiguration, bool IsLocked); public record UpdateTemplateAlarmCommand(int AlarmId, string Name, string TriggerType, int PriorityLevel, string? Description, string? TriggerConfiguration, bool IsLocked); diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index 8456c69e..a99a0729 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -1442,9 +1442,13 @@ public class ManagementActor : ReceiveActor private static async Task HandleAddAttribute(IServiceProvider sp, AddTemplateAttributeCommand cmd, string user) { var svc = sp.GetRequiredService(); + var dataType = Enum.Parse(cmd.DataType, ignoreCase: true); + var elementType = ParseElementDataType(cmd.ElementDataType); + ValidateAttributeTypes(cmd.Name, dataType, elementType, cmd.Value); var attr = new TemplateAttribute(cmd.Name) { - DataType = Enum.Parse(cmd.DataType, ignoreCase: true), + DataType = dataType, + ElementDataType = elementType, Value = cmd.Value, Description = cmd.Description, DataSourceReference = cmd.DataSourceReference, @@ -1457,9 +1461,13 @@ public class ManagementActor : ReceiveActor private static async Task HandleUpdateAttribute(IServiceProvider sp, UpdateTemplateAttributeCommand cmd, string user) { var svc = sp.GetRequiredService(); + var dataType = Enum.Parse(cmd.DataType, ignoreCase: true); + var elementType = ParseElementDataType(cmd.ElementDataType); + ValidateAttributeTypes(cmd.Name, dataType, elementType, cmd.Value); var attr = new TemplateAttribute(cmd.Name) { - DataType = Enum.Parse(cmd.DataType, ignoreCase: true), + DataType = dataType, + ElementDataType = elementType, Value = cmd.Value, Description = cmd.Description, DataSourceReference = cmd.DataSourceReference, @@ -1469,6 +1477,57 @@ public class ManagementActor : ReceiveActor return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error); } + /// + /// Parses an optional element data type token. Returns null when the token is + /// empty/whitespace; throws on an + /// unrecognised type name. + /// + private static Commons.Types.Enums.DataType? ParseElementDataType(string? elementDataType) + { + if (string.IsNullOrWhiteSpace(elementDataType)) return null; + if (!Enum.TryParse(elementDataType, ignoreCase: true, out var parsed)) + throw new ManagementCommandException($"Unrecognised element type '{elementDataType}'."); + return parsed; + } + + /// + /// Validates the (DataType, ElementDataType, Value) triple shared by the add + /// and update attribute handlers. Throws + /// on any violation: + /// + /// List attributes require a valid scalar element type. + /// Scalar attributes may not carry an element type. + /// A List default value must decode against the declared element type. + /// + /// + private static void ValidateAttributeTypes( + string name, Commons.Types.Enums.DataType dataType, Commons.Types.Enums.DataType? elementType, string? value) + { + if (dataType == Commons.Types.Enums.DataType.List) + { + if (elementType is null || !Commons.Types.AttributeValueCodec.IsValidElementType(elementType.Value)) + throw new ManagementCommandException( + $"List attribute '{name}' requires a valid element type (String, Int32, Float, Double, Boolean, DateTime)."); + + if (!string.IsNullOrWhiteSpace(value)) + { + try + { + Commons.Types.AttributeValueCodec.Decode(value, Commons.Types.Enums.DataType.List, elementType); + } + catch (FormatException ex) + { + throw new ManagementCommandException( + $"List attribute '{name}' has an invalid list value: {ex.Message}"); + } + } + } + else if (elementType is not null) + { + throw new ManagementCommandException("Element type is only valid on List attributes."); + } + } + private static async Task HandleDeleteAttribute(IServiceProvider sp, DeleteTemplateAttributeCommand cmd, string user) { var svc = sp.GetRequiredService(); diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs index 770fa3dc..da5eea7b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs @@ -387,6 +387,140 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Contains("Designer", response.Message); } + // ======================================================================== + // MV-10: ElementDataType accept + validate on attribute add/update + // ======================================================================== + + [Fact] + public void AddListAttribute_WithStringElementType_PersistsBothColumns() + { + // A template exists with no attributes; AddAttributeAsync will save the + // entity built by the handler. Capture it to assert the persisted shape. + var template = new Template("T1") { Id = 1 }; + _templateRepo.GetTemplateByIdAsync(1, Arg.Any()).Returns(template); + _templateRepo.GetAllTemplatesAsync(Arg.Any()) + .Returns(new List