From 68aa2e30ab2de391c27fe9c7fcd2eb2f20fdf52a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 5 May 2026 08:33:42 -0400 Subject: [PATCH] [M3] codec+galaxy: MxValueKind::for_data_type + GalaxyTagMetadata::resolve_write_kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last codec-side prerequisite before F13 (NmxClient high-level write wrappers) can land. Two small additions, both wire-byte-direct ports of the .NET reference's MxDataType → MxValueKind lookup logic. mxaccess-codec - MxValueKind::for_data_type(MxDataType, is_array) -> Option: fuses NmxWriteMessage.cs:58-86 (TryGetValueKind's 12 base mappings for data types 1..=6 scalar+array) with the two scalar fallbacks the .NET GalaxyTagMetadata.ProjectWriteValue layers on top (GalaxyRepositoryTagResolver.cs:65-69): ElapsedTime → Int32, InternationalizedString → String. Returns None for any other combination — including arrays of those two types and unsupported scalars (ReferenceType, StatusType, Enum, etc.). - 6 new tests covering the base table, both fallbacks, the array-of- unsupported rejection, and the no-mapping branch for ReferenceType / StatusType / Enum / DataQualityType / BigString / Unknown / NoData / End sentinels. mxaccess-galaxy - GalaxyTagMetadata::resolve_write_kind() -> Result: pure delegation to MxValueKind::for_data_type + a typed error carrying (mx_data_type, is_array) for diagnostics. - GalaxyTagMetadata::is_writable() — Ok-side accessor for browse UIs. - UnsupportedDataType public error type (re-exported from lib.rs). - 7 new tests: Double scalar → Float64, Boolean array → BoolArray, ElapsedTime scalar → Int32 (the fallback path), array-of-ElapsedTime rejected, InternationalizedString → String, ReferenceType rejected, Unknown sentinel rejected. Test count delta: 446 -> 459 (+13; codec 215 -> 221, galaxy 49 -> 56). All four DoD gates green. Co-Authored-By: Claude Opus 4.7 (1M context) --- rust/crates/mxaccess-codec/src/value.rs | 177 ++++++++++++++++++++ rust/crates/mxaccess-galaxy/src/lib.rs | 2 +- rust/crates/mxaccess-galaxy/src/metadata.rs | 137 ++++++++++++++- 3 files changed, 307 insertions(+), 9 deletions(-) diff --git a/rust/crates/mxaccess-codec/src/value.rs b/rust/crates/mxaccess-codec/src/value.rs index f204f58..4a45673 100644 --- a/rust/crates/mxaccess-codec/src/value.rs +++ b/rust/crates/mxaccess-codec/src/value.rs @@ -116,6 +116,61 @@ impl MxValueKind { pub fn to_u8(self) -> u8 { self as u8 } + + /// Map a model-side `(MxDataType, is_array)` pair to the wire-side + /// `MxValueKind` the LMX server expects on a Write body. + /// + /// Mirrors `NmxWriteMessage.GetValueKind` + `TryGetValueKind` + /// (`NmxWriteMessage.cs:58-86`) **plus** the two scalar fallbacks the + /// .NET `GalaxyTagMetadata.ProjectWriteValue` + /// (`GalaxyRepositoryTagResolver.cs:53-72`) layers on top: + /// + /// - `ElapsedTime` (scalar) → `Int32`. The .NET reference converts a + /// `TimeSpan` value to `int totalMilliseconds` at `cs:67-68`; the + /// wire kind is `Int32` regardless of the source CLR type. + /// - `InternationalizedString` (scalar) → `String` + /// (`cs:69`). + /// + /// Returns `None` for any other combination — including arrays of + /// `ElapsedTime` / `InternationalizedString` / `Enum` / `BigString`, + /// which the .NET reference explicitly rejects at `cs:60-63`. + /// + /// The 12 base mappings (data types 1..=6, scalar and array each): + /// + /// ```text + /// (Boolean, false) → Boolean (Boolean, true) → BoolArray + /// (Integer, false) → Int32 (Integer, true) → Int32Array + /// (Float, false) → Float32 (Float, true) → Float32Array + /// (Double, false) → Float64 (Double, true) → Float64Array + /// (String, false) → String (String, true) → StringArray + /// (Time, false) → DateTime (Time, true) → DateTimeArray + /// ``` + #[must_use] + pub fn for_data_type(data_type: MxDataType, is_array: bool) -> Option { + match (data_type, is_array) { + (MxDataType::Boolean, false) => Some(MxValueKind::Boolean), + (MxDataType::Integer, false) => Some(MxValueKind::Int32), + (MxDataType::Float, false) => Some(MxValueKind::Float32), + (MxDataType::Double, false) => Some(MxValueKind::Float64), + (MxDataType::String, false) => Some(MxValueKind::String), + (MxDataType::Time, false) => Some(MxValueKind::DateTime), + (MxDataType::Boolean, true) => Some(MxValueKind::BoolArray), + (MxDataType::Integer, true) => Some(MxValueKind::Int32Array), + (MxDataType::Float, true) => Some(MxValueKind::Float32Array), + (MxDataType::Double, true) => Some(MxValueKind::Float64Array), + (MxDataType::String, true) => Some(MxValueKind::StringArray), + (MxDataType::Time, true) => Some(MxValueKind::DateTimeArray), + // ProjectWriteValue scalar fallbacks (`cs:65-69`): + (MxDataType::ElapsedTime, false) => Some(MxValueKind::Int32), + (MxDataType::InternationalizedString, false) => Some(MxValueKind::String), + // Everything else (arrays of unsupported types, or unsupported + // scalars like ReferenceType / StatusType / Enum / etc.) is + // rejected. Mirrors the `_ => Return(default, out valueKind, + // success: false)` arm at `cs:84` plus the + // `ArgumentOutOfRangeException` paths at `cs:62,70`. + _ => None, + } + } } /// Attribute-model data type — port of `MxDataType.cs:3-24`. @@ -468,4 +523,126 @@ mod tests { assert_eq!(MxDataType::default(), MxDataType::Unknown); assert_eq!(MxDataType::default().to_i16(), -1); } + + #[test] + fn for_data_type_scalar_base_table() { + // Mirrors NmxWriteMessage.cs:72-77 (scalar arms of TryGetValueKind). + assert_eq!( + MxValueKind::for_data_type(MxDataType::Boolean, false), + Some(MxValueKind::Boolean) + ); + assert_eq!( + MxValueKind::for_data_type(MxDataType::Integer, false), + Some(MxValueKind::Int32) + ); + assert_eq!( + MxValueKind::for_data_type(MxDataType::Float, false), + Some(MxValueKind::Float32) + ); + assert_eq!( + MxValueKind::for_data_type(MxDataType::Double, false), + Some(MxValueKind::Float64) + ); + assert_eq!( + MxValueKind::for_data_type(MxDataType::String, false), + Some(MxValueKind::String) + ); + assert_eq!( + MxValueKind::for_data_type(MxDataType::Time, false), + Some(MxValueKind::DateTime) + ); + } + + #[test] + fn for_data_type_array_base_table() { + // Mirrors NmxWriteMessage.cs:78-83 (array arms of TryGetValueKind). + assert_eq!( + MxValueKind::for_data_type(MxDataType::Boolean, true), + Some(MxValueKind::BoolArray) + ); + assert_eq!( + MxValueKind::for_data_type(MxDataType::Integer, true), + Some(MxValueKind::Int32Array) + ); + assert_eq!( + MxValueKind::for_data_type(MxDataType::Float, true), + Some(MxValueKind::Float32Array) + ); + assert_eq!( + MxValueKind::for_data_type(MxDataType::Double, true), + Some(MxValueKind::Float64Array) + ); + assert_eq!( + MxValueKind::for_data_type(MxDataType::String, true), + Some(MxValueKind::StringArray) + ); + assert_eq!( + MxValueKind::for_data_type(MxDataType::Time, true), + Some(MxValueKind::DateTimeArray) + ); + } + + #[test] + fn for_data_type_elapsed_time_scalar_falls_back_to_int32() { + // GalaxyRepositoryTagResolver.cs:67-68: ElapsedTime scalar maps to + // Int32 (caller is expected to convert TimeSpan to milliseconds). + assert_eq!( + MxValueKind::for_data_type(MxDataType::ElapsedTime, false), + Some(MxValueKind::Int32) + ); + } + + #[test] + fn for_data_type_internationalized_string_scalar_falls_back_to_string() { + // GalaxyRepositoryTagResolver.cs:69. + assert_eq!( + MxValueKind::for_data_type(MxDataType::InternationalizedString, false), + Some(MxValueKind::String) + ); + } + + #[test] + fn for_data_type_array_of_unsupported_returns_none() { + // GalaxyRepositoryTagResolver.cs:60-63 explicitly rejects array of + // unsupported types — no fallback applies in the array case. + assert_eq!( + MxValueKind::for_data_type(MxDataType::ElapsedTime, true), + None + ); + assert_eq!( + MxValueKind::for_data_type(MxDataType::InternationalizedString, true), + None + ); + assert_eq!(MxValueKind::for_data_type(MxDataType::Enum, true), None); + assert_eq!( + MxValueKind::for_data_type(MxDataType::BigString, true), + None + ); + } + + #[test] + fn for_data_type_unsupported_scalars_return_none() { + // ReferenceType, StatusType, Enum, etc. are not in either the base + // table or the ProjectWriteValue fallbacks → None. + assert_eq!( + MxValueKind::for_data_type(MxDataType::ReferenceType, false), + None + ); + assert_eq!( + MxValueKind::for_data_type(MxDataType::StatusType, false), + None + ); + assert_eq!(MxValueKind::for_data_type(MxDataType::Enum, false), None); + assert_eq!( + MxValueKind::for_data_type(MxDataType::DataQualityType, false), + None + ); + assert_eq!( + MxValueKind::for_data_type(MxDataType::BigString, false), + None + ); + assert_eq!(MxValueKind::for_data_type(MxDataType::Unknown, false), None); + assert_eq!(MxValueKind::for_data_type(MxDataType::NoData, false), None); + assert_eq!(MxValueKind::for_data_type(MxDataType::End, false), None); + } } diff --git a/rust/crates/mxaccess-galaxy/src/lib.rs b/rust/crates/mxaccess-galaxy/src/lib.rs index 4f27f28..ecf7d47 100644 --- a/rust/crates/mxaccess-galaxy/src/lib.rs +++ b/rust/crates/mxaccess-galaxy/src/lib.rs @@ -33,7 +33,7 @@ pub mod role_blob; pub mod sql; pub mod user; -pub use metadata::GalaxyTagMetadata; +pub use metadata::{GalaxyTagMetadata, UnsupportedDataType}; pub use parser::{ParseError, ParsedTagReference}; pub use resolver::{Resolver, ResolverError}; pub use role_blob::parse_role_blob; diff --git a/rust/crates/mxaccess-galaxy/src/metadata.rs b/rust/crates/mxaccess-galaxy/src/metadata.rs index 9c3f219..381deda 100644 --- a/rust/crates/mxaccess-galaxy/src/metadata.rs +++ b/rust/crates/mxaccess-galaxy/src/metadata.rs @@ -3,22 +3,42 @@ //! 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: +//! plus four 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`]. +//! - [`GalaxyTagMetadata::resolve_write_kind`] + [`GalaxyTagMetadata::is_writable`] +//! (mirrors the `MxDataType` → `MxValueKind` selection from +//! `ToValueKind` / `TryGetValueKind` / `IsSupportedValueKind` / +//! `ProjectWriteValue` at `cs:41-72`). Delegates to +//! [`MxValueKind::for_data_type`] in `mxaccess-codec` which fuses the +//! primary `NmxWriteMessage.GetValueKind` table with the two scalar +//! fallbacks (`ElapsedTime` → `Int32`, `InternationalizedString` → +//! `String`). //! -//! 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. +//! What's still deferred: the value-side of `ProjectWriteValue` +//! (`cs:53-72`) — converting a caller-supplied value (e.g. .NET `TimeSpan`) +//! into the `MxValue` variant the wire kind expects. That belongs at the +//! consumer boundary in Rust, not in the metadata; F13's `NmxClient::write_*` +//! wrappers will handle it. -use mxaccess_codec::{CodecError, MxReferenceHandle}; +use mxaccess_codec::{CodecError, MxDataType, MxReferenceHandle, MxValueKind}; +use thiserror::Error; + +/// Returned by [`GalaxyTagMetadata::resolve_write_kind`] when the metadata's +/// `(mx_data_type, is_array)` combination is not writable on the LMX wire. +/// +/// Mirrors the `ArgumentOutOfRangeException` paths in the .NET reference +/// at `NmxWriteMessage.cs:62-65,108` and `GalaxyRepositoryTagResolver.cs:62,70`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[error("MX data type {mx_data_type} (is_array={is_array}) has no supported MxValueKind mapping")] +pub struct UnsupportedDataType { + pub mx_data_type: i16, + pub is_array: bool, +} /// Resolved Galaxy tag metadata. Field order and types match the .NET /// `GalaxyTagMetadata` record exactly (`cs:6-19`). @@ -69,6 +89,43 @@ impl GalaxyTagMetadata { self.property_id == Self::BUFFER_PROPERTY_ID } + /// Resolve the wire-side [`MxValueKind`] this attribute writes as, + /// based on `(mx_data_type, is_array)`. Mirrors the .NET + /// `GalaxyTagMetadata.ProjectWriteValue` kind selection at + /// `GalaxyRepositoryTagResolver.cs:53-72` (delegated to + /// [`MxValueKind::for_data_type`] which fuses both + /// `NmxWriteMessage.GetValueKind` and the `ProjectWriteValue` scalar + /// fallbacks for `ElapsedTime` → `Int32` and `InternationalizedString` → + /// `String`). + /// + /// # Errors + /// + /// [`UnsupportedDataType`] when the `(mx_data_type, is_array)` pair has + /// no LMX wire encoding (e.g. arrays of `ElapsedTime`, scalars of + /// `ReferenceType` / `StatusType` / `Enum` / etc.). + /// + /// # Note + /// + /// This only resolves the **kind**; converting the caller's value + /// payload into the right `MxValue` variant is the caller's job. + /// The .NET reference's `TimeSpan` → `int millis` conversion at + /// `cs:67-68` happens at the consumer boundary in Rust, not here — + /// the Rust port doesn't expose `TimeSpan`-style types in the codec. + pub fn resolve_write_kind(&self) -> Result { + let data_type = MxDataType::from_i16(self.mx_data_type); + MxValueKind::for_data_type(data_type, self.is_array).ok_or(UnsupportedDataType { + mx_data_type: self.mx_data_type, + is_array: self.is_array, + }) + } + + /// `true` when [`Self::resolve_write_kind`] would succeed. Useful as a + /// pre-flight check in browse UIs. + #[must_use] + pub fn is_writable(&self) -> bool { + self.resolve_write_kind().is_ok() + } + /// Build the wire-form [`MxReferenceHandle`] this metadata describes. /// Mirrors `ToReferenceHandle(byte galaxyId = 1)` (`cs:26-39`). /// @@ -192,4 +249,68 @@ mod tests { assert_eq!(meta.primitive_name, None); assert_eq!(meta.attribute_source, "dynamic"); } + + #[test] + fn resolve_write_kind_double_scalar_is_float64() { + // sample defaults mx_data_type=4 (Double), is_array=false. + let meta = sample(10, 0, None); + assert_eq!(meta.resolve_write_kind(), Ok(MxValueKind::Float64)); + assert!(meta.is_writable()); + } + + #[test] + fn resolve_write_kind_boolean_array_is_bool_array() { + let mut meta = sample(10, 0, None); + meta.mx_data_type = 1; // Boolean + meta.is_array = true; + assert_eq!(meta.resolve_write_kind(), Ok(MxValueKind::BoolArray)); + } + + #[test] + fn resolve_write_kind_elapsed_time_scalar_is_int32() { + let mut meta = sample(10, 0, None); + meta.mx_data_type = 7; // ElapsedTime + meta.is_array = false; + assert_eq!(meta.resolve_write_kind(), Ok(MxValueKind::Int32)); + } + + #[test] + fn resolve_write_kind_array_of_elapsed_time_unsupported() { + let mut meta = sample(10, 0, None); + meta.mx_data_type = 7; // ElapsedTime + meta.is_array = true; + let err = meta.resolve_write_kind().unwrap_err(); + assert_eq!( + err, + UnsupportedDataType { + mx_data_type: 7, + is_array: true, + } + ); + assert!(!meta.is_writable()); + } + + #[test] + fn resolve_write_kind_internationalized_string_scalar_is_string() { + let mut meta = sample(10, 0, None); + meta.mx_data_type = 15; // InternationalizedString + meta.is_array = false; + assert_eq!(meta.resolve_write_kind(), Ok(MxValueKind::String)); + } + + #[test] + fn resolve_write_kind_reference_type_unsupported() { + let mut meta = sample(10, 0, None); + meta.mx_data_type = 8; // ReferenceType — never writable on the wire + meta.is_array = false; + assert!(meta.resolve_write_kind().is_err()); + assert!(!meta.is_writable()); + } + + #[test] + fn resolve_write_kind_unknown_data_type_unsupported() { + let mut meta = sample(10, 0, None); + meta.mx_data_type = -1; // Unknown sentinel + assert!(meta.resolve_write_kind().is_err()); + } }