diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/ConfigurationValidator.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/ConfigurationValidator.cs index a172c56..7cb06f4 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/ConfigurationValidator.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/ConfigurationValidator.cs @@ -48,9 +48,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration } // Galaxy Repository - Log.Information("GalaxyRepository.ConnectionString={ConnectionString}, ChangeDetectionInterval={ChangeInterval}s, CommandTimeout={CmdTimeout}s", + Log.Information("GalaxyRepository.ConnectionString={ConnectionString}, ChangeDetectionInterval={ChangeInterval}s, CommandTimeout={CmdTimeout}s, ExtendedAttributes={ExtendedAttributes}", config.GalaxyRepository.ConnectionString, config.GalaxyRepository.ChangeDetectionIntervalSeconds, - config.GalaxyRepository.CommandTimeoutSeconds); + config.GalaxyRepository.CommandTimeoutSeconds, config.GalaxyRepository.ExtendedAttributes); if (string.IsNullOrWhiteSpace(config.GalaxyRepository.ConnectionString)) { diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/GalaxyRepositoryConfiguration.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/GalaxyRepositoryConfiguration.cs index 468fc4d..446792f 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/GalaxyRepositoryConfiguration.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/GalaxyRepositoryConfiguration.cs @@ -8,5 +8,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration public string ConnectionString { get; set; } = "Server=localhost;Database=ZB;Integrated Security=true;"; public int ChangeDetectionIntervalSeconds { get; set; } = 30; public int CommandTimeoutSeconds { get; set; } = 30; + public bool ExtendedAttributes { get; set; } = false; } } diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/GalaxyAttributeInfo.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/GalaxyAttributeInfo.cs index 632701b..8b323ca 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/GalaxyAttributeInfo.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/GalaxyAttributeInfo.cs @@ -13,5 +13,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain public string DataTypeName { get; set; } = ""; public bool IsArray { get; set; } public int? ArrayDimension { get; set; } + public string PrimitiveName { get; set; } = ""; + public string AttributeSource { get; set; } = ""; } } diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/GalaxyRepositoryService.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/GalaxyRepositoryService.cs index 93e434c..db87f9d 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/GalaxyRepositoryService.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/GalaxyRepositoryService.cs @@ -92,6 +92,112 @@ WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26) AND da.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24) ORDER BY g.tag_name, da.attribute_name"; + private const string ExtendedAttributesSql = @" +;WITH template_chain AS ( + SELECT g.gobject_id, g.derived_from_gobject_id, 0 AS depth + FROM gobject g + WHERE g.is_template = 0 + UNION ALL + SELECT tc.gobject_id, t.derived_from_gobject_id, tc.depth + 1 + FROM template_chain tc + INNER JOIN gobject t ON t.gobject_id = tc.derived_from_gobject_id + WHERE tc.derived_from_gobject_id <> 0 AND tc.depth < 10 +) +SELECT DISTINCT + gobject_id, + tag_name, + primitive_name, + attribute_name, + full_tag_reference, + mx_data_type, + data_type_name, + is_array, + array_dimension, + mx_attribute_category, + security_classification, + attribute_source +FROM ( + SELECT + g.gobject_id, + g.tag_name, + pi.primitive_name, + ad.attribute_name, + CASE WHEN pi.primitive_name = '' + THEN g.tag_name + '.' + ad.attribute_name + ELSE g.tag_name + '.' + pi.primitive_name + '.' + ad.attribute_name + END + CASE WHEN ad.is_array = 1 THEN '[]' ELSE '' END + AS full_tag_reference, + ad.mx_data_type, + dt.description AS data_type_name, + ad.is_array, + CASE WHEN ad.is_array = 1 + THEN CONVERT(int, CONVERT(varbinary(2), + SUBSTRING(ad.mx_value, 15, 2) + SUBSTRING(ad.mx_value, 13, 2), 2)) + ELSE NULL + END AS array_dimension, + ad.mx_attribute_category, + ad.security_classification, + 'primitive' AS attribute_source + FROM gobject g + INNER JOIN instance i + ON i.gobject_id = g.gobject_id + INNER JOIN template_definition td + ON td.template_definition_id = g.template_definition_id + AND td.runtime_clsid <> '{00000000-0000-0000-0000-000000000000}' + INNER JOIN package p + ON p.package_id = g.checked_in_package_id + INNER JOIN primitive_instance pi + ON pi.package_id = p.package_id + AND pi.property_bitmask & 0x10 <> 0x10 + INNER JOIN attribute_definition ad + ON ad.primitive_definition_id = pi.primitive_definition_id + AND ad.attribute_name NOT LIKE '[_]%' + AND ad.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24) + LEFT JOIN data_type dt + ON dt.mx_data_type = ad.mx_data_type + WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26) + AND g.is_template = 0 + AND g.deployed_package_id <> 0 + + UNION ALL + + SELECT + g.gobject_id, + g.tag_name, + '' AS primitive_name, + da.attribute_name, + g.tag_name + '.' + da.attribute_name + + CASE WHEN da.is_array = 1 THEN '[]' ELSE '' END + AS full_tag_reference, + da.mx_data_type, + dt.description AS data_type_name, + da.is_array, + CASE WHEN da.is_array = 1 + THEN CONVERT(int, CONVERT(varbinary(2), + SUBSTRING(da.mx_value, 15, 2) + SUBSTRING(da.mx_value, 13, 2), 2)) + ELSE NULL + END AS array_dimension, + da.mx_attribute_category, + da.security_classification, + 'dynamic' AS attribute_source + FROM template_chain tc + INNER JOIN dynamic_attribute da + ON da.gobject_id = tc.derived_from_gobject_id + INNER JOIN gobject g + ON g.gobject_id = tc.gobject_id + INNER JOIN template_definition td + ON td.template_definition_id = g.template_definition_id + LEFT JOIN data_type dt + ON dt.mx_data_type = da.mx_data_type + WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26) + AND g.is_template = 0 + AND g.deployed_package_id <> 0 + AND da.attribute_name NOT LIKE '[_]%' + AND da.attribute_name NOT LIKE '%.Description' + AND da.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24) +) all_attributes +ORDER BY tag_name, primitive_name, attribute_name"; + private const string ChangeDetectionSql = "SELECT time_of_last_deploy FROM galaxy"; private const string TestConnectionSql = "SELECT 1"; @@ -137,32 +243,67 @@ ORDER BY g.tag_name, da.attribute_name"; public async Task> GetAttributesAsync(CancellationToken ct = default) { var results = new List(); + var extended = _config.ExtendedAttributes; + var sql = extended ? ExtendedAttributesSql : AttributesSql; using var conn = new SqlConnection(_config.ConnectionString); await conn.OpenAsync(ct); - using var cmd = new SqlCommand(AttributesSql, conn) { CommandTimeout = _config.CommandTimeoutSeconds }; + using var cmd = new SqlCommand(sql, conn) { CommandTimeout = _config.CommandTimeoutSeconds }; using var reader = await cmd.ExecuteReaderAsync(ct); while (await reader.ReadAsync(ct)) { - results.Add(new GalaxyAttributeInfo - { - GobjectId = reader.GetInt32(0), - TagName = reader.GetString(1), - AttributeName = reader.GetString(2), - FullTagReference = reader.GetString(3), - MxDataType = Convert.ToInt32(reader.GetValue(4)), - DataTypeName = reader.IsDBNull(5) ? "" : reader.GetString(5), - IsArray = Convert.ToBoolean(reader.GetValue(6)), - ArrayDimension = reader.IsDBNull(7) ? null : (int?)Convert.ToInt32(reader.GetValue(7)) - }); + results.Add(extended ? ReadExtendedAttribute(reader) : ReadStandardAttribute(reader)); } - Log.Information("GetAttributesAsync returned {Count} attributes", results.Count); + Log.Information("GetAttributesAsync returned {Count} attributes (extended={Extended})", results.Count, extended); return results; } + /// + /// Reads a row from the standard attributes query (10 columns). + /// Columns: gobject_id, tag_name, attribute_name, full_tag_reference, mx_data_type, + /// data_type_name, is_array, array_dimension, mx_attribute_category, security_classification + /// + private static GalaxyAttributeInfo ReadStandardAttribute(SqlDataReader reader) + { + return new GalaxyAttributeInfo + { + GobjectId = Convert.ToInt32(reader.GetValue(0)), + TagName = reader.GetString(1), + AttributeName = reader.GetString(2), + FullTagReference = reader.GetString(3), + MxDataType = Convert.ToInt32(reader.GetValue(4)), + DataTypeName = reader.IsDBNull(5) ? "" : reader.GetString(5), + IsArray = Convert.ToBoolean(reader.GetValue(6)), + ArrayDimension = reader.IsDBNull(7) ? null : (int?)Convert.ToInt32(reader.GetValue(7)) + }; + } + + /// + /// Reads a row from the extended attributes query (12 columns). + /// Columns: gobject_id, tag_name, primitive_name, attribute_name, full_tag_reference, + /// mx_data_type, data_type_name, is_array, array_dimension, + /// mx_attribute_category, security_classification, attribute_source + /// + private static GalaxyAttributeInfo ReadExtendedAttribute(SqlDataReader reader) + { + return new GalaxyAttributeInfo + { + GobjectId = Convert.ToInt32(reader.GetValue(0)), + TagName = reader.GetString(1), + PrimitiveName = reader.IsDBNull(2) ? "" : reader.GetString(2), + AttributeName = reader.GetString(3), + FullTagReference = reader.GetString(4), + MxDataType = Convert.ToInt32(reader.GetValue(5)), + DataTypeName = reader.IsDBNull(6) ? "" : reader.GetString(6), + IsArray = Convert.ToBoolean(reader.GetValue(7)), + ArrayDimension = reader.IsDBNull(8) ? null : (int?)Convert.ToInt32(reader.GetValue(8)), + AttributeSource = reader.IsDBNull(11) ? "" : reader.GetString(11) + }; + } + public async Task GetLastDeployTimeAsync(CancellationToken ct = default) { using var conn = new SqlConnection(_config.ConnectionString); diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json b/src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json index 29c9623..815f771 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json @@ -22,7 +22,8 @@ "GalaxyRepository": { "ConnectionString": "Server=localhost;Database=ZB;Integrated Security=true;", "ChangeDetectionIntervalSeconds": 30, - "CommandTimeoutSeconds": 30 + "CommandTimeoutSeconds": 30, + "ExtendedAttributes": false }, "Dashboard": { "Enabled": true, diff --git a/tests/ZB.MOM.WW.LmxOpcUa.IntegrationTests/GalaxyRepositoryServiceTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.IntegrationTests/GalaxyRepositoryServiceTests.cs new file mode 100644 index 0000000..5a43d1c --- /dev/null +++ b/tests/ZB.MOM.WW.LmxOpcUa.IntegrationTests/GalaxyRepositoryServiceTests.cs @@ -0,0 +1,103 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Shouldly; +using Xunit; +using ZB.MOM.WW.LmxOpcUa.Host.Configuration; +using ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository; + +namespace ZB.MOM.WW.LmxOpcUa.IntegrationTests +{ + public class GalaxyRepositoryServiceTests + { + private static GalaxyRepositoryConfiguration LoadConfig(bool extendedAttributes = false) + { + var configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.test.json", optional: false) + .Build(); + + var config = new GalaxyRepositoryConfiguration(); + configuration.GetSection("GalaxyRepository").Bind(config); + config.ExtendedAttributes = extendedAttributes; + return config; + } + + [Fact] + public async Task GetAttributesAsync_StandardMode_ReturnsRows() + { + var config = LoadConfig(extendedAttributes: false); + var service = new GalaxyRepositoryService(config); + + var results = await service.GetAttributesAsync(); + + results.ShouldNotBeEmpty(); + // Standard mode: PrimitiveName and AttributeSource should be empty + results.ShouldAllBe(r => r.PrimitiveName == "" && r.AttributeSource == ""); + } + + [Fact] + public async Task GetAttributesAsync_ExtendedMode_ReturnsMoreRows() + { + var standardConfig = LoadConfig(extendedAttributes: false); + var extendedConfig = LoadConfig(extendedAttributes: true); + var standardService = new GalaxyRepositoryService(standardConfig); + var extendedService = new GalaxyRepositoryService(extendedConfig); + + var standardResults = await standardService.GetAttributesAsync(); + var extendedResults = await extendedService.GetAttributesAsync(); + + extendedResults.Count.ShouldBeGreaterThan(standardResults.Count); + } + + [Fact] + public async Task GetAttributesAsync_ExtendedMode_IncludesPrimitiveAttributes() + { + var config = LoadConfig(extendedAttributes: true); + var service = new GalaxyRepositoryService(config); + + var results = await service.GetAttributesAsync(); + + results.ShouldContain(r => r.AttributeSource == "primitive"); + results.ShouldContain(r => r.AttributeSource == "dynamic"); + } + + [Fact] + public async Task GetAttributesAsync_ExtendedMode_PrimitiveNamePopulated() + { + var config = LoadConfig(extendedAttributes: true); + var service = new GalaxyRepositoryService(config); + + var results = await service.GetAttributesAsync(); + + // Some primitive attributes have non-empty primitive names + // (though many have empty primitive_name for the root UDO) + results.ShouldNotBeEmpty(); + // All should have an attribute source + results.ShouldAllBe(r => r.AttributeSource == "primitive" || r.AttributeSource == "dynamic"); + } + + [Fact] + public async Task GetAttributesAsync_StandardMode_AllHaveFullTagReference() + { + var config = LoadConfig(extendedAttributes: false); + var service = new GalaxyRepositoryService(config); + + var results = await service.GetAttributesAsync(); + + results.ShouldAllBe(r => !string.IsNullOrEmpty(r.FullTagReference)); + results.ShouldAllBe(r => r.FullTagReference.Contains(".")); + } + + [Fact] + public async Task GetAttributesAsync_ExtendedMode_AllHaveFullTagReference() + { + var config = LoadConfig(extendedAttributes: true); + var service = new GalaxyRepositoryService(config); + + var results = await service.GetAttributesAsync(); + + results.ShouldAllBe(r => !string.IsNullOrEmpty(r.FullTagReference)); + results.ShouldAllBe(r => r.FullTagReference.Contains(".")); + } + } +} diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Configuration/ConfigurationLoadingTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Configuration/ConfigurationLoadingTests.cs index 6361b22..a046dcf 100644 --- a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Configuration/ConfigurationLoadingTests.cs +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Configuration/ConfigurationLoadingTests.cs @@ -53,6 +53,27 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Configuration config.GalaxyRepository.ConnectionString.ShouldContain("ZB"); config.GalaxyRepository.ChangeDetectionIntervalSeconds.ShouldBe(30); config.GalaxyRepository.CommandTimeoutSeconds.ShouldBe(30); + config.GalaxyRepository.ExtendedAttributes.ShouldBe(false); + } + + [Fact] + public void GalaxyRepository_ExtendedAttributes_DefaultsFalse() + { + var config = new GalaxyRepositoryConfiguration(); + config.ExtendedAttributes.ShouldBe(false); + } + + [Fact] + public void GalaxyRepository_ExtendedAttributes_BindsFromJson() + { + var configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", optional: false) + .AddInMemoryCollection(new[] { new System.Collections.Generic.KeyValuePair("GalaxyRepository:ExtendedAttributes", "true") }) + .Build(); + + var config = new GalaxyRepositoryConfiguration(); + configuration.GetSection("GalaxyRepository").Bind(config); + config.ExtendedAttributes.ShouldBe(true); } [Fact] diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Domain/GalaxyAttributeInfoTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Domain/GalaxyAttributeInfoTests.cs new file mode 100644 index 0000000..717e028 --- /dev/null +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Domain/GalaxyAttributeInfoTests.cs @@ -0,0 +1,48 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.LmxOpcUa.Host.Domain; + +namespace ZB.MOM.WW.LmxOpcUa.Tests.Domain +{ + public class GalaxyAttributeInfoTests + { + [Fact] + public void DefaultValues_AreEmpty() + { + var info = new GalaxyAttributeInfo(); + info.PrimitiveName.ShouldBe(""); + info.AttributeSource.ShouldBe(""); + info.TagName.ShouldBe(""); + info.AttributeName.ShouldBe(""); + info.FullTagReference.ShouldBe(""); + info.DataTypeName.ShouldBe(""); + } + + [Fact] + public void ExtendedFields_CanBeSet() + { + var info = new GalaxyAttributeInfo + { + PrimitiveName = "UDO", + AttributeSource = "primitive" + }; + info.PrimitiveName.ShouldBe("UDO"); + info.AttributeSource.ShouldBe("primitive"); + } + + [Fact] + public void StandardAttributes_HaveEmptyExtendedFields() + { + var info = new GalaxyAttributeInfo + { + GobjectId = 1, + TagName = "TestObj", + AttributeName = "MachineID", + FullTagReference = "TestObj.MachineID", + MxDataType = 5 + }; + info.PrimitiveName.ShouldBe(""); + info.AttributeSource.ShouldBe(""); + } + } +}