diff --git a/docs/GalaxyRepository.md b/docs/GalaxyRepository.md index b9957f1..7353c53 100644 --- a/docs/GalaxyRepository.md +++ b/docs/GalaxyRepository.md @@ -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 + diff --git a/src/MxGateway.Server/Galaxy/GalaxyRepository.cs b/src/MxGateway.Server/Galaxy/GalaxyRepository.cs index 39bc1f6..201030e 100644 --- a/src/MxGateway.Server/Galaxy/GalaxyRepository.cs +++ b/src/MxGateway.Server/Galaxy/GalaxyRepository.cs @@ -3,10 +3,15 @@ using Microsoft.Data.SqlClient; namespace MxGateway.Server.Galaxy; /// -/// 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. +/// +/// 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 { @@ -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"; }