From d84b066c6292887c6496399469d829fd83ad48cc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 5 May 2026 08:17:16 -0400 Subject: [PATCH] [M3] mxaccess-galaxy: GalaxyTagMetadata + parser + Resolver trait + SQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- rust/Cargo.lock | 17 + rust/crates/mxaccess-galaxy/Cargo.toml | 6 + rust/crates/mxaccess-galaxy/src/lib.rs | 42 ++- rust/crates/mxaccess-galaxy/src/metadata.rs | 195 +++++++++++ rust/crates/mxaccess-galaxy/src/parser.rs | 348 +++++++++++++++++++ rust/crates/mxaccess-galaxy/src/resolver.rs | 197 +++++++++++ rust/crates/mxaccess-galaxy/src/sql.rs | 353 ++++++++++++++++++++ 7 files changed, 1148 insertions(+), 10 deletions(-) create mode 100644 rust/crates/mxaccess-galaxy/src/metadata.rs create mode 100644 rust/crates/mxaccess-galaxy/src/parser.rs create mode 100644 rust/crates/mxaccess-galaxy/src/resolver.rs create mode 100644 rust/crates/mxaccess-galaxy/src/sql.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 5e1a947..55b64c6 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2,6 +2,17 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -205,6 +216,12 @@ dependencies = [ [[package]] name = "mxaccess-galaxy" version = "0.0.0" +dependencies = [ + "async-trait", + "mxaccess-codec", + "thiserror", + "tokio", +] [[package]] name = "mxaccess-nmx" diff --git a/rust/crates/mxaccess-galaxy/Cargo.toml b/rust/crates/mxaccess-galaxy/Cargo.toml index 00c7140..68cd8d4 100644 --- a/rust/crates/mxaccess-galaxy/Cargo.toml +++ b/rust/crates/mxaccess-galaxy/Cargo.toml @@ -9,6 +9,12 @@ rust-version.workspace = true authors.workspace = true [dependencies] +mxaccess-codec = { path = "../mxaccess-codec" } +async-trait = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } [features] default = [] diff --git a/rust/crates/mxaccess-galaxy/src/lib.rs b/rust/crates/mxaccess-galaxy/src/lib.rs index a38989b..7ca7b0c 100644 --- a/rust/crates/mxaccess-galaxy/src/lib.rs +++ b/rust/crates/mxaccess-galaxy/src/lib.rs @@ -1,14 +1,36 @@ -//! `mxaccess-galaxy` — Galaxy Repository SQL resolver. +//! `mxaccess-galaxy` — Galaxy Repository tag resolver. //! -//! M0 stub. The real resolver lands in M3 — see `design/60-roadmap.md`. -//! Replicates the recursive CTE from -//! `src/MxNativeClient/GalaxyRepositoryTagResolver.cs:209-293` -//! (`deployed_package_chain`) against the verified table set -//! `dbo.gobject` / `dbo.instance` / `dbo.dynamic_attribute` / -//! `dbo.attribute_definition` / `dbo.primitive_instance` / `dbo.package`. +//! M3 stream A landed: the trait + metadata + parser + canonical SQL +//! constants. The actual `tiberius`-backed implementation behind the +//! `galaxy-resolver` Cargo feature is a follow-up (see +//! `design/followups.md`). //! -//! **Resolver input contract**: `tag_name`-form only (e.g. `DelmiaReceiver_001`), -//! not `contained_name`-form (e.g. `TestMachine_001.DelmiaReceiver`). See -//! `wwtools/grdb/README.md` for the asymmetry. +//! Modules: +//! +//! - [`metadata`] — [`metadata::GalaxyTagMetadata`] record (port of +//! `GalaxyTagMetadata` at `GalaxyRepositoryTagResolver.cs:6-73`). +//! - [`parser`] — [`parser::ParsedTagReference`] (port of `cs:167-206`). +//! Pure-Rust, no I/O. Handles `Object.Attribute` / +//! `Object.Primitive.Attribute` / `.property(buffer)` shapes. +//! - [`resolver`] — [`resolver::Resolver`] async trait + [`resolver::ResolverError`]. +//! - [`sql`] — `RESOLVE_SQL` + `BROWSE_SQL` constants (the recursive +//! `deployed_package_chain` CTE from `cs:208-432`). Exposed publicly +//! so any backend (the future `tiberius` impl, a snapshot replay +//! harness, etc.) can grab the canonical query. +//! +//! **Resolver input contract**: `tag_name`-form only (e.g. +//! `DelmiaReceiver_001`), not `contained_name`-form (e.g. +//! `TestMachine_001.DelmiaReceiver`). See `wwtools/grdb/README.md` for +//! the asymmetry. The parser does not enforce this — the SQL queries do +//! by joining `g.tag_name = @objectTagName` (not `contained_name`). #![forbid(unsafe_code)] + +pub mod metadata; +pub mod parser; +pub mod resolver; +pub mod sql; + +pub use metadata::GalaxyTagMetadata; +pub use parser::{ParseError, ParsedTagReference}; +pub use resolver::{Resolver, ResolverError}; diff --git a/rust/crates/mxaccess-galaxy/src/metadata.rs b/rust/crates/mxaccess-galaxy/src/metadata.rs new file mode 100644 index 0000000..9c3f219 --- /dev/null +++ b/rust/crates/mxaccess-galaxy/src/metadata.rs @@ -0,0 +1,195 @@ +//! `GalaxyTagMetadata` — the resolved attribute-metadata record. +//! +//! Direct port of the `GalaxyTagMetadata` record at the top of +//! `src/MxNativeClient/GalaxyRepositoryTagResolver.cs:6-73`. Carries the +//! exact set of fields the .NET reference reads from a Galaxy SQL row, +//! plus three small derived helpers: +//! +//! - [`GalaxyTagMetadata::is_buffer_property`] (mirrors the `IsBufferProperty` +//! property at `cs:24`). +//! - [`GalaxyTagMetadata::to_reference_handle`] (mirrors `ToReferenceHandle` +//! at `cs:26-39`) — converts the metadata into a wire-ready +//! [`mxaccess_codec::MxReferenceHandle`]. +//! +//! The .NET reference's `ToValueKind`, `TryGetValueKind`, `IsSupportedValueKind`, +//! and `ProjectWriteValue` (`cs:41-72`) are deferred to a future iteration +//! when the high-level `NmxClient::write_*` wrappers (followup F13) actually +//! need them. They reduce to a `MxDataType` → `MxValueKind` lookup table that +//! the codec doesn't currently expose; adding both the lookup and the +//! projection together keeps the iteration that needs them coherent. + +use mxaccess_codec::{CodecError, MxReferenceHandle}; + +/// Resolved Galaxy tag metadata. Field order and types match the .NET +/// `GalaxyTagMetadata` record exactly (`cs:6-19`). +/// +/// # Numeric ranges +/// +/// `platform_id`, `engine_id`, `object_id` are stored as `u16` because they +/// come from `dbo.instance.mx_*_id` (SQL `smallint` checked-cast to ushort +/// in .NET — `cs:155-157`). `primitive_id`, `attribute_id`, `property_id`, +/// `mx_data_type`, `security_classification` are `i16` for the same reason +/// (signed `smallint`). +/// +/// `property_id` is sourced from `SQL int` and checked-cast to `i16` +/// (`cs:160`). The Rust port stores `i16` to match the .NET shape; values +/// outside the i16 range are a SQL-side issue, not a Rust-side issue. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct GalaxyTagMetadata { + pub object_tag_name: String, + pub attribute_name: String, + /// `None` for `dynamic` attributes; `Some(name)` for `primitive`. + /// Mirrors `string? PrimitiveName` (`cs:9`). + pub primitive_name: Option, + pub platform_id: u16, + pub engine_id: u16, + pub object_id: u16, + pub primitive_id: i16, + pub attribute_id: i16, + pub property_id: i16, + pub mx_data_type: i16, + pub is_array: bool, + pub security_classification: i16, + /// Provenance tag — either `"dynamic"` or `"primitive"` per the SQL + /// `attribute_source` column (`cs:247,276,375,399`). + pub attribute_source: String, +} + +impl GalaxyTagMetadata { + /// Default `property_id` for ordinary value attributes — `cs:21`. + pub const VALUE_PROPERTY_ID: i16 = 10; + + /// `property_id` used for `(buffer)` property references — `cs:22`. + pub const BUFFER_PROPERTY_ID: i16 = 50; + + /// `true` when [`Self::property_id`] equals [`Self::BUFFER_PROPERTY_ID`]. + /// Mirrors `IsBufferProperty` (`cs:24`). + #[must_use] + pub const fn is_buffer_property(&self) -> bool { + self.property_id == Self::BUFFER_PROPERTY_ID + } + + /// Build the wire-form [`MxReferenceHandle`] this metadata describes. + /// Mirrors `ToReferenceHandle(byte galaxyId = 1)` (`cs:26-39`). + /// + /// `galaxy_id` defaults to 1 in the .NET signature; the Rust port makes + /// it explicit so callers don't accidentally use `0` (which would + /// produce a different wire handle). + /// + /// # Errors + /// + /// Propagates [`CodecError::InvalidName`] from + /// [`MxReferenceHandle::from_names`] when either name is empty or + /// whitespace-only. + pub fn to_reference_handle(&self, galaxy_id: u8) -> Result { + MxReferenceHandle::from_names( + galaxy_id, + self.platform_id, + self.engine_id, + self.object_id, + &self.object_tag_name, + self.primitive_id, + self.attribute_id, + self.property_id, + &self.attribute_name, + self.is_array, + ) + } +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic +)] +mod tests { + use super::*; + + fn sample( + property_id: i16, + primitive_id: i16, + primitive_name: Option<&str>, + ) -> GalaxyTagMetadata { + GalaxyTagMetadata { + object_tag_name: "TestObject_001".to_string(), + attribute_name: "TestInt".to_string(), + primitive_name: primitive_name.map(str::to_string), + platform_id: 5, + engine_id: 7, + object_id: 42, + primitive_id, + attribute_id: 99, + property_id, + mx_data_type: 4, + is_array: false, + security_classification: 0, + attribute_source: if primitive_name.is_some() { + "primitive" + } else { + "dynamic" + } + .to_string(), + } + } + + #[test] + fn property_id_constants_match_dotnet() { + // .NET `GalaxyTagMetadata.ValuePropertyId` and `BufferPropertyId` at cs:21-22. + assert_eq!(GalaxyTagMetadata::VALUE_PROPERTY_ID, 10); + assert_eq!(GalaxyTagMetadata::BUFFER_PROPERTY_ID, 50); + } + + #[test] + fn is_buffer_property_true_only_for_50() { + assert!(!sample(10, 0, None).is_buffer_property()); + assert!(sample(50, 0, None).is_buffer_property()); + assert!(!sample(0, 0, None).is_buffer_property()); + assert!(!sample(11, 0, None).is_buffer_property()); + } + + #[test] + fn to_reference_handle_builds_wire_handle() { + let meta = sample(10, -1, None); // primitive_id = -1, the .NET "no primitive" sentinel + let handle = meta.to_reference_handle(1).unwrap(); + assert_eq!(handle.galaxy_id, 1); + assert_eq!(handle.platform_id, 5); + assert_eq!(handle.engine_id, 7); + assert_eq!(handle.object_id, 42); + assert_eq!(handle.attribute_id, 99); + assert_eq!(handle.property_id, 10); + // is_array = false → attribute_index = 0 per MxReferenceHandle::from_names. + assert_eq!(handle.attribute_index, 0); + } + + #[test] + fn to_reference_handle_array_sets_attribute_index_minus_one() { + let mut meta = sample(10, 0, None); + meta.is_array = true; + let handle = meta.to_reference_handle(1).unwrap(); + assert_eq!(handle.attribute_index, -1); + } + + #[test] + fn to_reference_handle_rejects_empty_name() { + let mut meta = sample(10, 0, None); + meta.object_tag_name = " ".to_string(); + let err = meta.to_reference_handle(1).unwrap_err(); + assert!(matches!(err, CodecError::InvalidName)); + } + + #[test] + fn primitive_name_round_trips() { + let meta = sample(10, 0, Some("DelmiaReceiver")); + assert_eq!(meta.primitive_name.as_deref(), Some("DelmiaReceiver")); + assert_eq!(meta.attribute_source, "primitive"); + } + + #[test] + fn dynamic_metadata_has_no_primitive_name() { + let meta = sample(10, 0, None); + assert_eq!(meta.primitive_name, None); + assert_eq!(meta.attribute_source, "dynamic"); + } +} diff --git a/rust/crates/mxaccess-galaxy/src/parser.rs b/rust/crates/mxaccess-galaxy/src/parser.rs new file mode 100644 index 0000000..790e8fe --- /dev/null +++ b/rust/crates/mxaccess-galaxy/src/parser.rs @@ -0,0 +1,348 @@ +//! Tag-reference parser. +//! +//! Direct port of the inner `ParsedTagReference` record + `ParseCandidates` +//! / `ParsePropertySuffix` helpers from +//! `src/MxNativeClient/GalaxyRepositoryTagResolver.cs:167-206`. +//! +//! The .NET reference accepts three input shapes for `ResolveAsync`: +//! +//! 1. `Object.Attribute` — 2 dot-separated parts, no primitive, dynamic only. +//! 2. `Object.Primitive.Attribute` — 3+ parts. **Two candidates** are +//! produced: one treating part 1 as the primitive (a primitive-attribute +//! lookup), one treating the entire `Primitive.Attribute` tail as a +//! dotted attribute name on a dynamic attribute (a dynamic lookup). The +//! SQL UNION returns the first that matches, with `dynamic` preferred +//! when both match. +//! 3. Either of the above with the `.property(buffer)` suffix — strips the +//! suffix and overrides `property_id` with +//! [`GalaxyTagMetadata::BUFFER_PROPERTY_ID`]. +//! +//! Anything else (one part, empty, only whitespace) is rejected with a +//! [`ParseError`]. + +// Each indexed/sliced access into `parts` is preceded by an explicit +// length check via the `match parts.len()` arm, so the indexing is +// statically known to be in-bounds. `.get(n).copied().unwrap_or(...)` +// would obscure that 1:1 mirror of the .NET `parts[0]`/`parts[1]`/ +// `parts[2..]` shape at `cs:180-184`. +#![allow(clippy::indexing_slicing)] + +use crate::metadata::GalaxyTagMetadata; +use thiserror::Error; + +/// Errors raised by [`ParsedTagReference::parse_candidates`]. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[non_exhaustive] +pub enum ParseError { + /// Tag reference was empty or whitespace-only. Mirrors + /// `ArgumentException.ThrowIfNullOrWhiteSpace` at `cs:175`. + #[error("tag reference must not be empty or whitespace-only")] + Empty, + + /// Tag reference produced fewer than two dot-separated parts. + /// Mirrors the `_ =>` arm at `cs:186`. + #[error("tag reference must be Object.Attribute or Object.Primitive.Attribute")] + InvalidShape, + + /// `.property(buffer)` suffix with no base reference. Mirrors + /// `cs:196-199`. + #[error("property reference must include a base tag reference")] + EmptyBaseBeforePropertySuffix, +} + +/// One parsed candidate. The .NET reference returns a list of these +/// because a 3-part input is ambiguous (primitive vs dotted attribute). +/// +/// Mirrors the inner record at `cs:167-171`. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ParsedTagReference { + pub object_tag_name: String, + pub primitive_name: Option, + pub attribute_name: String, + /// When `Some`, the resolver replaces the metadata's `property_id` + /// with this value. Mirrors the `with { PropertyId = override }` at + /// `cs:108-110`. Currently set only for the `.property(buffer)` + /// suffix. + pub property_id_override: Option, +} + +const PROPERTY_BUFFER_SUFFIX: &str = ".property(buffer)"; + +impl ParsedTagReference { + /// Parse a tag reference into one or more candidates. Mirrors + /// `ParseCandidates` (`cs:173-188`). + /// + /// Returns at least one candidate; for 3+-part inputs returns 2 + /// candidates (primitive-attribute first, dotted-attribute second) + /// matching the .NET ordering at `cs:181-185`. + /// + /// # Errors + /// [`ParseError::Empty`], [`ParseError::InvalidShape`], or + /// [`ParseError::EmptyBaseBeforePropertySuffix`]. + pub fn parse_candidates(tag_reference: &str) -> Result, ParseError> { + if tag_reference.trim().is_empty() { + return Err(ParseError::Empty); + } + + let (base_reference, property_id_override) = parse_property_suffix(tag_reference)?; + + // Split on `.`, drop empty parts, trim each (mirrors + // `StringSplitOptions.RemoveEmptyEntries | TrimEntries` at cs:177). + let parts: Vec<&str> = base_reference + .split('.') + .map(str::trim) + .filter(|p| !p.is_empty()) + .collect(); + + match parts.len() { + 0 | 1 => Err(ParseError::InvalidShape), + 2 => Ok(vec![Self { + object_tag_name: parts[0].to_string(), + primitive_name: None, + attribute_name: parts[1].to_string(), + property_id_override, + }]), + _ => { + // 3+ parts — produce both candidates per cs:181-185. + let object = parts[0].to_string(); + let primitive_first = parts[1].to_string(); + let attr_after_primitive = parts[2..].join("."); + let attr_after_object = parts[1..].join("."); + Ok(vec![ + Self { + object_tag_name: object.clone(), + primitive_name: Some(primitive_first), + attribute_name: attr_after_primitive, + property_id_override, + }, + Self { + object_tag_name: object, + primitive_name: None, + attribute_name: attr_after_object, + property_id_override, + }, + ]) + } + } + } + + /// Apply this candidate's `property_id_override` to the resolved + /// metadata. The .NET reference does this with `metadata with + /// { PropertyId = override }` at `cs:108-110`; the Rust port exposes + /// it as a method so resolver impls can stay short. + #[must_use] + pub fn apply_overrides(&self, metadata: GalaxyTagMetadata) -> GalaxyTagMetadata { + let mut out = metadata; + if let Some(pid) = self.property_id_override { + out.property_id = pid; + } + out + } +} + +/// Mirrors `ParsePropertySuffix` (`cs:190-205`). Returns `(base_reference, +/// property_id_override)`. +/// +/// Currently only `.property(buffer)` is recognised; the suffix match is +/// case-insensitive (`StringComparison.OrdinalIgnoreCase` at `cs:193`). +fn parse_property_suffix(tag_reference: &str) -> Result<(&str, Option), ParseError> { + if tag_reference.len() >= PROPERTY_BUFFER_SUFFIX.len() { + let suffix_start = tag_reference.len() - PROPERTY_BUFFER_SUFFIX.len(); + let suffix = &tag_reference[suffix_start..]; + if suffix.eq_ignore_ascii_case(PROPERTY_BUFFER_SUFFIX) { + let base = &tag_reference[..suffix_start]; + if base.trim().is_empty() { + return Err(ParseError::EmptyBaseBeforePropertySuffix); + } + return Ok((base, Some(GalaxyTagMetadata::BUFFER_PROPERTY_ID))); + } + } + Ok((tag_reference, None)) +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic +)] +mod tests { + use super::*; + + #[test] + fn empty_reference_rejected() { + assert_eq!( + ParsedTagReference::parse_candidates(""), + Err(ParseError::Empty) + ); + assert_eq!( + ParsedTagReference::parse_candidates(" "), + Err(ParseError::Empty) + ); + } + + #[test] + fn single_part_rejected_as_invalid_shape() { + assert_eq!( + ParsedTagReference::parse_candidates("OnlyOneSegment"), + Err(ParseError::InvalidShape) + ); + } + + #[test] + fn two_part_returns_single_dynamic_candidate() { + let candidates = ParsedTagReference::parse_candidates("Obj.Attr").unwrap(); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].object_tag_name, "Obj"); + assert_eq!(candidates[0].primitive_name, None); + assert_eq!(candidates[0].attribute_name, "Attr"); + assert_eq!(candidates[0].property_id_override, None); + } + + #[test] + fn three_part_returns_primitive_then_dynamic_candidates() { + let candidates = ParsedTagReference::parse_candidates("Obj.Prim.Attr").unwrap(); + assert_eq!(candidates.len(), 2); + + // Candidate 1: primitive-attribute lookup. + assert_eq!(candidates[0].object_tag_name, "Obj"); + assert_eq!(candidates[0].primitive_name.as_deref(), Some("Prim")); + assert_eq!(candidates[0].attribute_name, "Attr"); + + // Candidate 2: dynamic-attribute lookup with dotted attribute name. + assert_eq!(candidates[1].object_tag_name, "Obj"); + assert_eq!(candidates[1].primitive_name, None); + assert_eq!(candidates[1].attribute_name, "Prim.Attr"); + } + + #[test] + fn four_part_joins_attribute_with_dots() { + // Per cs:183-184: `string.Join('.', parts.Skip(2))` and + // `string.Join('.', parts.Skip(1))`. + let candidates = ParsedTagReference::parse_candidates("Obj.A.B.C").unwrap(); + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[0].primitive_name.as_deref(), Some("A")); + assert_eq!(candidates[0].attribute_name, "B.C"); + assert_eq!(candidates[1].primitive_name, None); + assert_eq!(candidates[1].attribute_name, "A.B.C"); + } + + #[test] + fn whitespace_around_parts_is_trimmed() { + // Mirrors StringSplitOptions.TrimEntries (cs:177). + let candidates = ParsedTagReference::parse_candidates(" Obj . Attr ").unwrap(); + assert_eq!(candidates[0].object_tag_name, "Obj"); + assert_eq!(candidates[0].attribute_name, "Attr"); + } + + #[test] + fn empty_segments_dropped() { + // Mirrors RemoveEmptyEntries (cs:177). Multiple consecutive dots + // yield empty segments which are dropped before the count check. + let candidates = ParsedTagReference::parse_candidates("Obj..Attr").unwrap(); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].object_tag_name, "Obj"); + assert_eq!(candidates[0].attribute_name, "Attr"); + } + + #[test] + fn property_buffer_suffix_overrides_property_id() { + let candidates = ParsedTagReference::parse_candidates("Obj.Attr.property(buffer)").unwrap(); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].object_tag_name, "Obj"); + assert_eq!(candidates[0].attribute_name, "Attr"); + assert_eq!( + candidates[0].property_id_override, + Some(GalaxyTagMetadata::BUFFER_PROPERTY_ID) + ); + } + + #[test] + fn property_buffer_suffix_is_case_insensitive() { + let candidates = ParsedTagReference::parse_candidates("Obj.Attr.PROPERTY(BUFFER)").unwrap(); + assert_eq!( + candidates[0].property_id_override, + Some(GalaxyTagMetadata::BUFFER_PROPERTY_ID) + ); + } + + #[test] + fn property_buffer_suffix_with_empty_base_rejected() { + assert_eq!( + ParsedTagReference::parse_candidates(".property(buffer)"), + Err(ParseError::EmptyBaseBeforePropertySuffix) + ); + } + + #[test] + fn property_buffer_suffix_propagates_to_three_part_candidates() { + let candidates = + ParsedTagReference::parse_candidates("Obj.Prim.Attr.property(buffer)").unwrap(); + assert_eq!(candidates.len(), 2); + assert_eq!( + candidates[0].property_id_override, + Some(GalaxyTagMetadata::BUFFER_PROPERTY_ID) + ); + assert_eq!( + candidates[1].property_id_override, + Some(GalaxyTagMetadata::BUFFER_PROPERTY_ID) + ); + } + + #[test] + fn apply_overrides_replaces_property_id_when_set() { + use crate::metadata::GalaxyTagMetadata; + let candidate = ParsedTagReference { + object_tag_name: "Obj".to_string(), + primitive_name: None, + attribute_name: "Attr".to_string(), + property_id_override: Some(50), + }; + let metadata = GalaxyTagMetadata { + object_tag_name: "Obj".to_string(), + attribute_name: "Attr".to_string(), + primitive_name: None, + platform_id: 1, + engine_id: 1, + object_id: 1, + primitive_id: 0, + attribute_id: 1, + property_id: 10, + mx_data_type: 4, + is_array: false, + security_classification: 0, + attribute_source: "dynamic".to_string(), + }; + let updated = candidate.apply_overrides(metadata); + assert_eq!(updated.property_id, 50); + } + + #[test] + fn apply_overrides_no_op_when_unset() { + use crate::metadata::GalaxyTagMetadata; + let candidate = ParsedTagReference { + object_tag_name: "Obj".to_string(), + primitive_name: None, + attribute_name: "Attr".to_string(), + property_id_override: None, + }; + let metadata = GalaxyTagMetadata { + object_tag_name: "Obj".to_string(), + attribute_name: "Attr".to_string(), + primitive_name: None, + platform_id: 1, + engine_id: 1, + object_id: 1, + primitive_id: 0, + attribute_id: 1, + property_id: 10, + mx_data_type: 4, + is_array: false, + security_classification: 0, + attribute_source: "dynamic".to_string(), + }; + let updated = candidate.apply_overrides(metadata); + assert_eq!(updated.property_id, 10); + } +} diff --git a/rust/crates/mxaccess-galaxy/src/resolver.rs b/rust/crates/mxaccess-galaxy/src/resolver.rs new file mode 100644 index 0000000..c7758e0 --- /dev/null +++ b/rust/crates/mxaccess-galaxy/src/resolver.rs @@ -0,0 +1,197 @@ +//! `Resolver` async trait — pluggable backend for tag-name lookup. +//! +//! The .NET reference's `GalaxyRepositoryTagResolver` is a single concrete +//! class with a SQL backend. The Rust port splits the surface into a trait +//! plus one provided implementation (`tiberius`-backed, gated by the +//! `galaxy-resolver` Cargo feature) so consumers can plug in any other +//! backing — an in-memory cache for tests, a JSON snapshot from +//! `wwtools/grdb/`, a future REST client, etc. +//! +//! Both [`Resolver::resolve`] and [`Resolver::browse`] mirror the .NET +//! `ResolveAsync` and `BrowseAsync` signatures (`GalaxyRepositoryTagResolver.cs:88,117`). + +use crate::metadata::GalaxyTagMetadata; +use crate::parser::ParseError; +use async_trait::async_trait; +use thiserror::Error; + +/// Errors raised by [`Resolver`] implementations. Mirrors the +/// `InvalidOperationException` raised by .NET when a tag is not found +/// (`GalaxyRepositoryTagResolver.cs:114`) plus parser failures from +/// [`crate::parser::ParsedTagReference::parse_candidates`]. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum ResolverError { + /// The tag-reference string itself failed to parse. + #[error("invalid tag reference: {0}")] + InvalidTagReference(#[from] ParseError), + + /// No metadata row matched any of the parsed candidates. Mirrors + /// `cs:114`. + #[error( + "Galaxy tag reference '{tag_reference}' was not found in the deployed repository metadata" + )] + NotFound { tag_reference: String }, + + /// Backend-specific failure (SQL connect / query error, transport + /// error, etc.). Carries an opaque message so backends don't have to + /// expose their concrete error types in the trait. + #[error("Galaxy resolver backend error: {message}")] + Backend { message: String }, +} + +/// Pluggable async tag-name → metadata resolver. +/// +/// Implementations should be thread-safe (`Send + Sync`) so the Rust +/// `NmxClient` can hold one in an `Arc` shared across +/// multiple write/advise calls. +#[async_trait] +pub trait Resolver: Send + Sync { + /// Resolve a single tag reference (`Object.Attribute`, + /// `Object.Primitive.Attribute`, optionally `.property(buffer)`- + /// suffixed) to its metadata. Mirrors `ResolveAsync` + /// (`GalaxyRepositoryTagResolver.cs:88-115`). + /// + /// # Errors + /// [`ResolverError::InvalidTagReference`], [`ResolverError::NotFound`], + /// or [`ResolverError::Backend`]. + async fn resolve(&self, tag_reference: &str) -> Result; + + /// Browse multiple matching attributes. The default implementation + /// returns [`ResolverError::Backend`] with `"browse not implemented"` + /// — backends that support browsing override this. Mirrors + /// `BrowseAsync` (`cs:117-147`). + /// + /// `object_tag_like` and `attribute_like` use SQL `LIKE` semantics + /// (`%` for any-sequence, `_` for any-single-char). + /// + /// # Errors + /// As for [`Self::resolve`]. + async fn browse( + &self, + object_tag_like: &str, + attribute_like: &str, + max_rows: usize, + ) -> Result, ResolverError> { + let _ = (object_tag_like, attribute_like, max_rows); + Err(ResolverError::Backend { + message: "browse not implemented for this resolver".to_string(), + }) + } +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic +)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::sync::Mutex; + + /// Tiny in-memory resolver for tests. Demonstrates that the trait is + /// implementable without any SQL machinery, validating the + /// "pluggable backend" design. + struct InMemoryResolver { + rows: HashMap, + browse_calls: Mutex>, + } + + impl InMemoryResolver { + fn new() -> Self { + let mut rows = HashMap::new(); + rows.insert( + "TestObject.TestAttr".to_string(), + GalaxyTagMetadata { + object_tag_name: "TestObject".to_string(), + attribute_name: "TestAttr".to_string(), + primitive_name: None, + platform_id: 1, + engine_id: 2, + object_id: 3, + primitive_id: 0, + attribute_id: 7, + property_id: 10, + mx_data_type: 4, + is_array: false, + security_classification: 0, + attribute_source: "dynamic".to_string(), + }, + ); + Self { + rows, + browse_calls: Mutex::new(Vec::new()), + } + } + } + + #[async_trait] + impl Resolver for InMemoryResolver { + async fn resolve(&self, tag_reference: &str) -> Result { + self.rows + .get(tag_reference) + .cloned() + .ok_or_else(|| ResolverError::NotFound { + tag_reference: tag_reference.to_string(), + }) + } + + async fn browse( + &self, + object_tag_like: &str, + attribute_like: &str, + max_rows: usize, + ) -> Result, ResolverError> { + self.browse_calls.lock().unwrap().push(( + object_tag_like.to_string(), + attribute_like.to_string(), + max_rows, + )); + Ok(self.rows.values().take(max_rows).cloned().collect()) + } + } + + #[tokio::test(flavor = "current_thread")] + async fn in_memory_resolver_round_trip() { + let r = InMemoryResolver::new(); + let m = r.resolve("TestObject.TestAttr").await.unwrap(); + assert_eq!(m.object_tag_name, "TestObject"); + assert_eq!(m.attribute_name, "TestAttr"); + } + + #[tokio::test(flavor = "current_thread")] + async fn in_memory_resolver_not_found() { + let r = InMemoryResolver::new(); + let err = r.resolve("DoesNotExist.X").await.unwrap_err(); + assert!(matches!(err, ResolverError::NotFound { .. })); + } + + #[tokio::test(flavor = "current_thread")] + async fn default_browse_returns_backend_error() { + // Concrete impl that doesn't override browse picks up the default. + struct NoBrowse; + #[async_trait] + impl Resolver for NoBrowse { + async fn resolve(&self, _: &str) -> Result { + unimplemented!() + } + } + let err = NoBrowse.browse("%", "%", 10).await.unwrap_err(); + match err { + ResolverError::Backend { message } => { + assert!(message.contains("browse not implemented")); + } + other => panic!("expected Backend, got {other:?}"), + } + } + + #[test] + fn parse_error_converts_into_resolver_error() { + // ResolverError::from(ParseError::Empty) via #[from]. + let e: ResolverError = ParseError::Empty.into(); + assert!(matches!(e, ResolverError::InvalidTagReference(_))); + } +} diff --git a/rust/crates/mxaccess-galaxy/src/sql.rs b/rust/crates/mxaccess-galaxy/src/sql.rs new file mode 100644 index 0000000..c3cedc9 --- /dev/null +++ b/rust/crates/mxaccess-galaxy/src/sql.rs @@ -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 (")); + } +}