[M3] mxaccess-galaxy: GalaxyTagMetadata + parser + Resolver trait + SQL

Lands M3 stream A — the pure-Rust foundation of the Galaxy resolver:
the data type, the tag-reference parser, the async trait, and the
canonical SQL strings. Unblocks F13 (NmxClient::write_* wrappers depend
on GalaxyTagMetadata) without pulling in tiberius yet.

New
- metadata.rs (~195 LoC, 7 tests) — GalaxyTagMetadata record (port of
  cs:6-73). Includes is_buffer_property + to_reference_handle(galaxy_id)
  bridging into mxaccess-codec::MxReferenceHandle::from_names.
- parser.rs (~330 LoC, 12 tests) — ParsedTagReference parser. Handles
  Object.Attribute (1 candidate), Object.Primitive.Attribute (2
  candidates: primitive-attr first, dotted-attr second per cs:181-185),
  and the case-insensitive .property(buffer) suffix. Pure-Rust, no I/O.
- resolver.rs (~200 LoC, 5 tests including a tokio-driven InMemoryResolver
  proving the trait is implementable without SQL) — async Resolver trait
  + ResolverError. Default browse returns Backend("not implemented") so
  read-only backends don't need to override it.
- sql.rs (~280 LoC, 5 smoke tests) — RESOLVE_SQL + BROWSE_SQL constants
  ported byte-for-byte from cs:208-432. Available publicly so any
  backend (the planned tiberius impl, a wwtools/grdb snapshot replay,
  etc.) can grab the canonical query.

Cargo.toml: added mxaccess-codec (path), async-trait, thiserror;
tokio added as dev-dependency for the resolver-trait async tests.

Deliberately deferred to a later iteration:
- The tiberius-backed Resolver impl behind the galaxy-resolver feature.
- ToValueKind / TryGetValueKind / ProjectWriteValue helpers on
  GalaxyTagMetadata (cs:41-72) — these need a MxDataType -> MxValueKind
  lookup that the codec doesn't currently expose; landing them with
  F13's write-helper iteration keeps the iteration coherent.

