feat(transport): normalize List attribute values to native JSON on import
This commit is contained in:
@@ -1040,7 +1040,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
{
|
||||
t.Attributes.Add(new TemplateAttribute(a.Name)
|
||||
{
|
||||
Value = a.Value,
|
||||
Value = ImportValueNormalizer.NormalizeListValue(a.Value, a.DataType, a.ElementDataType),
|
||||
DataType = a.DataType,
|
||||
IsLocked = a.IsLocked,
|
||||
Description = a.Description,
|
||||
@@ -1115,11 +1115,17 @@ public sealed class BundleImporter : IBundleImporter
|
||||
// Adds + Updates.
|
||||
foreach (var attrDto in dto.Attributes)
|
||||
{
|
||||
// Normalise List values to the native-typed JSON form on import so the
|
||||
// comparison (and the persisted value) match what the target already
|
||||
// stores natively — otherwise an idempotent re-import of an old-form
|
||||
// bundle would spuriously report a Value change.
|
||||
var normalizedValue = ImportValueNormalizer.NormalizeListValue(
|
||||
attrDto.Value, attrDto.DataType, attrDto.ElementDataType);
|
||||
if (existingByName.TryGetValue(attrDto.Name, out var current))
|
||||
{
|
||||
// Update only if any field actually changed.
|
||||
bool changed =
|
||||
!string.Equals(current.Value, attrDto.Value, StringComparison.Ordinal) ||
|
||||
!string.Equals(current.Value, normalizedValue, StringComparison.Ordinal) ||
|
||||
current.DataType != attrDto.DataType ||
|
||||
current.IsLocked != attrDto.IsLocked ||
|
||||
!string.Equals(current.Description, attrDto.Description, StringComparison.Ordinal) ||
|
||||
@@ -1127,7 +1133,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
current.ElementDataType != attrDto.ElementDataType;
|
||||
if (!changed) continue;
|
||||
|
||||
current.Value = attrDto.Value;
|
||||
current.Value = normalizedValue;
|
||||
current.DataType = attrDto.DataType;
|
||||
current.IsLocked = attrDto.IsLocked;
|
||||
current.Description = attrDto.Description;
|
||||
@@ -1157,7 +1163,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
{
|
||||
var newAttr = new TemplateAttribute(attrDto.Name)
|
||||
{
|
||||
Value = attrDto.Value,
|
||||
Value = normalizedValue,
|
||||
DataType = attrDto.DataType,
|
||||
IsLocked = attrDto.IsLocked,
|
||||
Description = attrDto.Description,
|
||||
|
||||
@@ -199,7 +199,7 @@ public sealed class EntitySerializer
|
||||
t.Attributes.Add(new TemplateAttribute(a.Name)
|
||||
{
|
||||
TemplateId = t.Id,
|
||||
Value = a.Value,
|
||||
Value = ImportValueNormalizer.NormalizeListValue(a.Value, a.DataType, a.ElementDataType),
|
||||
DataType = a.DataType,
|
||||
IsLocked = a.IsLocked,
|
||||
Description = a.Description,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Import-time normalization of attribute values to the native-typed JSON form.
|
||||
/// <para>
|
||||
/// Bundles exported before the native-typed-JSON change carry List attribute
|
||||
/// values in the old quoted-element form (e.g. <c>["10","20"]</c> for an
|
||||
/// <see cref="DataType.Int32"/> list). Already-exported bundle files can't be
|
||||
/// rewritten, so List values are normalised on import: every DTO→entity write
|
||||
/// site routes the value through <see cref="NormalizeListValue"/> so imported
|
||||
/// data lands native (<c>[10,20]</c>). The central DB normalizer remains the
|
||||
/// backstop for anything that slips through.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Non-List attributes and null/empty values pass through unchanged. A value
|
||||
/// that fails to decode (malformed JSON / un-parseable element) is left exactly
|
||||
/// as-is so the import still succeeds — the DB normalizer is the backstop.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class ImportValueNormalizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the native-typed JSON form of a List attribute value, or the
|
||||
/// value unchanged for non-List / null / empty / malformed inputs.
|
||||
/// </summary>
|
||||
/// <param name="value">The attribute value as carried by the bundle DTO.</param>
|
||||
/// <param name="dataType">The attribute's declared data type.</param>
|
||||
/// <param name="elementType">The List element type (null for scalars).</param>
|
||||
public static string? NormalizeListValue(string? value, DataType dataType, DataType? elementType)
|
||||
{
|
||||
if (dataType != DataType.List || string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return AttributeValueCodec.Encode(
|
||||
AttributeValueCodec.Decode(value, DataType.List, elementType));
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// Leave malformed values exactly as imported; the DB normalizer is
|
||||
// the backstop. Never abort the import for a single bad value.
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -259,6 +259,122 @@ public sealed class EntitySerializerTests
|
||||
Assert.Equal("[\"a\",\"b\"]", rtAttr.Value);
|
||||
}
|
||||
|
||||
private static BundleContentDto MakeContentWithListAttribute(
|
||||
string value, DataType elementType)
|
||||
{
|
||||
var template = new TemplateDto(
|
||||
Name: "Pump",
|
||||
FolderName: null,
|
||||
BaseTemplateName: null,
|
||||
Description: null,
|
||||
Attributes: new[]
|
||||
{
|
||||
new TemplateAttributeDto(
|
||||
Name: "Tags",
|
||||
Value: value,
|
||||
DataType: DataType.List,
|
||||
IsLocked: false,
|
||||
Description: null,
|
||||
DataSourceReference: null,
|
||||
ElementDataType: elementType),
|
||||
},
|
||||
Alarms: Array.Empty<TemplateAlarmDto>(),
|
||||
Scripts: Array.Empty<TemplateScriptDto>(),
|
||||
Compositions: Array.Empty<TemplateCompositionDto>());
|
||||
|
||||
return new BundleContentDto(
|
||||
TemplateFolders: Array.Empty<TemplateFolderDto>(),
|
||||
Templates: new[] { template },
|
||||
SharedScripts: Array.Empty<SharedScriptDto>(),
|
||||
ExternalSystems: Array.Empty<ExternalSystemDto>(),
|
||||
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
|
||||
NotificationLists: Array.Empty<NotificationListDto>(),
|
||||
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
|
||||
ApiMethods: Array.Empty<ApiMethodDto>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Import_normalizes_old_form_Int32_list_value_to_native_json()
|
||||
{
|
||||
// Pre-native bundle: quoted Int32 list elements.
|
||||
var dto = MakeContentWithListAttribute("[\"10\",\"20\"]", DataType.Int32);
|
||||
|
||||
var aggregate = new EntitySerializer().FromBundleContent(dto);
|
||||
|
||||
var attr = Assert.Single(Assert.Single(aggregate.Templates).Attributes);
|
||||
Assert.Equal(DataType.List, attr.DataType);
|
||||
Assert.Equal(DataType.Int32, attr.ElementDataType);
|
||||
// Imported native: numbers unquoted.
|
||||
Assert.Equal("[10,20]", attr.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Import_leaves_string_list_value_quoted()
|
||||
{
|
||||
var dto = MakeContentWithListAttribute("[\"a\",\"b\"]", DataType.String);
|
||||
|
||||
var aggregate = new EntitySerializer().FromBundleContent(dto);
|
||||
|
||||
var attr = Assert.Single(Assert.Single(aggregate.Templates).Attributes);
|
||||
Assert.Equal(DataType.List, attr.DataType);
|
||||
Assert.Equal(DataType.String, attr.ElementDataType);
|
||||
// Strings stay quoted in native form.
|
||||
Assert.Equal("[\"a\",\"b\"]", attr.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Import_leaves_malformed_list_value_unchanged_without_throwing()
|
||||
{
|
||||
// Truncated JSON array — Decode throws FormatException; the import must
|
||||
// still succeed and carry the value through verbatim (DB normalizer is
|
||||
// the backstop).
|
||||
var dto = MakeContentWithListAttribute("[\"a\"", DataType.String);
|
||||
|
||||
var aggregate = new EntitySerializer().FromBundleContent(dto);
|
||||
|
||||
var attr = Assert.Single(Assert.Single(aggregate.Templates).Attributes);
|
||||
Assert.Equal("[\"a\"", attr.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Import_leaves_scalar_attribute_value_unchanged()
|
||||
{
|
||||
var template = new TemplateDto(
|
||||
Name: "Sensor",
|
||||
FolderName: null,
|
||||
BaseTemplateName: null,
|
||||
Description: null,
|
||||
Attributes: new[]
|
||||
{
|
||||
new TemplateAttributeDto(
|
||||
Name: "Pressure",
|
||||
Value: "42.0",
|
||||
DataType: DataType.Double,
|
||||
IsLocked: false,
|
||||
Description: null,
|
||||
DataSourceReference: null,
|
||||
ElementDataType: null),
|
||||
},
|
||||
Alarms: Array.Empty<TemplateAlarmDto>(),
|
||||
Scripts: Array.Empty<TemplateScriptDto>(),
|
||||
Compositions: Array.Empty<TemplateCompositionDto>());
|
||||
var dto = new BundleContentDto(
|
||||
TemplateFolders: Array.Empty<TemplateFolderDto>(),
|
||||
Templates: new[] { template },
|
||||
SharedScripts: Array.Empty<SharedScriptDto>(),
|
||||
ExternalSystems: Array.Empty<ExternalSystemDto>(),
|
||||
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
|
||||
NotificationLists: Array.Empty<NotificationListDto>(),
|
||||
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
|
||||
ApiMethods: Array.Empty<ApiMethodDto>());
|
||||
|
||||
var aggregate = new EntitySerializer().FromBundleContent(dto);
|
||||
|
||||
var attr = Assert.Single(Assert.Single(aggregate.Templates).Attributes);
|
||||
Assert.Equal(DataType.Double, attr.DataType);
|
||||
Assert.Equal("42.0", attr.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_scalar_attribute_with_null_ElementDataType_remains_null()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user