using Microsoft.Data.SqlClient; namespace ZB.MOM.WW.MxGateway.Server.Galaxy; /// /// SQL access to the AVEVA System Platform Galaxy Repository (ZB) database. /// /// is still the query originally ported from the OtOpcUa /// project. has diverged: it additionally enumerates the /// built-in attributes contributed by each object's primitives (from /// attribute_definition via primitive_instance), so engine/platform objects /// and extension sub-attributes (e.g. TestAlarm001.Acked) are surfaced. The /// OtOpcUa query is not kept in sync — see docs/GalaxyRepository.md. /// /// public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyRepository { /// Tests the connection to the Galaxy Repository database. /// Token to cancel the asynchronous operation. public async Task TestConnectionAsync(CancellationToken ct = default) { try { using SqlConnection conn = new(options.ConnectionString); await conn.OpenAsync(ct).ConfigureAwait(false); using SqlCommand cmd = new("SELECT 1", conn) { CommandTimeout = options.CommandTimeoutSeconds }; object? result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false); return result is int i && i == 1; } catch (SqlException) { return false; } catch (InvalidOperationException) { return false; } } /// Retrieves the last deployment time from the Galaxy Repository. /// Token to cancel the asynchronous operation. public async Task GetLastDeployTimeAsync(CancellationToken ct = default) { using SqlConnection conn = new(options.ConnectionString); await conn.OpenAsync(ct).ConfigureAwait(false); using SqlCommand cmd = new("SELECT time_of_last_deploy FROM galaxy", conn) { CommandTimeout = options.CommandTimeoutSeconds }; object? result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false); return result is DateTime dt ? dt : null; } /// Retrieves the complete hierarchy of Galaxy objects from the repository. /// Token to cancel the asynchronous operation. public async Task> GetHierarchyAsync(CancellationToken ct = default) { List rows = new(); using SqlConnection conn = new(options.ConnectionString); await conn.OpenAsync(ct).ConfigureAwait(false); using SqlCommand cmd = new(HierarchySql, conn) { CommandTimeout = options.CommandTimeoutSeconds }; using SqlDataReader reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); while (await reader.ReadAsync(ct).ConfigureAwait(false)) { string templateChainRaw = reader.IsDBNull(8) ? string.Empty : reader.GetString(8); string[] templateChain = templateChainRaw.Length == 0 ? Array.Empty() : templateChainRaw.Split(['|'], StringSplitOptions.RemoveEmptyEntries) .Select(s => s.Trim()) .Where(s => s.Length > 0) .ToArray(); rows.Add(new GalaxyHierarchyRow { GobjectId = Convert.ToInt32(reader.GetValue(0)), TagName = reader.GetString(1), ContainedName = reader.IsDBNull(2) ? string.Empty : reader.GetString(2), BrowseName = reader.GetString(3), ParentGobjectId = Convert.ToInt32(reader.GetValue(4)), IsArea = Convert.ToInt32(reader.GetValue(5)) == 1, CategoryId = Convert.ToInt32(reader.GetValue(6)), HostedByGobjectId = Convert.ToInt32(reader.GetValue(7)), TemplateChain = templateChain, }); } return rows; } /// Retrieves all attributes for Galaxy objects from the repository. /// Token to cancel the asynchronous operation. public async Task> GetAttributesAsync(CancellationToken ct = default) { List rows = new(); using SqlConnection conn = new(options.ConnectionString); await conn.OpenAsync(ct).ConfigureAwait(false); using SqlCommand cmd = new(AttributesSql, conn) { CommandTimeout = options.CommandTimeoutSeconds }; using SqlDataReader reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); while (await reader.ReadAsync(ct).ConfigureAwait(false)) { rows.Add(new GalaxyAttributeRow { 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) ? null : reader.GetString(5), IsArray = Convert.ToInt32(reader.GetValue(6)) == 1, ArrayDimension = reader.IsDBNull(7) ? null : Convert.ToInt32(reader.GetValue(7)), MxAttributeCategory = Convert.ToInt32(reader.GetValue(8)), SecurityClassification = Convert.ToInt32(reader.GetValue(9)), IsHistorized = Convert.ToInt32(reader.GetValue(10)) == 1, IsAlarm = Convert.ToInt32(reader.GetValue(11)) == 1, }); } 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, t.tag_name AS template_tag_name, t.derived_from_gobject_id, 0 AS depth FROM gobject g INNER JOIN gobject t ON t.gobject_id = g.derived_from_gobject_id WHERE g.is_template = 0 AND g.deployed_package_id <> 0 AND g.derived_from_gobject_id <> 0 UNION ALL SELECT tc.instance_gobject_id, t.gobject_id, t.tag_name, 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 g.gobject_id, g.tag_name, g.contained_name, CASE WHEN g.contained_name IS NULL OR g.contained_name = '' THEN g.tag_name ELSE g.contained_name END AS browse_name, CASE WHEN g.contained_by_gobject_id = 0 THEN g.area_gobject_id ELSE g.contained_by_gobject_id END AS parent_gobject_id, CASE WHEN td.category_id = 13 THEN 1 ELSE 0 END AS is_area, td.category_id AS category_id, g.hosted_by_gobject_id AS hosted_by_gobject_id, ISNULL( STUFF(( SELECT '|' + tc.template_tag_name FROM template_chain tc WHERE tc.instance_gobject_id = g.gobject_id ORDER BY tc.depth FOR XML PATH('') ), 1, 1, ''), '' ) AS template_chain FROM gobject g INNER JOIN template_definition td ON g.template_definition_id = td.template_definition_id 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 ORDER BY parent_gobject_id, g.tag_name"; // Unlike HierarchySql, this query has diverged from the OtOpcUa original. It returns two // kinds of attribute: user-configured dynamic attributes (the original `dynamic_attribute` // body, src_pri 0) and the built-in attributes every object inherits from its primitives // (`attribute_definition` joined through `primitive_instance`, src_pri 1). Built-in // attributes are why engine/platform objects and extension sub-attributes such as // `TestAlarm001.Acked` show up at all. Built-in rows carry no category filter (the // `attribute_definition` category numbering differs from `dynamic_attribute`'s — only the // `_`-prefix and `.Description` name exclusions apply) and are never flagged // `is_historized`/`is_alarm`: those flags describe a user attribute that anchors an // extension, not the extension's machinery leaves. See docs/GalaxyRepository.md. private const string AttributesSql = @" ;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, da.mx_data_type, 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, 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, ad.mx_data_type, 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, 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.gobject_id, r.tag_name, r.attribute_name, r.tag_name + '.' + r.attribute_name + CASE WHEN r.is_array = 1 THEN '[]' ELSE '' END AS full_tag_reference, r.mx_data_type, dt.description AS data_type_name, r.is_array, r.array_dimension, r.mx_attribute_category, r.security_classification, CASE WHEN 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 = 'HistoryExtension' WHERE dpc2.gobject_id = r.gobject_id ) THEN 1 ELSE 0 END AS is_historized, CASE WHEN 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 ) THEN 1 ELSE 0 END AS is_alarm 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"; }