369 lines
18 KiB
C#
369 lines
18 KiB
C#
using Microsoft.Data.SqlClient;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
|
|
|
/// <summary>
|
|
/// SQL access to the AVEVA System Platform Galaxy Repository (ZB) database.
|
|
/// <para>
|
|
/// <see cref="HierarchySql" /> is still the query originally ported from the OtOpcUa
|
|
/// project. <see cref="AttributesSql" /> has diverged: it additionally enumerates the
|
|
/// built-in attributes contributed by each object's primitives (from
|
|
/// <c>attribute_definition</c> via <c>primitive_instance</c>), so engine/platform objects
|
|
/// and extension sub-attributes (e.g. <c>TestAlarm001.Acked</c>) are surfaced. The
|
|
/// OtOpcUa query is not kept in sync — see docs/GalaxyRepository.md.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyRepository
|
|
{
|
|
/// <summary>Tests the connection to the Galaxy Repository database.</summary>
|
|
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
|
public async Task<bool> 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; }
|
|
}
|
|
|
|
/// <summary>Retrieves the last deployment time from the Galaxy Repository.</summary>
|
|
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
|
public async Task<DateTime?> 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;
|
|
}
|
|
|
|
/// <summary>Retrieves the complete hierarchy of Galaxy objects from the repository.</summary>
|
|
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
|
public async Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
|
|
{
|
|
List<GalaxyHierarchyRow> 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<string>()
|
|
: 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;
|
|
}
|
|
|
|
/// <summary>Retrieves all attributes for Galaxy objects from the repository.</summary>
|
|
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
|
public async Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
|
|
{
|
|
List<GalaxyAttributeRow> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves only the alarm-bearing attributes for the subtag-fallback watch-list.
|
|
/// Alarm detection is identical to <see cref="GetAttributesAsync"/>: a row is
|
|
/// alarm-bearing when its owning object configures an <c>AlarmExtension</c>
|
|
/// primitive (the same <c>is_alarm</c> projection, here applied as a SQL filter).
|
|
/// </summary>
|
|
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
|
public async Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default)
|
|
{
|
|
List<GalaxyAlarmAttributeRow> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps a raw alarm-attribute reader row to a <see cref="GalaxyAlarmAttributeRow"/>.
|
|
/// <para>
|
|
/// <paramref name="sourceObjectReference"/> is the Galaxy <c>tag_name</c> (the
|
|
/// owning object), and <paramref name="fullTagReference"/> is
|
|
/// <c>tag_name + '.' + attribute_name</c> — the same composition the
|
|
/// <c>full_tag_reference</c> projection of <see cref="AttributesSql"/> produces.
|
|
/// <see cref="GalaxyAlarmAttributeRow.AckCommentSubtag"/> is left empty here; the
|
|
/// schema does not expose an ack-comment address and the watch-list resolver
|
|
/// composes it later.
|
|
/// </para>
|
|
/// Exposed internally so the derivation can be unit-tested without a database.
|
|
/// </summary>
|
|
/// <param name="fullTagReference">The alarm-bearing attribute reference.</param>
|
|
/// <param name="sourceObjectReference">The owning object reference (tag name).</param>
|
|
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";
|
|
}
|