fix(tagconfig): close the four #551 residuals — accept-gate-first Inspect, three authoring rules, CSV/service gate, doc sweep
Gitea #551. 1. CSV import ran no driver-typed config gate. Its typed columns were always strictly parsed, but the free-form TagConfigJson column reached the database unread, so a CSV could author a tag the driver rejects — one that deploys green and reads BadNodeIdUnknown forever. RawTagCsvMapper.Parse now names the offending row in the staged review grid, and RawTreeService enforces the same gate on CreateTag/UpdateTag/ImportTags: a form cannot be the guarantee (the API and any future caller reach none of them, and this repo has no bUnit). A rejected import row is an ERROR — the batch inserts nothing and the row is named — never a skip. Note the brief's premise that manual entry rides ImportTagsAsync is wrong: RawManualTagEntryModal commits row-by-row via CreateTagAsync, which is why the gate had to land on all three write paths. 2/3. The seven pre-#549 Inspect methods ran a hand-list of DescribeInvalidEnum checks, so every rejection cause not on the list was invisible. They are now ACCEPT-GATE-FIRST (the MTConnect/Sql/OpcUaClient/Calculation shape): ask the driver's own reader for the verdict, then explain the refusal, with a catch-all so "the parse rejects but Inspect said nothing" is unreachable. That closes S7's absent dataType, AbCip members[], Modbus stringByteOrder and the type-dependent guards, plus causes the issue did not name (absent region/address/tagPath/symbolPath/topic, TwinCAT Structure, MQTT's Sparkplug tuple). FOCAS and MQTT keep advisories about ACCEPTED blobs, so they diagnose rather than return empty. Authoring half: ModbusTagConfigModel gains stringByteOrder; AbCipTagConfigModel asks the new AbCipTagDefinitionFactory.DescribeInvalidMember; S7TagConfigModel captures whether the SOURCE blob declared dataType and asks S7TagDefinitionFactory.DataTypeKey/DeclaresDataType. One authority per rule. 4. Doc sweep: the six factory Inspect summaries, EquipmentTagConfigInspector's class remarks and AdminOperationsActor's comment all claimed invalid enums are "silently defaulted by the lenient runtime" — false since R2-11 Phase C made the runtime reads strict. They describe rejections now.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
@@ -834,6 +835,9 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
t => t.DeviceId == deviceId && t.TagGroupId == tagGroupId && t.Name == input.Name, ct))
|
||||
return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists here.");
|
||||
|
||||
if (await ValidateTagConfigAsync(db, deviceId, tagConfig, ct) is { } configError)
|
||||
return new UnsMutationResult(false, configError);
|
||||
|
||||
var id = NewId("TAG");
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
@@ -872,6 +876,9 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
&& t.Name == input.Name && t.TagId != tagId, ct))
|
||||
return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists here.");
|
||||
|
||||
if (await ValidateTagConfigAsync(db, entity.DeviceId, tagConfig, ct) is { } configError)
|
||||
return new UnsMutationResult(false, configError);
|
||||
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
||||
entity.Name = input.Name;
|
||||
entity.DataType = input.DataType;
|
||||
@@ -902,6 +909,10 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
if (!await db.Devices.AnyAsync(dev => dev.DeviceId == deviceId, ct))
|
||||
return new RawTagImportResult(0, new[] { $"Device '{deviceId}' not found." });
|
||||
|
||||
// The device's driver type, resolved ONCE: an import is per-device, and a device hangs off exactly
|
||||
// one driver, so every row in the batch is judged by the same driver's rules (Gitea #551 item 1).
|
||||
var driverType = await ResolveDriverTypeAsync(db, deviceId, ct);
|
||||
|
||||
// Existing groups on the device: a (parent-group-id | null, name) → group-id lookup we extend with
|
||||
// pending (auto-created) groups so a repeated path within the batch resolves to one group.
|
||||
var groupLookup = (await db.TagGroups.AsNoTracking()
|
||||
@@ -951,6 +962,15 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
continue;
|
||||
}
|
||||
|
||||
// The driver-typed config gate. A rejected row is an ERROR — the import is all-or-nothing, so
|
||||
// the batch inserts nothing and the row is NAMED; it is never skipped ("skipped" would claim
|
||||
// the tag is already present). See ValidateTagConfigAsync for why this lives in the service.
|
||||
if (TagConfigValidator.Validate(driverType, tagConfig) is { } configError)
|
||||
{
|
||||
errors.Add($"{rowLabel}: TagConfig: {configError}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!takenNames.Add((groupId, row.Tag.Name)))
|
||||
{
|
||||
errors.Add($"{rowLabel}: a tag named '{row.Tag.Name}' already exists at that path.");
|
||||
@@ -1196,6 +1216,55 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The driver-type string of the driver a device hangs off, or <see langword="null"/> when the
|
||||
/// device (or its driver) is gone. Joined in exactly as <c>LoadDeviceForEditAsync</c> does — a
|
||||
/// device carries no driver type of its own.
|
||||
/// </summary>
|
||||
/// <param name="db">The open context.</param>
|
||||
/// <param name="deviceId">The device whose driver type to resolve.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The canonical driver-type string, or null.</returns>
|
||||
private static Task<string?> ResolveDriverTypeAsync(
|
||||
OtOpcUaConfigDbContext db, string deviceId, CancellationToken ct)
|
||||
=> db.Devices.AsNoTracking()
|
||||
.Where(dev => dev.DeviceId == deviceId)
|
||||
.Join(db.DriverInstances.AsNoTracking(),
|
||||
dev => dev.DriverInstanceId,
|
||||
drv => drv.DriverInstanceId,
|
||||
(dev, drv) => drv.DriverType)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
/// <summary>
|
||||
/// The driver-typed <c>TagConfig</c> gate, applied on the SERVER for every tag-writing path —
|
||||
/// create, update and bulk import alike (Gitea #551 item 1).
|
||||
/// <para>
|
||||
/// <b>It lives here rather than in the modals because a form cannot be the guarantee.</b> The
|
||||
/// tag modal and the manual-entry grid already run <see cref="TagConfigValidator"/> for fast
|
||||
/// feedback, but the CSV import did not, and the API and any future caller reach none of them —
|
||||
/// so a blob the driver rejects could be persisted, deploy clean, and read
|
||||
/// <c>BadNodeIdUnknown</c> forever. This repo also has no bUnit, so a rule only a page enforces
|
||||
/// is a rule no test can reach.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The rule itself is not stated here: <see cref="TagConfigValidator"/> dispatches to the
|
||||
/// driver's typed model, which in turn asks the driver's own reader. An unmapped driver type
|
||||
/// (Galaxy) validates to null, exactly as it does in the modals.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="db">The open context.</param>
|
||||
/// <param name="deviceId">The device the tag belongs to (supplies the driver type).</param>
|
||||
/// <param name="tagConfig">The normalised TagConfig JSON about to be persisted.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>An operator-facing error, or <see langword="null"/> when the config is acceptable.</returns>
|
||||
private static async Task<string?> ValidateTagConfigAsync(
|
||||
OtOpcUaConfigDbContext db, string deviceId, string tagConfig, CancellationToken ct)
|
||||
{
|
||||
var driverType = await ResolveDriverTypeAsync(db, deviceId, ct);
|
||||
var error = TagConfigValidator.Validate(driverType, tagConfig);
|
||||
return error is null ? null : $"TagConfig: {error}";
|
||||
}
|
||||
|
||||
/// <summary>Ordinal (case-sensitive) equality for a nullable-parent + name tag-group key.</summary>
|
||||
private sealed class GroupKeyComparer : IEqualityComparer<(string? Parent, string Name)>
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user