From e7e34b26f19b5764e1cfdbb600766a8d946ddec2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 15:23:39 -0400 Subject: [PATCH] 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); + } }