diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyAlarmAttributeRow.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyAlarmAttributeRow.cs new file mode 100644 index 0000000..5529097 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyAlarmAttributeRow.cs @@ -0,0 +1,36 @@ +namespace ZB.MOM.WW.MxGateway.Server.Galaxy; + +/// +/// One alarm-bearing attribute discovered by +/// : an attribute whose owning +/// object configures an AlarmExtension primitive (the same +/// is_alarm detection used by ). +/// Used to build the subtag-fallback watch-list. +/// +public sealed class GalaxyAlarmAttributeRow +{ + /// + /// Gets the alarm-bearing attribute reference (e.g. Tank01.Level.HiHi), + /// matching the full_tag_reference projection of + /// . + /// + public string FullTagReference { get; init; } = string.Empty; + + /// + /// Gets the owning object reference (e.g. Tank01). This is the Galaxy + /// tag_name — the segment that precedes the first attribute dot in + /// . + /// + public string SourceObjectReference { get; init; } = string.Empty; + + /// + /// Gets the writable ack-comment attribute address. + /// + /// The Galaxy Repository schema does not expose an ack-comment subtag address + /// directly, so this is always here. The watch-list + /// resolver (a later task) composes the concrete address from configuration plus + /// / . + /// + /// + public string AckCommentSubtag { get; init; } = string.Empty; +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepository.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepository.cs index db3c171..075dd83 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepository.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepository.cs @@ -114,6 +114,56 @@ public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyR return rows; } + /// + /// Retrieves only the alarm-bearing attributes for the subtag-fallback watch-list. + /// Alarm detection is identical to : a row is + /// alarm-bearing when its owning object configures an AlarmExtension + /// primitive (the same is_alarm projection, here applied as a SQL filter). + /// + /// Token to cancel the asynchronous operation. + public async Task> GetAlarmAttributesAsync(CancellationToken ct = default) + { + List rows = new(); + + using SqlConnection conn = new(options.ConnectionString); + await conn.OpenAsync(ct).ConfigureAwait(false); + + using SqlCommand cmd = new(AlarmAttributesSql, conn) { CommandTimeout = options.CommandTimeoutSeconds }; + using SqlDataReader reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + + while (await reader.ReadAsync(ct).ConfigureAwait(false)) + { + rows.Add(MapAlarmRow( + fullTagReference: reader.GetString(0), + sourceObjectReference: reader.GetString(1))); + } + return rows; + } + + /// + /// Maps a raw alarm-attribute reader row to a . + /// + /// is the Galaxy tag_name (the + /// owning object), and is + /// tag_name + '.' + attribute_name — the same composition the + /// full_tag_reference projection of produces. + /// is left empty here; the + /// schema does not expose an ack-comment address and the watch-list resolver + /// composes it later. + /// + /// Exposed internally so the derivation can be unit-tested without a database. + /// + /// The alarm-bearing attribute reference. + /// The owning object reference (tag name). + internal static GalaxyAlarmAttributeRow MapAlarmRow( + string fullTagReference, + string sourceObjectReference) => new() + { + FullTagReference = fullTagReference, + SourceObjectReference = sourceObjectReference, + AckCommentSubtag = string.Empty, + }; + private const string HierarchySql = @" ;WITH template_chain AS ( SELECT g.gobject_id AS instance_gobject_id, t.gobject_id AS template_gobject_id, @@ -248,5 +298,71 @@ SELECT FROM ranked r LEFT JOIN data_type dt ON dt.mx_data_type = r.mx_data_type WHERE r.rn = 1 +ORDER BY r.tag_name, r.attribute_name"; + + // Alarm-only discovery for the subtag-fallback watch-list. This deliberately reuses the + // exact candidate/ranked CTE structure and the same `AlarmExtension`-based is_alarm + // detection as AttributesSql so the two queries cannot drift: a row qualifies only when + // its user attribute (src_pri 0) anchors an `AlarmExtension` primitive on the owning + // object. It projects just what the watch-list needs — full_tag_reference (tag_name + + // '.' + attribute_name, matching AttributesSql) and the owning object's tag_name as + // source_object_reference. The array `[]` suffix is intentionally omitted: an + // alarm-bearing attribute is a scalar anchor, not an array body. + private const string AlarmAttributesSql = @" +;WITH deployed_package_chain AS ( + SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth + FROM gobject g + INNER JOIN package p ON p.package_id = g.deployed_package_id + WHERE g.is_template = 0 AND g.deployed_package_id <> 0 + UNION ALL + SELECT dpc.gobject_id, p.package_id, p.derived_from_package_id, dpc.depth + 1 + FROM deployed_package_chain dpc + INNER JOIN package p ON p.package_id = dpc.derived_from_package_id + WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10 +), +candidate AS ( + SELECT + dpc.gobject_id, g.tag_name, da.attribute_name, dpc.depth, 0 AS src_pri + FROM deployed_package_chain dpc + INNER JOIN dynamic_attribute da ON da.package_id = dpc.package_id + INNER JOIN gobject g ON g.gobject_id = dpc.gobject_id + INNER JOIN template_definition td ON td.template_definition_id = g.template_definition_id + WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26) + 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) + UNION ALL + SELECT + dpc.gobject_id, g.tag_name, + CASE WHEN pi.primitive_name IS NULL OR pi.primitive_name = '' + THEN ad.attribute_name + ELSE pi.primitive_name + '.' + ad.attribute_name END AS attribute_name, + dpc.depth, 1 AS src_pri + FROM deployed_package_chain dpc + INNER JOIN primitive_instance pi ON pi.package_id = dpc.package_id + INNER JOIN attribute_definition ad ON ad.primitive_definition_id = pi.primitive_definition_id + INNER JOIN gobject g ON g.gobject_id = dpc.gobject_id + INNER JOIN template_definition td ON td.template_definition_id = g.template_definition_id + WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26) + AND ad.attribute_name NOT LIKE '[_]%' + AND ad.attribute_name NOT LIKE '%.Description' +), +ranked AS ( + SELECT c.*, ROW_NUMBER() OVER ( + PARTITION BY c.gobject_id, c.attribute_name ORDER BY c.src_pri, c.depth) AS rn + FROM candidate c +) +SELECT + r.tag_name + '.' + r.attribute_name AS full_tag_reference, + r.tag_name AS source_object_reference +FROM ranked r +WHERE r.rn = 1 + AND r.src_pri = 0 + AND EXISTS ( + SELECT 1 FROM deployed_package_chain dpc2 + INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = r.attribute_name + INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'AlarmExtension' + WHERE dpc2.gobject_id = r.gobject_id + ) ORDER BY r.tag_name, r.attribute_name"; } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyRepository.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyRepository.cs index 6621837..73fb8d2 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyRepository.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyRepository.cs @@ -27,4 +27,12 @@ public interface IGalaxyRepository /// Retrieves all attributes for Galaxy objects from the repository. /// Token to cancel the asynchronous operation. Task> GetAttributesAsync(CancellationToken ct = default); + + /// + /// Retrieves only the alarm-bearing attributes (those whose owning object + /// configures an AlarmExtension primitive) for building the + /// subtag-fallback watch-list. + /// + /// Token to cancel the asynchronous operation. + Task> GetAlarmAttributesAsync(CancellationToken ct = default); } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyAlarmAttributeMappingTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyAlarmAttributeMappingTests.cs new file mode 100644 index 0000000..d3633dc --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyAlarmAttributeMappingTests.cs @@ -0,0 +1,64 @@ +using ZB.MOM.WW.MxGateway.Server.Galaxy; + +namespace ZB.MOM.WW.MxGateway.Tests.Galaxy; + +/// +/// Pure mapper tests for . These assert the +/// FullTagReference / SourceObjectReference derivation produced by +/// AlarmAttributesSql without touching a database: the SQL projects +/// tag_name as the source object and tag_name + '.' + attribute_name as +/// the full reference, exactly as AttributesSql does. +/// +public sealed class GalaxyAlarmAttributeMappingTests +{ + /// Verifies the mapper copies both projected columns onto the row. + [Fact] + public void MapAlarmRow_CopiesProjectedColumns() + { + GalaxyAlarmAttributeRow row = GalaxyRepository.MapAlarmRow( + fullTagReference: "Tank01.Level.HiHi", + sourceObjectReference: "Tank01"); + + Assert.Equal("Tank01.Level.HiHi", row.FullTagReference); + Assert.Equal("Tank01", row.SourceObjectReference); + } + + /// + /// Verifies is always empty: + /// the schema does not expose an ack-comment address, so the watch-list resolver + /// composes it later from configuration. + /// + [Fact] + public void MapAlarmRow_LeavesAckCommentSubtagEmpty() + { + GalaxyAlarmAttributeRow row = GalaxyRepository.MapAlarmRow( + fullTagReference: "Tank01.Level.HiHi", + sourceObjectReference: "Tank01"); + + Assert.Equal(string.Empty, row.AckCommentSubtag); + } + + /// + /// Verifies the SourceObjectReference is the owning object (the SQL tag_name), + /// i.e. the segment that precedes the first attribute dot in the full reference, even + /// when the attribute itself is a multi-segment extension path. + /// + [Theory] + [InlineData("Tank01", "Level.HiHi", "Tank01.Level.HiHi")] + [InlineData("Pump_001", "Speed", "Pump_001.Speed")] + [InlineData("TestAlarm001", "Alarm001", "TestAlarm001.Alarm001")] + public void MapAlarmRow_SourceObjectIsSegmentBeforeFirstAttributeDot( + string tagName, + string attributeName, + string expectedFullReference) + { + // Mirror the AlarmAttributesSql projection: full_tag_reference = tag_name + '.' + attribute_name. + string fullTagReference = tagName + "." + attributeName; + + GalaxyAlarmAttributeRow row = GalaxyRepository.MapAlarmRow(fullTagReference, tagName); + + Assert.Equal(expectedFullReference, row.FullTagReference); + Assert.Equal(tagName, row.SourceObjectReference); + Assert.Equal(row.FullTagReference, row.SourceObjectReference + "." + attributeName); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs index 96dcb2a..f7718b3 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs @@ -378,6 +378,10 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable /// public Task> GetAttributesAsync(CancellationToken ct = default) => throw new InvalidOperationException("GetAttributesAsync should not be reached"); + + /// + public Task> GetAlarmAttributesAsync(CancellationToken ct = default) + => throw new InvalidOperationException("GetAlarmAttributesAsync should not be reached"); } /// Snapshot store whose cancels the token mid-save. @@ -465,6 +469,10 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable GetAttributesCount++; return Task.FromResult(_attributes); } + + /// + public Task> GetAlarmAttributesAsync(CancellationToken ct = default) + => Task.FromResult(new List()); } /// @@ -518,6 +526,10 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable GetAttributesCount++; throw toThrow; } + + /// + public Task> GetAlarmAttributesAsync(CancellationToken ct = default) + => throw toThrow; } }