diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs index a93d34bc..f05852d9 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs @@ -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, diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs index 22c7c31b..88b66573 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs @@ -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, diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/ImportValueNormalizer.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/ImportValueNormalizer.cs new file mode 100644 index 00000000..e4211ae1 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/ImportValueNormalizer.cs @@ -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; + +/// +/// Import-time normalization of attribute values to the native-typed JSON form. +/// +/// Bundles exported before the native-typed-JSON change carry List attribute +/// values in the old quoted-element form (e.g. ["10","20"] for an +/// 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 so imported +/// data lands native ([10,20]). The central DB normalizer remains the +/// backstop for anything that slips through. +/// +/// +/// 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. +/// +/// +internal static class ImportValueNormalizer +{ + /// + /// Returns the native-typed JSON form of a List attribute value, or the + /// value unchanged for non-List / null / empty / malformed inputs. + /// + /// The attribute value as carried by the bundle DTO. + /// The attribute's declared data type. + /// The List element type (null for scalars). + 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; + } + } +} 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 b4fefbb0..51f8ab0c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs @@ -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(), + Scripts: Array.Empty(), + Compositions: Array.Empty()); + + return new BundleContentDto( + TemplateFolders: Array.Empty(), + Templates: new[] { template }, + SharedScripts: Array.Empty(), + ExternalSystems: Array.Empty(), + DatabaseConnections: Array.Empty(), + NotificationLists: Array.Empty(), + SmtpConfigs: Array.Empty(), + ApiMethods: Array.Empty()); + } + + [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(), + Scripts: Array.Empty(), + Compositions: Array.Empty()); + var dto = new BundleContentDto( + TemplateFolders: Array.Empty(), + Templates: new[] { template }, + SharedScripts: Array.Empty(), + ExternalSystems: Array.Empty(), + DatabaseConnections: Array.Empty(), + NotificationLists: Array.Empty(), + SmtpConfigs: Array.Empty(), + ApiMethods: Array.Empty()); + + 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() {