Surface built-in primitive attributes in Galaxy browse

AttributesSql enumerated only the dynamic_attribute table (user-configured
attributes), so engine/platform objects came back with zero attributes and
extension sub-attributes (TestAlarm001.Acked, .AckMsg, ...) were missing.
DiscoverHierarchy diverged badly from what System Platform's Object Viewer
shows.

AttributesSql now UNIONs dynamic_attribute with the built-in attributes
every object inherits from its primitives (attribute_definition joined via
primitive_instance). Built-in rows carry no category filter (the
attribute_definition category numbering differs from dynamic_attribute's)
and are never flagged is_historized/is_alarm, since those flags identify a
configured attribute that anchors an extension, not the extension's leaves.
dynamic_attribute wins on a reference collision.

This raises the attribute surface ~7x (verified 2,026 -> 14,334 against the
ZB database). AttributesSql no longer matches the OtOpcUa original;
HierarchySql still does. Column shape, ordinals, proto, and generated code
are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-22 01:42:18 -04:00
parent 5e493484f1
commit aba228f443
2 changed files with 123 additions and 57 deletions
+48 -8
View File
@@ -2,7 +2,7 @@
The gateway exposes a read-only browse surface over the AVEVA System Platform
Galaxy Repository (the SQL Server database named `ZB`). Clients use it to
enumerate the deployed object hierarchy and each object's dynamic attributes
enumerate the deployed object hierarchy and each object's attributes
before subscribing to runtime values via the existing `MxAccessGateway` RPCs.
This is a metadata layer: it never reads or writes runtime tag values, never
@@ -19,8 +19,10 @@ ArchestrA IDE renders the deployment tree. Surfacing that data over gRPC lets
remote clients build a navigable address space without any coupling to the
COM layer or the host platform.
The query bodies are kept byte-for-byte identical to the equivalent OPC UA
server in the OtOpcUa project so the two consumers see the same row sets.
`HierarchySql` is the object-hierarchy query originally ported from the
equivalent OPC UA server in the OtOpcUa project. `AttributesSql` has since
diverged from OtOpcUa — see [Built-in vs configured attributes](#built-in-vs-configured-attributes)
— and is no longer kept in sync with it.
## RPC Surface
@@ -32,7 +34,7 @@ The service is defined in
|-----|---------|
| `TestConnection` | Connectivity probe. Returns `{ ok: bool }` after a `SELECT 1`. Does not throw on SQL failure — returns `ok = false`. Always hits SQL directly so it remains a true health check. |
| `GetLastDeployTime` | Returns the cached `galaxy.time_of_last_deploy`. Served from the shared hierarchy cache; refreshed in the background. |
| `DiscoverHierarchy` | Returns one page of the deployed hierarchy plus each returned object's dynamic attributes. **Served from cache** — see [Hierarchy Cache](#hierarchy-cache). |
| `DiscoverHierarchy` | Returns one page of the deployed hierarchy plus each returned object's attributes (configured and built-in — see [Built-in vs configured attributes](#built-in-vs-configured-attributes)). **Served from cache** — see [Hierarchy Cache](#hierarchy-cache). |
| `WatchDeployEvents` | **Server-streaming.** The server emits the current state immediately on subscribe (so clients can bootstrap without waiting), then emits one event per detected deploy change. See [Deploy Notifications](#deploy-notifications). |
`DiscoverHierarchy` is a paged unary RPC. The raw request accepts `page_size`
@@ -176,6 +178,43 @@ message DiscoverHierarchyReply {
}
```
### Built-in vs configured attributes
Each `GalaxyObject` carries two kinds of attribute, both surfaced the same way
in the `attributes` list:
- **Configured (dynamic) attributes** — attributes added in the ArchestrA IDE
attribute editor. Stored in the Galaxy `dynamic_attribute` table.
- **Built-in attributes** — attributes every object inherits from its
primitives: the object framework, the engine/platform primitives, and the
per-attribute extensions (Alarm, History, Boolean, …). Stored in
`attribute_definition` and reached through `primitive_instance`.
Built-in attributes are why an `AppEngine` or `WinPlatform` object reports its
`Engine.*` and `Alarm*` attributes, and why an alarmed attribute such as
`TestAlarm001` reports its extension leaves `TestAlarm001.Acked`,
`TestAlarm001.AckMsg`, `TestAlarm001.ActiveAlarmState`, and so on. An earlier
version of the browse query returned only configured attributes, so those
objects came back empty or partial; including built-ins makes the browse
surface match what System Platform's own Object Viewer shows. Expect roughly
seven times as many attributes as configured-only — the dashboard attribute
count reflects this.
Two rules govern the built-in rows:
- **No category filter.** `attribute_definition` uses a different
`mx_attribute_category` numbering than `dynamic_attribute`, so only the
`_`-prefixed-name and `.Description` exclusions apply to built-ins. (The
configured-attribute category allow-list is unchanged.)
- **`is_historized` / `is_alarm` are always `false` for built-in rows.** Those
flags identify a configured attribute that *anchors* a history or alarm
extension (e.g. `TestAlarm001`), not the extension's machinery leaves
(`TestAlarm001.Acked`). `alarm_bearing_only` and `historized_only` therefore
still select the anchor attributes, not their built-in children.
When a configured attribute and a built-in attribute resolve to the same
reference, the configured attribute wins.
### Contained name vs tag name
Galaxy objects carry two names. `tag_name` is globally unique and is what
@@ -219,10 +258,11 @@ GalaxyHierarchyRefreshService (BackgroundService)
Component breakdown:
- `GalaxyRepository` (`src/MxGateway.Server/Galaxy/GalaxyRepository.cs`) holds
the SQL. Its constants `HierarchySql` and `AttributesSql` are copied verbatim
from the OtOpcUa project; do not edit them in isolation here. The two
queries walk template-derivation and package-derivation chains via
recursive CTEs and pick the most-derived attribute override per object.
the SQL. Both `HierarchySql` and `AttributesSql` walk template-derivation and
package-derivation chains via recursive CTEs and pick the most-derived
override per object. `HierarchySql` still matches the OtOpcUa original;
`AttributesSql` does not — it additionally enumerates built-in primitive
attributes (see [Built-in vs configured attributes](#built-in-vs-configured-attributes)).
- `GalaxyHierarchyCache`
(`src/MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs`) holds the most
recent immutable `GalaxyHierarchyCacheEntry` (materialized objects +
+75 -49
View File
@@ -3,10 +3,15 @@ using Microsoft.Data.SqlClient;
namespace MxGateway.Server.Galaxy;
/// <summary>
/// SQL access to the AVEVA System Platform Galaxy Repository (ZB) database. Ported from
/// the OtOpcUa project so the row sets stay byte-for-byte identical between the two
/// consumers — the same SQL drives the OPC UA server's address space and this gateway's
/// gRPC browse surface.
/// 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
{
@@ -158,6 +163,16 @@ WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
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
@@ -169,58 +184,69 @@ ORDER BY parent_gobject_id, g.tag_name";
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 (
),
candidate AS (
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,
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,
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
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
LEFT JOIN data_type dt
ON dt.mx_data_type = da.mx_data_type
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)
) ranked
WHERE rn = 1
ORDER BY tag_name, attribute_name";
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";
}