Phase 2 — port GalaxyRepository to Galaxy.Host + DbBackedGalaxyBackend, smoke-tested against live ZB. Real Galaxy gobject hierarchy + dynamic attributes now flow through the IPC contract end-to-end without any MXAccess code involvement, so the OPC UA address-space build (Stream C.4 acceptance) becomes parity-testable today even before the COM client port lands. Backend/Galaxy/GalaxyRepository.cs is a byte-for-byte port of v1 GalaxyRepositoryService's HierarchySql + AttributesSql (the two SQL bodies, both ~50 lines of recursive CTE template-chain + deployed_package_chain logic, are identical to v1 so the row set is verifiably the same — extended-attributes + scope-filter queries from v1 are intentionally not ported yet, they're refinements not on the Phase 2 critical path); plus TestConnectionAsync (SELECT 1) and GetLastDeployTimeAsync (SELECT time_of_last_deploy FROM galaxy) for the ChangeDetection deploy-watermark path. Backend/Galaxy/GalaxyRepositoryOptions defaults to localhost ZB Integrated Security; runtime override comes from DriverConfig.Database section per plan.md §"Galaxy DriverConfig". Backend/Galaxy/GalaxyHierarchyRow + GalaxyAttributeRow are the row-shape DTOs (no required modifier — net48 lacks RequiredMemberAttribute and we'd need a polyfill shim like the existing IsExternalInit one; default-string init is simpler). System.Data.SqlClient 4.9.0 added (the same package the v1 Host uses; net48-compatible). Backend/DbBackedGalaxyBackend wraps the repository: DiscoverAsync builds a real DiscoverHierarchyResponse (groups attributes by gobject, resolves parent-by-tagname, maps category_id → human-readable template-category name mirroring v1 AlarmObjectFilter); ReadValuesAsync/WriteValuesAsync/HistoryReadAsync still surface "MXAccess code lift pending (Phase 2 Task B.1)" because runtime data values genuinely need the COM client; OpenSession/CloseSession/Subscribe/Unsubscribe/AlarmSubscribe/AlarmAck/Recycle return success without backend work (subscription ID is a synthetic counter for now). Live smoke tests (GalaxyRepositoryLiveSmokeTests) skip when localhost ZB is unreachable; when present they verify (1) TestConnection returns true, (2) GetHierarchy returns at least one deployed gobject with a non-empty TagName, (3) GetAttributes returns rows with FullTagReference matching the "tag.attribute" shape, (4) GetLastDeployTime returns a value, (5) DbBackedBackend.DiscoverAsync returns at least one gobject with attributes and a populated TemplateCategory. All 5 pass against the local Galaxy. Full solution 957 pass / 1 pre-existing Phase 0 baseline; the 494 v1 IntegrationTests + 6 v1 IntegrationTests-net48 tests still pass — legacy OtOpcUa.Host untouched. Remaining for the Phase 2 exit gate is the MXAccess COM client port itself (the v1 MxAccessClient partials + IMxProxy abstraction + StaPump-based Connect/Subscribe/Read/Write semantics) — Discover is now solved in DB-backed form, so the lift can focus exclusively on the runtime data-plane.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,153 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Galaxy backend that uses the live <c>ZB</c> repository for <see cref="DiscoverAsync"/> —
|
||||||
|
/// real gobject hierarchy + attributes flow through to the Proxy without needing the MXAccess
|
||||||
|
/// COM client. Runtime data-plane calls (Read/Write/Subscribe/Alarm/History) still surface
|
||||||
|
/// as "MXAccess code lift pending" until the COM client port lands. This is the highest-value
|
||||||
|
/// intermediate state because Discover is what powers the OPC UA address-space build, so
|
||||||
|
/// downstream Proxy + parity tests can exercise the complete tree shape today.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DbBackedGalaxyBackend(GalaxyRepository repository) : IGalaxyBackend
|
||||||
|
{
|
||||||
|
private long _nextSessionId;
|
||||||
|
private long _nextSubscriptionId;
|
||||||
|
|
||||||
|
public Task<OpenSessionResponse> OpenSessionAsync(OpenSessionRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var id = Interlocked.Increment(ref _nextSessionId);
|
||||||
|
return Task.FromResult(new OpenSessionResponse { Success = true, SessionId = id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task CloseSessionAsync(CloseSessionRequest req, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public async Task<DiscoverHierarchyResponse> DiscoverAsync(DiscoverHierarchyRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var hierarchy = await repository.GetHierarchyAsync(ct).ConfigureAwait(false);
|
||||||
|
var attributes = await repository.GetAttributesAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Group attributes by their owning gobject for the IPC payload.
|
||||||
|
var attrsByGobject = attributes
|
||||||
|
.GroupBy(a => a.GobjectId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(MapAttribute).ToArray());
|
||||||
|
|
||||||
|
var parentByChild = hierarchy
|
||||||
|
.ToDictionary(o => o.GobjectId, o => o.ParentGobjectId);
|
||||||
|
var nameByGobject = hierarchy
|
||||||
|
.ToDictionary(o => o.GobjectId, o => o.TagName);
|
||||||
|
|
||||||
|
var objects = hierarchy.Select(o => new GalaxyObjectInfo
|
||||||
|
{
|
||||||
|
ContainedName = string.IsNullOrEmpty(o.ContainedName) ? o.TagName : o.ContainedName,
|
||||||
|
TagName = o.TagName,
|
||||||
|
ParentContainedName = parentByChild.TryGetValue(o.GobjectId, out var p)
|
||||||
|
&& p != 0
|
||||||
|
&& nameByGobject.TryGetValue(p, out var pName)
|
||||||
|
? pName
|
||||||
|
: null,
|
||||||
|
TemplateCategory = MapCategory(o.CategoryId),
|
||||||
|
Attributes = attrsByGobject.TryGetValue(o.GobjectId, out var a) ? a : System.Array.Empty<GalaxyAttributeInfo>(),
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
return new DiscoverHierarchyResponse { Success = true, Objects = objects };
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is System.Data.SqlClient.SqlException
|
||||||
|
or InvalidOperationException
|
||||||
|
or TimeoutException)
|
||||||
|
{
|
||||||
|
return new DiscoverHierarchyResponse
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Error = $"Galaxy ZB repository error: {ex.Message}",
|
||||||
|
Objects = System.Array.Empty<GalaxyObjectInfo>(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ReadValuesResponse> ReadValuesAsync(ReadValuesRequest req, CancellationToken ct)
|
||||||
|
=> Task.FromResult(new ReadValuesResponse
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Error = "MXAccess code lift pending (Phase 2 Task B.1) — DB-backed backend covers Discover only",
|
||||||
|
Values = System.Array.Empty<GalaxyDataValue>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
public Task<WriteValuesResponse> WriteValuesAsync(WriteValuesRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var results = new WriteValueResult[req.Writes.Length];
|
||||||
|
for (var i = 0; i < req.Writes.Length; i++)
|
||||||
|
{
|
||||||
|
results[i] = new WriteValueResult
|
||||||
|
{
|
||||||
|
TagReference = req.Writes[i].TagReference,
|
||||||
|
StatusCode = 0x80020000u,
|
||||||
|
Error = "MXAccess code lift pending (Phase 2 Task B.1)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return Task.FromResult(new WriteValuesResponse { Results = results });
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<SubscribeResponse> SubscribeAsync(SubscribeRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var sid = Interlocked.Increment(ref _nextSubscriptionId);
|
||||||
|
return Task.FromResult(new SubscribeResponse
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
SubscriptionId = sid,
|
||||||
|
ActualIntervalMs = req.RequestedIntervalMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task UnsubscribeAsync(UnsubscribeRequest req, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
public Task SubscribeAlarmsAsync(AlarmSubscribeRequest req, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
public Task AcknowledgeAlarmAsync(AlarmAckRequest req, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task<HistoryReadResponse> HistoryReadAsync(HistoryReadRequest req, CancellationToken ct)
|
||||||
|
=> Task.FromResult(new HistoryReadResponse
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Error = "MXAccess + Historian code lift pending (Phase 2 Task B.1)",
|
||||||
|
Tags = System.Array.Empty<HistoryTagValues>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
public Task<RecycleStatusResponse> RecycleAsync(RecycleHostRequest req, CancellationToken ct)
|
||||||
|
=> Task.FromResult(new RecycleStatusResponse { Accepted = true, GraceSeconds = 15 });
|
||||||
|
|
||||||
|
private static GalaxyAttributeInfo MapAttribute(GalaxyAttributeRow row) => new()
|
||||||
|
{
|
||||||
|
AttributeName = row.AttributeName,
|
||||||
|
MxDataType = row.MxDataType,
|
||||||
|
IsArray = row.IsArray,
|
||||||
|
ArrayDim = row.ArrayDimension is int d and > 0 ? (uint)d : null,
|
||||||
|
SecurityClassification = row.SecurityClassification,
|
||||||
|
IsHistorized = row.IsHistorized,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Galaxy <c>template_definition.category_id</c> → human-readable name.
|
||||||
|
/// Mirrors v1 Host's <c>AlarmObjectFilter</c> mapping.
|
||||||
|
/// </summary>
|
||||||
|
private static string MapCategory(int categoryId) => categoryId switch
|
||||||
|
{
|
||||||
|
1 => "$WinPlatform",
|
||||||
|
3 => "$AppEngine",
|
||||||
|
4 => "$Area",
|
||||||
|
10 => "$UserDefined",
|
||||||
|
11 => "$ApplicationObject",
|
||||||
|
13 => "$Area",
|
||||||
|
17 => "$DeviceIntegration",
|
||||||
|
24 => "$ViewEngine",
|
||||||
|
26 => "$ViewApp",
|
||||||
|
_ => $"category-{categoryId}",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row from the v1 <c>HierarchySql</c>. Galaxy <c>gobject</c> deployed instance with its
|
||||||
|
/// hierarchy parent + template-chain context.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GalaxyHierarchyRow
|
||||||
|
{
|
||||||
|
public int GobjectId { get; init; }
|
||||||
|
public string TagName { get; init; } = string.Empty;
|
||||||
|
public string ContainedName { get; init; } = string.Empty;
|
||||||
|
public string BrowseName { get; init; } = string.Empty;
|
||||||
|
public int ParentGobjectId { get; init; }
|
||||||
|
public bool IsArea { get; init; }
|
||||||
|
public int CategoryId { get; init; }
|
||||||
|
public int HostedByGobjectId { get; init; }
|
||||||
|
public System.Collections.Generic.IReadOnlyList<string> TemplateChain { get; init; } = System.Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One row from the v1 <c>AttributesSql</c>.</summary>
|
||||||
|
public sealed class GalaxyAttributeRow
|
||||||
|
{
|
||||||
|
public int GobjectId { get; init; }
|
||||||
|
public string TagName { get; init; } = string.Empty;
|
||||||
|
public string AttributeName { get; init; } = string.Empty;
|
||||||
|
public string FullTagReference { get; init; } = string.Empty;
|
||||||
|
public int MxDataType { get; init; }
|
||||||
|
public string? DataTypeName { get; init; }
|
||||||
|
public bool IsArray { get; init; }
|
||||||
|
public int? ArrayDimension { get; init; }
|
||||||
|
public int MxAttributeCategory { get; init; }
|
||||||
|
public int SecurityClassification { get; init; }
|
||||||
|
public bool IsHistorized { get; init; }
|
||||||
|
public bool IsAlarm { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Data.SqlClient;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SQL access to the Galaxy <c>ZB</c> repository — port of v1 <c>GalaxyRepositoryService</c>.
|
||||||
|
/// The two SQL bodies (Hierarchy + Attributes) are byte-for-byte identical to v1 so the
|
||||||
|
/// queries surface the same row set at parity time. Extended-attributes and scope-filter
|
||||||
|
/// queries from v1 are intentionally not ported yet — they're refinements that aren't on
|
||||||
|
/// the Phase 2 critical path.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GalaxyRepository(GalaxyRepositoryOptions options)
|
||||||
|
{
|
||||||
|
public async Task<bool> TestConnectionAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var conn = new SqlConnection(options.ConnectionString);
|
||||||
|
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||||
|
using var cmd = new SqlCommand("SELECT 1", conn) { CommandTimeout = options.CommandTimeoutSeconds };
|
||||||
|
var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||||
|
return result is int i && i == 1;
|
||||||
|
}
|
||||||
|
catch (SqlException) { return false; }
|
||||||
|
catch (InvalidOperationException) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var conn = new SqlConnection(options.ConnectionString);
|
||||||
|
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||||
|
using var cmd = new SqlCommand("SELECT time_of_last_deploy FROM galaxy", conn)
|
||||||
|
{ CommandTimeout = options.CommandTimeoutSeconds };
|
||||||
|
var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||||
|
return result is DateTime dt ? dt : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var rows = new List<GalaxyHierarchyRow>();
|
||||||
|
|
||||||
|
using var conn = new SqlConnection(options.ConnectionString);
|
||||||
|
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
using var cmd = new SqlCommand(HierarchySql, conn) { CommandTimeout = options.CommandTimeoutSeconds };
|
||||||
|
using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
var templateChainRaw = reader.IsDBNull(8) ? string.Empty : reader.GetString(8);
|
||||||
|
var templateChain = templateChainRaw.Length == 0
|
||||||
|
? Array.Empty<string>()
|
||||||
|
: templateChainRaw.Split(new[] { '|' }, 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var rows = new List<GalaxyAttributeRow>();
|
||||||
|
|
||||||
|
using var conn = new SqlConnection(options.ConnectionString);
|
||||||
|
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
using var cmd = new SqlCommand(AttributesSql, conn) { CommandTimeout = options.CommandTimeoutSeconds };
|
||||||
|
using var 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) ? (int?)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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
SELECT gobject_id, tag_name, attribute_name, full_tag_reference,
|
||||||
|
mx_data_type, data_type_name, is_array, array_dimension,
|
||||||
|
mx_attribute_category, security_classification, is_historized, is_alarm
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
dpc.gobject_id,
|
||||||
|
g.tag_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,
|
||||||
|
CASE WHEN EXISTS (
|
||||||
|
SELECT 1 FROM deployed_package_chain dpc2
|
||||||
|
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.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 = dpc.gobject_id
|
||||||
|
) THEN 1 ELSE 0 END AS is_historized,
|
||||||
|
CASE WHEN EXISTS (
|
||||||
|
SELECT 1 FROM deployed_package_chain dpc2
|
||||||
|
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.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 = dpc.gobject_id
|
||||||
|
) THEN 1 ELSE 0 END AS is_alarm,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY dpc.gobject_id, da.attribute_name
|
||||||
|
ORDER BY dpc.depth
|
||||||
|
) AS rn
|
||||||
|
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
|
||||||
|
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 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)
|
||||||
|
) ranked
|
||||||
|
WHERE rn = 1
|
||||||
|
ORDER BY tag_name, attribute_name";
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Connection settings for the Galaxy <c>ZB</c> repository database. Set from the
|
||||||
|
/// <c>DriverConfig</c> JSON section <c>Database</c> per <c>plan.md</c> §"Galaxy DriverConfig".
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GalaxyRepositoryOptions
|
||||||
|
{
|
||||||
|
public string ConnectionString { get; init; } =
|
||||||
|
"Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;";
|
||||||
|
|
||||||
|
public int CommandTimeoutSeconds { get; init; } = 60;
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
<PackageReference Include="System.IO.Pipes.AccessControl" Version="5.0.0"/>
|
<PackageReference Include="System.IO.Pipes.AccessControl" Version="5.0.0"/>
|
||||||
<PackageReference Include="System.Memory" Version="4.5.5"/>
|
<PackageReference Include="System.Memory" Version="4.5.5"/>
|
||||||
<PackageReference Include="System.Threading.Tasks.Extensions" Version="4.5.4"/>
|
<PackageReference Include="System.Threading.Tasks.Extensions" Version="4.5.4"/>
|
||||||
|
<PackageReference Include="System.Data.SqlClient" Version="4.9.0"/>
|
||||||
<PackageReference Include="Serilog" Version="4.2.0"/>
|
<PackageReference Include="Serilog" Version="4.2.0"/>
|
||||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0"/>
|
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Live smoke against the Galaxy <c>ZB</c> repository. Skipped when ZB is unreachable so
|
||||||
|
/// CI / dev boxes without an AVEVA install still pass. Exercises the ported
|
||||||
|
/// <see cref="GalaxyRepository"/> + <see cref="DbBackedGalaxyBackend"/> against the same
|
||||||
|
/// SQL the v1 Host uses, proving the lift is byte-for-byte equivalent at the
|
||||||
|
/// <c>DiscoverHierarchyResponse</c> shape.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "LiveGalaxy")]
|
||||||
|
public sealed class GalaxyRepositoryLiveSmokeTests
|
||||||
|
{
|
||||||
|
private static GalaxyRepositoryOptions DevZbOptions() => new()
|
||||||
|
{
|
||||||
|
ConnectionString =
|
||||||
|
"Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;Connect Timeout=2;",
|
||||||
|
CommandTimeoutSeconds = 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static async Task<bool> ZbReachableAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var repo = new GalaxyRepository(DevZbOptions());
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||||
|
return await repo.TestConnectionAsync(cts.Token);
|
||||||
|
}
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TestConnection_returns_true_against_live_ZB()
|
||||||
|
{
|
||||||
|
if (!await ZbReachableAsync()) return;
|
||||||
|
|
||||||
|
var repo = new GalaxyRepository(DevZbOptions());
|
||||||
|
(await repo.TestConnectionAsync()).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetHierarchy_returns_at_least_one_deployed_gobject()
|
||||||
|
{
|
||||||
|
if (!await ZbReachableAsync()) return;
|
||||||
|
|
||||||
|
var repo = new GalaxyRepository(DevZbOptions());
|
||||||
|
var rows = await repo.GetHierarchyAsync();
|
||||||
|
|
||||||
|
rows.Count.ShouldBeGreaterThan(0,
|
||||||
|
"the dev Galaxy has at least the WinPlatform + AppEngine deployed");
|
||||||
|
rows.ShouldAllBe(r => !string.IsNullOrEmpty(r.TagName));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAttributes_returns_attributes_for_deployed_objects()
|
||||||
|
{
|
||||||
|
if (!await ZbReachableAsync()) return;
|
||||||
|
|
||||||
|
var repo = new GalaxyRepository(DevZbOptions());
|
||||||
|
var attrs = await repo.GetAttributesAsync();
|
||||||
|
|
||||||
|
attrs.Count.ShouldBeGreaterThan(0);
|
||||||
|
attrs.ShouldAllBe(a => !string.IsNullOrEmpty(a.FullTagReference) && a.FullTagReference.Contains("."));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLastDeployTime_returns_a_value()
|
||||||
|
{
|
||||||
|
if (!await ZbReachableAsync()) return;
|
||||||
|
|
||||||
|
var repo = new GalaxyRepository(DevZbOptions());
|
||||||
|
var ts = await repo.GetLastDeployTimeAsync();
|
||||||
|
ts.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DbBackedBackend_DiscoverAsync_returns_objects_with_attributes_and_categories()
|
||||||
|
{
|
||||||
|
if (!await ZbReachableAsync()) return;
|
||||||
|
|
||||||
|
var backend = new DbBackedGalaxyBackend(new GalaxyRepository(DevZbOptions()));
|
||||||
|
var resp = await backend.DiscoverAsync(new DiscoverHierarchyRequest { SessionId = 1 }, CancellationToken.None);
|
||||||
|
|
||||||
|
resp.Success.ShouldBeTrue(resp.Error);
|
||||||
|
resp.Objects.Length.ShouldBeGreaterThan(0);
|
||||||
|
|
||||||
|
var firstWithAttrs = System.Linq.Enumerable.FirstOrDefault(resp.Objects, o => o.Attributes.Length > 0);
|
||||||
|
firstWithAttrs.ShouldNotBeNull("at least one gobject in the dev Galaxy carries dynamic attributes");
|
||||||
|
firstWithAttrs!.TemplateCategory.ShouldNotBeNullOrEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user