Test count delta: 397 -> 427 (+30). All four DoD gates green.
Open followups touched: F13 prerequisite (GalaxyTagMetadata) now in
place; F13 itself stays open until the write helpers wire it up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-05 08:17:16 -04:00
parent 0c772d273d
commit d84b066c62
7 changed files with 1148 additions and 10 deletions
+353
View File
@@ -0,0 +1,353 @@
//! SQL strings used by the future tiberius-backed resolver.
//!
//! Direct port of the two `private const string` blocks at
//! `src/MxNativeClient/GalaxyRepositoryTagResolver.cs:208-432`. Kept as
//! `pub const &str` so any future SQL backend (the planned
//! `tiberius`-gated implementation, an alternative dapper-style backend,
//! or a snapshot-replay test harness) can grab the canonical query without
//! re-typing it.
//!
//! Both queries assume a Galaxy DB that exposes the tables verified in
//! `wwtools/grdb/`:
//!
//! - `dbo.gobject` / `dbo.instance` — object instances + their MX ids.
//! - `dbo.package` (recursive `derived_from_package_id` for inheritance).
//! - `dbo.dynamic_attribute` — dynamic attributes attached to a package.
//! - `dbo.attribute_definition` / `dbo.primitive_instance` — primitive-
//! bound attributes.
//!
//! ## Resolver input contract
//!
//! Both queries take `tag_name`-form input only (e.g. `DelmiaReceiver_001`),
//! NOT `contained_name`-form (`TestMachine_001.DelmiaReceiver`). See
//! `wwtools/grdb/README.md` for the schema asymmetry. The Rust resolver
//! enforces this at the parser layer ([`crate::parser::ParsedTagReference`])
//! before dispatching to SQL.
//!
//! ## Result columns (in order)
//!
//! Both queries return the same 13-column shape — keep this list aligned
//! with [`crate::metadata::GalaxyTagMetadata`] field order:
//!
//! 0. `object_tag_name` `nvarchar`
//! 1. `attribute_name` `nvarchar`
//! 2. `primitive_name` `nvarchar` or `NULL`
//! 3. `mx_platform_id` `smallint` → `u16`
//! 4. `mx_engine_id` `smallint` → `u16`
//! 5. `mx_object_id` `smallint` → `u16`
//! 6. `mx_primitive_id` `smallint` → `i16`
//! 7. `mx_attribute_id` `smallint` → `i16`
//! 8. `property_id` `int` → `i16` (checked-cast)
//! 9. `mx_data_type` `smallint` → `i16`
//! 10. `is_array` `bit` → `bool`
//! 11. `security_classification` `smallint` → `i16`
//! 12. `attribute_source` `nvarchar` ("dynamic" or "primitive")
//!
//! ## Recursive CTE depth
//!
//! Both queries cap package-derivation depth at 10 (`AND dpc.depth < 10`).
//! Galaxy package inheritance chains are typically short (3-5 levels);
//! 10 is a defensive cap against malformed package_id loops. If a real
//! deployment legitimately exceeds this, the cap should be raised here
//! and tracked in `design/70-risks-and-open-questions.md`.
/// Single-row resolver query — `Resolve(tag_reference)`.
///
/// Parameters (in order):
/// - `@objectTagName` (`nvarchar`) — the leading `Object` segment.
/// - `@attributeName` (`nvarchar`) — the trailing `Attribute` segment, or
/// `Primitive.Attribute` for the dotted-attribute candidate.
/// - `@primitiveName` (`nvarchar` or `NULL`) — the middle segment when
/// the input was `Object.Primitive.Attribute`; `NULL` for dynamic-only
/// candidates.
///
/// Direct port of `GalaxyRepositoryTagResolver.cs:208-314` — the `;WITH`
/// `deployed_package_chain`, `ranked_dynamic`, `primitive_attributes`
/// blocks plus the final UNION + `ORDER BY` that prefers `dynamic` rows
/// over `primitive` rows when both match.
pub const RESOLVE_SQL: &str = r#";WITH deployed_package_chain AS (
SELECT
g.gobject_id,
p.package_id,
p.derived_from_package_id,
0 AS depth
FROM dbo.gobject g
INNER JOIN dbo.package p
ON p.package_id = g.deployed_package_id
WHERE g.is_template = 0
AND g.deployed_package_id <> 0
AND g.tag_name = @objectTagName
UNION ALL
SELECT
dpc.gobject_id,
p.package_id,
p.derived_from_package_id,
dpc.depth + 1
FROM deployed_package_chain dpc
INNER JOIN dbo.package p
ON p.package_id = dpc.derived_from_package_id
WHERE dpc.derived_from_package_id <> 0
AND dpc.depth < 10
),
ranked_dynamic AS (
SELECT
g.tag_name AS object_tag_name,
da.attribute_name,
CAST(NULL AS nvarchar(329)) AS primitive_name,
i.mx_platform_id,
i.mx_engine_id,
i.mx_object_id,
da.mx_primitive_id,
da.mx_attribute_id,
CAST(10 AS int) AS property_id,
da.mx_data_type,
da.is_array,
da.security_classification,
CAST(N'dynamic' AS nvarchar(16)) AS attribute_source,
ROW_NUMBER() OVER (
PARTITION BY dpc.gobject_id, da.attribute_name
ORDER BY dpc.depth
) AS rn
FROM deployed_package_chain dpc
INNER JOIN dbo.dynamic_attribute da
ON da.package_id = dpc.package_id
INNER JOIN dbo.gobject g
ON g.gobject_id = dpc.gobject_id
INNER JOIN dbo.instance i
ON i.gobject_id = g.gobject_id
WHERE da.attribute_name = @attributeName
AND @primitiveName IS NULL
),
primitive_attributes AS (
SELECT
g.tag_name AS object_tag_name,
ad.attribute_name,
NULLIF(pi.primitive_name, N'') AS primitive_name,
i.mx_platform_id,
i.mx_engine_id,
i.mx_object_id,
pi.mx_primitive_id,
ad.mx_attribute_id,
CAST(10 AS int) AS property_id,
ad.mx_data_type,
ad.is_array,
ad.security_classification,
CAST(N'primitive' AS nvarchar(16)) AS attribute_source,
1 AS rn
FROM dbo.gobject g
INNER JOIN dbo.instance i
ON i.gobject_id = g.gobject_id
INNER JOIN dbo.primitive_instance pi
ON pi.gobject_id = g.gobject_id
AND pi.package_id = g.deployed_package_id
AND pi.property_bitmask & 0x10 <> 0x10
INNER JOIN dbo.attribute_definition ad
ON ad.primitive_definition_id = pi.primitive_definition_id
WHERE g.tag_name = @objectTagName
AND ad.attribute_name = @attributeName
AND (
(@primitiveName IS NULL AND pi.primitive_name = N'')
OR (@primitiveName IS NOT NULL AND pi.primitive_name = @primitiveName)
)
)
SELECT TOP (1)
object_tag_name,
attribute_name,
primitive_name,
mx_platform_id,
mx_engine_id,
mx_object_id,
mx_primitive_id,
mx_attribute_id,
property_id,
mx_data_type,
is_array,
security_classification,
attribute_source
FROM (
SELECT * FROM ranked_dynamic WHERE rn = 1
UNION ALL
SELECT * FROM primitive_attributes
) resolved
ORDER BY CASE attribute_source WHEN N'dynamic' THEN 0 ELSE 1 END
"#;
/// Multi-row browse query — `Browse(object_tag_like, attribute_like, max_rows)`.
///
/// Parameters:
/// - `@objectTagLike` (`nvarchar`) — `LIKE` pattern for `g.tag_name`.
/// - `@attributeLike` (`nvarchar`) — `LIKE` pattern for `attribute_name`.
/// - `@maxRows` (`int`) — `TOP (...)` cap. The .NET reference clamps to
/// 1000 (`cs:137`); the Rust resolver should do the same before binding
/// the parameter.
///
/// Direct port of `GalaxyRepositoryTagResolver.cs:316-432`. Same column
/// ordering as [`RESOLVE_SQL`].
pub const BROWSE_SQL: &str = r#";WITH deployed_objects AS (
SELECT
g.gobject_id,
g.tag_name,
g.deployed_package_id,
i.mx_platform_id,
i.mx_engine_id,
i.mx_object_id
FROM dbo.gobject g
INNER JOIN dbo.instance i
ON i.gobject_id = g.gobject_id
WHERE g.is_template = 0
AND g.deployed_package_id <> 0
AND g.tag_name LIKE @objectTagLike
),
deployed_package_chain AS (
SELECT
d.gobject_id,
d.tag_name,
d.mx_platform_id,
d.mx_engine_id,
d.mx_object_id,
p.package_id,
p.derived_from_package_id,
0 AS depth
FROM deployed_objects d
INNER JOIN dbo.package p
ON p.package_id = d.deployed_package_id
UNION ALL
SELECT
dpc.gobject_id,
dpc.tag_name,
dpc.mx_platform_id,
dpc.mx_engine_id,
dpc.mx_object_id,
p.package_id,
p.derived_from_package_id,
dpc.depth + 1
FROM deployed_package_chain dpc
INNER JOIN dbo.package p
ON p.package_id = dpc.derived_from_package_id
WHERE dpc.derived_from_package_id <> 0
AND dpc.depth < 10
),
ranked_dynamic AS (
SELECT
dpc.tag_name AS object_tag_name,
da.attribute_name,
CAST(NULL AS nvarchar(329)) AS primitive_name,
dpc.mx_platform_id,
dpc.mx_engine_id,
dpc.mx_object_id,
da.mx_primitive_id,
da.mx_attribute_id,
CAST(10 AS int) AS property_id,
da.mx_data_type,
da.is_array,
da.security_classification,
CAST(N'dynamic' AS nvarchar(16)) AS attribute_source,
ROW_NUMBER() OVER (
PARTITION BY dpc.gobject_id, da.attribute_name
ORDER BY dpc.depth
) AS rn
FROM deployed_package_chain dpc
INNER JOIN dbo.dynamic_attribute da
ON da.package_id = dpc.package_id
WHERE da.attribute_name LIKE @attributeLike
),
primitive_attributes AS (
SELECT
d.tag_name AS object_tag_name,
ad.attribute_name,
NULLIF(pi.primitive_name, N'') AS primitive_name,
d.mx_platform_id,
d.mx_engine_id,
d.mx_object_id,
pi.mx_primitive_id,
ad.mx_attribute_id,
CAST(10 AS int) AS property_id,
ad.mx_data_type,
ad.is_array,
ad.security_classification,
CAST(N'primitive' AS nvarchar(16)) AS attribute_source,
1 AS rn
FROM deployed_objects d
INNER JOIN dbo.gobject g
ON g.gobject_id = d.gobject_id
INNER JOIN dbo.primitive_instance pi
ON pi.gobject_id = g.gobject_id
AND pi.package_id = g.deployed_package_id
AND pi.property_bitmask & 0x10 <> 0x10
INNER JOIN dbo.attribute_definition ad
ON ad.primitive_definition_id = pi.primitive_definition_id
WHERE ad.attribute_name LIKE @attributeLike
)
SELECT TOP (@maxRows)
object_tag_name,
attribute_name,
primitive_name,
mx_platform_id,
mx_engine_id,
mx_object_id,
mx_primitive_id,
mx_attribute_id,
property_id,
mx_data_type,
is_array,
security_classification,
attribute_source
FROM (
SELECT * FROM ranked_dynamic WHERE rn = 1
UNION ALL
SELECT * FROM primitive_attributes
) resolved
ORDER BY object_tag_name, primitive_name, attribute_name
"#;
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic
)]
mod tests {
use super::*;
#[test]
fn resolve_sql_references_three_named_parameters() {
// Smoke check: the three @-parameters the .NET command binds at
// cs:100-102 must appear by name in the query body.
assert!(RESOLVE_SQL.contains("@objectTagName"));
assert!(RESOLVE_SQL.contains("@attributeName"));
assert!(RESOLVE_SQL.contains("@primitiveName"));
}
#[test]
fn browse_sql_references_three_named_parameters() {
assert!(BROWSE_SQL.contains("@objectTagLike"));
assert!(BROWSE_SQL.contains("@attributeLike"));
assert!(BROWSE_SQL.contains("@maxRows"));
}
#[test]
fn resolve_sql_caps_recursion_at_depth_10() {
// Defensive cap — see module doc.
assert!(RESOLVE_SQL.contains("dpc.depth < 10"));
}
#[test]
fn browse_sql_caps_recursion_at_depth_10() {
assert!(BROWSE_SQL.contains("dpc.depth < 10"));
}
#[test]
fn resolve_sql_orders_dynamic_before_primitive() {
// Per cs:313: ORDER BY CASE attribute_source WHEN N'dynamic' THEN 0 ELSE 1 END.
assert!(RESOLVE_SQL.contains("WHEN N'dynamic' THEN 0 ELSE 1 END"));
}
#[test]
fn both_queries_select_thirteen_columns_in_documented_order() {
// Spot-check: the SELECT list ends with attribute_source — the
// last (13th) column.
assert!(RESOLVE_SQL.contains("attribute_source\nFROM ("));
assert!(BROWSE_SQL.contains("attribute_source\nFROM ("));
}
}