diff --git a/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyAttributeRow.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyAttributeRow.cs new file mode 100644 index 0000000..e82cf2d --- /dev/null +++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyAttributeRow.cs @@ -0,0 +1,41 @@ +namespace ZB.MOM.WW.GalaxyRepository; + +/// One row from . +public sealed class GalaxyAttributeRow +{ + /// Gets the Galaxy object identifier. + public int GobjectId { get; init; } + + /// Gets the tag name. + public string TagName { get; init; } = string.Empty; + + /// Gets the attribute name. + public string AttributeName { get; init; } = string.Empty; + + /// Gets the full tag reference. + public string FullTagReference { get; init; } = string.Empty; + + /// Gets the MXAccess data type code. + public int MxDataType { get; init; } + + /// Gets the data type name. + public string? DataTypeName { get; init; } + + /// Gets a value indicating whether this is an array. + public bool IsArray { get; init; } + + /// Gets the array dimension, if applicable. + public int? ArrayDimension { get; init; } + + /// Gets the MXAccess attribute category code. + public int MxAttributeCategory { get; init; } + + /// Gets the security classification code. + public int SecurityClassification { get; init; } + + /// Gets a value indicating whether this is historized. + public bool IsHistorized { get; init; } + + /// Gets a value indicating whether this is an alarm. + public bool IsAlarm { get; init; } +} diff --git a/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyRow.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyRow.cs new file mode 100644 index 0000000..5bbbaa3 --- /dev/null +++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyRow.cs @@ -0,0 +1,35 @@ +namespace ZB.MOM.WW.GalaxyRepository; + +/// +/// One row from : a deployed Galaxy +/// gobject with its hierarchy parent and template-derivation chain. +/// +public sealed class GalaxyHierarchyRow +{ + /// Gets the Galaxy object identifier. + public int GobjectId { get; init; } + + /// Gets the tag name. + public string TagName { get; init; } = string.Empty; + + /// Gets the contained name. + public string ContainedName { get; init; } = string.Empty; + + /// Gets the browse name. + public string BrowseName { get; init; } = string.Empty; + + /// Gets the parent Galaxy object identifier. + public int ParentGobjectId { get; init; } + + /// Gets a value indicating whether this is an area. + public bool IsArea { get; init; } + + /// Gets the category identifier. + public int CategoryId { get; init; } + + /// Gets the Galaxy object identifier of the host. + public int HostedByGobjectId { get; init; } + + /// Gets the template derivation chain. + public IReadOnlyList TemplateChain { get; init; } = Array.Empty(); +} diff --git a/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyRepository.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyRepository.cs new file mode 100644 index 0000000..eb6224c --- /dev/null +++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyRepository.cs @@ -0,0 +1,257 @@ +using Microsoft.Data.SqlClient; + +namespace ZB.MOM.WW.GalaxyRepository; + +/// +/// SQL access to the AVEVA System Platform Galaxy Repository database. +/// +/// is 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. +/// +/// +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; + } + + // Area objects (category 13) are returned even when undeployed (deployed_package_id = 0): + // they are organizational/model nodes that group deployed objects, so excluding them + // orphans every area whose containing area is not itself deployed. All non-area objects + // still require deployment. Orphans left by a missing/deleted parent area are re-rooted + // by GalaxyHierarchyIndex.Build so nothing disappears from browse. + 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 OR td.category_id = 13) +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. + 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"; +} diff --git a/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyRepositoryOptions.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyRepositoryOptions.cs new file mode 100644 index 0000000..6975f7d --- /dev/null +++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyRepositoryOptions.cs @@ -0,0 +1,55 @@ +namespace ZB.MOM.WW.GalaxyRepository; + +/// +/// Connection settings for the AVEVA System Platform Galaxy Repository database. +/// +/// is a generic default; the DI extension accepts an explicit +/// configuration section path so a consumer can bind from its own section (e.g. +/// MxGateway:Galaxy). +/// +/// +public sealed class GalaxyRepositoryOptions +{ + /// + /// Generic default configuration section name. The DI extension accepts an explicit + /// section path, so a consumer may bind from a different section (e.g. + /// MxGateway:Galaxy). + /// + public const string SectionName = "GalaxyRepository"; + + /// + /// Default SQL Server connection string for the Galaxy Repository database. + /// Single source of truth shared with the integration-test fallback so the + /// production default and the live-test default cannot drift. + /// + public const string DefaultConnectionString = + "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;"; + + /// The SQL Server connection string for the Galaxy Repository database. + public string ConnectionString { get; init; } = DefaultConnectionString; + + /// The timeout in seconds for SQL commands executed against the Galaxy Repository. + public int CommandTimeoutSeconds { get; init; } = 60; + + /// + /// Interval (seconds) between background refreshes of the dashboard Galaxy summary + /// cache. SQL is hit at most once per interval regardless of dashboard render rate. + /// + public int DashboardRefreshIntervalSeconds { get; init; } = 30; + + /// + /// Whether the latest successful Galaxy browse dataset is persisted to disk. When + /// enabled, the cache reloads that snapshot at startup so clients can still browse + /// last-known data while the Galaxy database is unreachable. + /// + public bool PersistSnapshot { get; init; } = true; + + /// + /// File path for the persisted Galaxy browse snapshot. Ignored when + /// is . There is no built-in + /// default path — the consumer supplies a cross-platform-friendly path appropriate to + /// its host. When left empty and is enabled, the + /// snapshot store (a later task) decides where to write. + /// + public string SnapshotCachePath { get; init; } = string.Empty; +} diff --git a/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/IGalaxyRepository.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/IGalaxyRepository.cs new file mode 100644 index 0000000..8924e50 --- /dev/null +++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/IGalaxyRepository.cs @@ -0,0 +1,26 @@ +namespace ZB.MOM.WW.GalaxyRepository; + +/// +/// Abstraction over : the read-only SQL surface over the +/// AVEVA System Platform Galaxy Repository database. Exists so consumers (and the cache +/// layer, a later task) can be unit-tested against an in-memory fake without standing up a +/// real Microsoft.Data.SqlClient SqlConnection against a bogus host/port. +/// +public interface IGalaxyRepository +{ + /// Tests the connection to the Galaxy Repository database. + /// Token to cancel the asynchronous operation. + Task TestConnectionAsync(CancellationToken ct = default); + + /// Retrieves the last deployment time from the Galaxy Repository. + /// Token to cancel the asynchronous operation. + Task GetLastDeployTimeAsync(CancellationToken ct = default); + + /// Retrieves the complete hierarchy of Galaxy objects from the repository. + /// Token to cancel the asynchronous operation. + Task> GetHierarchyAsync(CancellationToken ct = default); + + /// Retrieves all attributes for Galaxy objects from the repository. + /// Token to cancel the asynchronous operation. + Task> GetAttributesAsync(CancellationToken ct = default); +}