//! `tiberius`-backed implementations of [`crate::Resolver`] and //! [`crate::UserResolver`]. Gated by the `galaxy-resolver` Cargo feature. //! //! Direct port of `GalaxyRepositoryTagResolver.cs` and //! `GalaxyRepositoryUserResolver.cs`. The pure-Rust foundation //! (parser, metadata, SQL constants) was already in place; this module //! is the "fill in the backend" piece tracked as F14 in //! `design/followups.md`. //! //! ## Connection-string parsing //! //! Both resolvers accept an `ADO.NET`-style connection string via //! [`SqlTagResolver::from_ado_string`] / [`SqlUserResolver::from_ado_string`]. //! The string is parsed by `tiberius::Config::from_ado_string`, which //! accepts the same shape the .NET reference uses by default //! (`Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;TrustServerCertificate=True`). //! `Integrated Security=True` resolves to Windows authentication on //! Windows hosts via the `winauth` feature. //! //! ## Named-parameter rewriting //! //! `tiberius` only accepts positional `@P1..@PN` placeholders (it //! delegates to `sp_executesql` internally). The canonical SQL constants //! in [`crate::sql`] use named parameters (`@objectTagName`, //! `@attributeName`, `@primitiveName`, `@objectTagLike`, `@attributeLike`, //! `@maxRows`, `@userGuid`, `@userName`) to stay byte-identical with the //! .NET reference. Each query string is rewritten once at module-init //! time via [`std::sync::OnceLock`]. //! //! ## Connection lifetime //! //! Each top-level call (`resolve`, `browse`, `resolve_by_guid`, //! `resolve_by_name`) opens a fresh `tiberius::Client` and drops it on //! return. This matches the `await using` pattern in the .NET reference //! (`GalaxyRepositoryTagResolver.cs:93-95`). The Galaxy DB is not //! request-pooled in the .NET shape either — tag resolution happens once //! per session bring-up, not on the data-plane hot path. #![cfg(feature = "galaxy-resolver")] use std::sync::OnceLock; use async_trait::async_trait; use futures_util::TryStreamExt; use tiberius::{Client, Config, QueryItem, Row}; use tokio::net::TcpStream; use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt}; use uuid::Uuid; use crate::metadata::GalaxyTagMetadata; use crate::parser::ParsedTagReference; use crate::resolver::{Resolver, ResolverError}; use crate::sql; use crate::user::{GalaxyUserProfile, UserResolver, UserResolverError}; /// Shorthand for the tiberius client we hold per call. type SqlClient = Client>; // --------------------------------------------------------------------------- // Tag resolver // --------------------------------------------------------------------------- /// Tiberius-backed [`Resolver`] hitting a Galaxy Repository SQL Server. /// /// Construct with [`SqlTagResolver::from_ado_string`]. The same /// connection string the .NET reference uses works verbatim /// (`Server=...;Database=...;Integrated Security=True;Encrypt=False;TrustServerCertificate=True`). #[derive(Debug)] pub struct SqlTagResolver { config: Config, } impl SqlTagResolver { /// Build a resolver from an ADO.NET connection string. Mirrors /// the .NET reference's default-construction path /// (`GalaxyRepositoryTagResolver.cs:77-86`). /// /// # Errors /// /// [`ResolverError::Backend`] when `tiberius::Config::from_ado_string` /// rejects the string (unparseable key/value, unsupported auth, /// etc.). pub fn from_ado_string(connection_string: &str) -> Result { let config = Config::from_ado_string(connection_string).map_err(|e| { ResolverError::Backend { message: format!("invalid ADO.NET connection string: {e}"), } })?; Ok(Self { config }) } async fn open(&self) -> Result { open_client(&self.config) .await .map_err(|message| ResolverError::Backend { message }) } } #[async_trait] impl Resolver for SqlTagResolver { async fn resolve(&self, tag_reference: &str) -> Result { let candidates = ParsedTagReference::parse_candidates(tag_reference)?; let mut client = self.open().await?; for parsed in &candidates { let primitive = parsed.primitive_name.as_deref(); let object_tag = parsed.object_tag_name.as_str(); let attribute = parsed.attribute_name.as_str(); let mut stream = client .query( resolve_sql_pos(), &[&object_tag, &attribute, &primitive], ) .await .map_err(|e| ResolverError::Backend { message: format!("RESOLVE_SQL execute: {e}"), })?; while let Some(item) = stream .try_next() .await .map_err(|e| ResolverError::Backend { message: format!("RESOLVE_SQL fetch: {e}"), })? { if let QueryItem::Row(row) = item { let metadata = read_metadata(&row).map_err(|e| ResolverError::Backend { message: format!("RESOLVE_SQL row decode: {e}"), })?; return Ok(parsed.apply_overrides(metadata)); } } } Err(ResolverError::NotFound { tag_reference: tag_reference.to_string(), }) } async fn browse( &self, object_tag_like: &str, attribute_like: &str, max_rows: usize, ) -> Result, ResolverError> { if object_tag_like.trim().is_empty() { return Err(ResolverError::Backend { message: "object_tag_like must not be empty".to_string(), }); } if attribute_like.trim().is_empty() { return Err(ResolverError::Backend { message: "attribute_like must not be empty".to_string(), }); } if max_rows == 0 { return Err(ResolverError::Backend { message: "max_rows must be positive".to_string(), }); } // Mirror the .NET clamp at GalaxyRepositoryTagResolver.cs:137. let clamped = i32::try_from(max_rows.min(1000)).unwrap_or(1000); let mut client = self.open().await?; let mut stream = client .query( browse_sql_pos(), &[&object_tag_like, &attribute_like, &clamped], ) .await .map_err(|e| ResolverError::Backend { message: format!("BROWSE_SQL execute: {e}"), })?; let mut out = Vec::new(); while let Some(item) = stream .try_next() .await .map_err(|e| ResolverError::Backend { message: format!("BROWSE_SQL fetch: {e}"), })? { if let QueryItem::Row(row) = item { out.push(read_metadata(&row).map_err(|e| ResolverError::Backend { message: format!("BROWSE_SQL row decode: {e}"), })?); } } Ok(out) } } // --------------------------------------------------------------------------- // User resolver // --------------------------------------------------------------------------- /// Tiberius-backed [`UserResolver`]. /// /// Mirrors `GalaxyRepositoryUserResolver` (`cs:13-149`). #[derive(Debug)] pub struct SqlUserResolver { config: Config, } impl SqlUserResolver { /// Build a user resolver from an ADO.NET connection string. /// /// # Errors /// /// [`UserResolverError::Backend`] when the connection string is /// rejected by `tiberius::Config::from_ado_string`. pub fn from_ado_string(connection_string: &str) -> Result { let config = Config::from_ado_string(connection_string).map_err(|e| { UserResolverError::Backend { message: format!("invalid ADO.NET connection string: {e}"), } })?; Ok(Self { config }) } async fn open(&self) -> Result { open_client(&self.config) .await .map_err(|message| UserResolverError::Backend { message }) } } #[async_trait] impl UserResolver for SqlUserResolver { async fn resolve_by_guid( &self, user_guid: Uuid, ) -> Result { let mut client = self.open().await?; // tiberius's `Uuid` parameter binds to `uniqueidentifier` directly. let mut stream = client .query(user_by_guid_sql_pos(), &[&user_guid]) .await .map_err(|e| UserResolverError::Backend { message: format!("USER_BY_GUID_SQL execute: {e}"), })?; while let Some(item) = stream .try_next() .await .map_err(|e| UserResolverError::Backend { message: format!("USER_BY_GUID_SQL fetch: {e}"), })? { if let QueryItem::Row(row) = item { return read_user_profile(&row).map_err(|e| UserResolverError::Backend { message: format!("USER_BY_GUID_SQL row decode: {e}"), }); } } Err(UserResolverError::NotFound { key: user_guid.to_string(), }) } async fn resolve_by_name( &self, user_name: &str, ) -> Result { if user_name.trim().is_empty() { return Err(UserResolverError::Backend { message: "user_name must not be empty".to_string(), }); } let mut client = self.open().await?; let mut stream = client .query(user_by_name_sql_pos(), &[&user_name]) .await .map_err(|e| UserResolverError::Backend { message: format!("USER_BY_NAME_SQL execute: {e}"), })?; while let Some(item) = stream .try_next() .await .map_err(|e| UserResolverError::Backend { message: format!("USER_BY_NAME_SQL fetch: {e}"), })? { if let QueryItem::Row(row) = item { return read_user_profile(&row).map_err(|e| UserResolverError::Backend { message: format!("USER_BY_NAME_SQL row decode: {e}"), }); } } Err(UserResolverError::NotFound { key: user_name.to_string(), }) } } // --------------------------------------------------------------------------- // Internals // --------------------------------------------------------------------------- /// Open a fresh tiberius client to the configured server. Returns a /// `String` error so each caller can wrap into its preferred error /// taxonomy. async fn open_client(config: &Config) -> Result { let stream = TcpStream::connect(config.get_addr()) .await .map_err(|e| format!("TCP connect to {}: {e}", config.get_addr()))?; // NODELAY mirrors what tiberius's own examples set; latency-sensitive // for short query/response cycles. let _ = stream.set_nodelay(true); Client::connect(config.clone(), stream.compat_write()) .await .map_err(|e| format!("tiberius connect: {e}")) } /// Decode one resolver row per `ReadMetadata` (`cs:149-165`). /// /// SQL Server smallint → tiberius `i16`; bit → `bool`; nvarchar → /// `&str`. The platform/engine/object IDs are signed `smallint` on the /// wire but the .NET reference checked-casts to `ushort`; we widen to /// `u16` the same way. fn read_metadata(row: &Row) -> Result { let object_tag_name: &str = row .try_get::<&str, _>(0) .map_err(|e| format!("col 0 object_tag_name: {e}"))? .ok_or("col 0 object_tag_name: NULL")?; let attribute_name: &str = row .try_get::<&str, _>(1) .map_err(|e| format!("col 1 attribute_name: {e}"))? .ok_or("col 1 attribute_name: NULL")?; let primitive_name: Option<&str> = row .try_get::<&str, _>(2) .map_err(|e| format!("col 2 primitive_name: {e}"))?; let platform_id_i16: i16 = row .try_get::(3) .map_err(|e| format!("col 3 mx_platform_id: {e}"))? .ok_or("col 3 mx_platform_id: NULL")?; let engine_id_i16: i16 = row .try_get::(4) .map_err(|e| format!("col 4 mx_engine_id: {e}"))? .ok_or("col 4 mx_engine_id: NULL")?; let object_id_i16: i16 = row .try_get::(5) .map_err(|e| format!("col 5 mx_object_id: {e}"))? .ok_or("col 5 mx_object_id: NULL")?; let primitive_id: i16 = row .try_get::(6) .map_err(|e| format!("col 6 mx_primitive_id: {e}"))? .ok_or("col 6 mx_primitive_id: NULL")?; let attribute_id: i16 = row .try_get::(7) .map_err(|e| format!("col 7 mx_attribute_id: {e}"))? .ok_or("col 7 mx_attribute_id: NULL")?; let property_id_i32: i32 = row .try_get::(8) .map_err(|e| format!("col 8 property_id: {e}"))? .ok_or("col 8 property_id: NULL")?; let mx_data_type: i16 = row .try_get::(9) .map_err(|e| format!("col 9 mx_data_type: {e}"))? .ok_or("col 9 mx_data_type: NULL")?; let is_array: bool = row .try_get::(10) .map_err(|e| format!("col 10 is_array: {e}"))? .ok_or("col 10 is_array: NULL")?; let security_classification: i16 = row .try_get::(11) .map_err(|e| format!("col 11 security_classification: {e}"))? .ok_or("col 11 security_classification: NULL")?; let attribute_source: &str = row .try_get::<&str, _>(12) .map_err(|e| format!("col 12 attribute_source: {e}"))? .ok_or("col 12 attribute_source: NULL")?; let property_id = i16::try_from(property_id_i32) .map_err(|_| format!("property_id {property_id_i32} out of i16 range"))?; Ok(GalaxyTagMetadata { object_tag_name: object_tag_name.to_string(), attribute_name: attribute_name.to_string(), primitive_name: primitive_name.map(str::to_string), platform_id: u16::try_from(platform_id_i16) .map_err(|_| format!("platform_id {platform_id_i16} negative"))?, engine_id: u16::try_from(engine_id_i16) .map_err(|_| format!("engine_id {engine_id_i16} negative"))?, object_id: u16::try_from(object_id_i16) .map_err(|_| format!("object_id {object_id_i16} negative"))?, primitive_id, attribute_id, property_id, mx_data_type, is_array, security_classification, attribute_source: attribute_source.to_string(), }) } /// Decode one user-profile row per `ReadProfile` (`cs:76-85`). fn read_user_profile(row: &Row) -> Result { let user_profile_id: i32 = row .try_get::(0) .map_err(|e| format!("col 0 user_profile_id: {e}"))? .ok_or("col 0 user_profile_id: NULL")?; let user_profile_name: &str = row .try_get::<&str, _>(1) .map_err(|e| format!("col 1 user_profile_name: {e}"))? .ok_or("col 1 user_profile_name: NULL")?; let user_guid: Uuid = row .try_get::(2) .map_err(|e| format!("col 2 user_guid: {e}"))? .ok_or("col 2 user_guid: NULL")?; let default_security_group: &str = row .try_get::<&str, _>(3) .map_err(|e| format!("col 3 default_security_group: {e}"))? .ok_or("col 3 default_security_group: NULL")?; let intouch_access_level: Option = row .try_get::(4) .map_err(|e| format!("col 4 intouch_access_level: {e}"))?; let roles_text: Option<&str> = row .try_get::<&str, _>(5) .map_err(|e| format!("col 5 roles_text: {e}"))?; Ok(GalaxyUserProfile::from_columns( user_profile_id, user_profile_name.to_string(), user_guid, default_security_group.to_string(), intouch_access_level, roles_text, )) } // --------------------------------------------------------------------------- // Named-parameter rewriting // --------------------------------------------------------------------------- // // tiberius accepts only positional `@P1..@PN` placeholders. Each canonical // SQL constant in `crate::sql` uses named parameters (matching the .NET // reference verbatim); rewrite to `@PN` once per process start. fn resolve_sql_pos() -> &'static str { static CACHE: OnceLock = OnceLock::new(); CACHE.get_or_init(|| { sql::RESOLVE_SQL .replace("@objectTagName", "@P1") .replace("@attributeName", "@P2") .replace("@primitiveName", "@P3") }) } fn browse_sql_pos() -> &'static str { static CACHE: OnceLock = OnceLock::new(); CACHE.get_or_init(|| { sql::BROWSE_SQL .replace("@objectTagLike", "@P1") .replace("@attributeLike", "@P2") .replace("@maxRows", "@P3") }) } fn user_by_guid_sql_pos() -> &'static str { static CACHE: OnceLock = OnceLock::new(); CACHE.get_or_init(|| sql::USER_BY_GUID_SQL.replace("@userGuid", "@P1")) } fn user_by_name_sql_pos() -> &'static str { static CACHE: OnceLock = OnceLock::new(); CACHE.get_or_init(|| sql::USER_BY_NAME_SQL.replace("@userName", "@P1")) } #[cfg(test)] #[allow( clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing, clippy::panic )] mod tests { use super::*; // ----- offline tests (no SQL Server required) ------------------------- #[test] fn resolve_sql_rewrites_three_named_to_positional() { let sql = resolve_sql_pos(); assert!(sql.contains("@P1")); assert!(sql.contains("@P2")); assert!(sql.contains("@P3")); assert!(!sql.contains("@objectTagName")); assert!(!sql.contains("@attributeName")); assert!(!sql.contains("@primitiveName")); } #[test] fn browse_sql_rewrites_three_named_to_positional() { let sql = browse_sql_pos(); assert!(sql.contains("@P1")); assert!(sql.contains("@P2")); assert!(sql.contains("@P3")); assert!(!sql.contains("@objectTagLike")); assert!(!sql.contains("@attributeLike")); assert!(!sql.contains("@maxRows")); } #[test] fn user_by_guid_rewrites_named_to_positional() { let sql = user_by_guid_sql_pos(); assert!(sql.contains("@P1")); assert!(!sql.contains("@userGuid")); } #[test] fn user_by_name_rewrites_named_to_positional() { let sql = user_by_name_sql_pos(); assert!(sql.contains("@P1")); assert!(!sql.contains("@userName")); } #[test] fn rewriting_preserves_line_count() { // Sanity — replacing named params shouldn't add or remove lines. assert_eq!( sql::RESOLVE_SQL.lines().count(), resolve_sql_pos().lines().count() ); assert_eq!( sql::BROWSE_SQL.lines().count(), browse_sql_pos().lines().count() ); } #[test] fn from_ado_string_rejects_garbage() { let err = SqlTagResolver::from_ado_string("this is not a valid ADO string").unwrap_err(); assert!(matches!(err, ResolverError::Backend { .. })); let err = SqlUserResolver::from_ado_string("=;=;=").unwrap_err(); assert!(matches!(err, UserResolverError::Backend { .. })); } #[test] fn from_ado_string_accepts_default_galaxy_shape() { // The default shape used by the .NET reference at // GalaxyRepositoryTagResolver.cs:78. let s = "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;TrustServerCertificate=True"; SqlTagResolver::from_ado_string(s).expect("default Galaxy connection shape parses"); SqlUserResolver::from_ado_string(s) .expect("default Galaxy connection shape parses for user resolver"); } #[test] fn browse_rejects_zero_max_rows() { // We can exercise the input-validation arm without a live DB — // it's checked before the connect attempt. let resolver = SqlTagResolver::from_ado_string( "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;TrustServerCertificate=True", ) .unwrap(); let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); let err = rt .block_on(resolver.browse("%", "%", 0)) .expect_err("max_rows=0 must be rejected"); match err { ResolverError::Backend { message } => assert!(message.contains("max_rows")), other => panic!("expected Backend error, got {other:?}"), } } #[test] fn browse_rejects_empty_like_patterns() { let resolver = SqlTagResolver::from_ado_string( "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;TrustServerCertificate=True", ) .unwrap(); let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); let err = rt .block_on(resolver.browse(" ", "%", 10)) .expect_err("empty object_tag_like must be rejected"); assert!(matches!(err, ResolverError::Backend { .. })); let err = rt .block_on(resolver.browse("%", "", 10)) .expect_err("empty attribute_like must be rejected"); assert!(matches!(err, ResolverError::Backend { .. })); } #[test] fn resolve_by_name_rejects_empty_user_name() { let resolver = SqlUserResolver::from_ado_string( "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;TrustServerCertificate=True", ) .unwrap(); let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); let err = rt .block_on(resolver.resolve_by_name(" ")) .expect_err("empty user_name must be rejected"); assert!(matches!(err, UserResolverError::Backend { .. })); } // ----- live tests (require MX_LIVE + MX_GALAXY_DB) -------------------- /// Live integration test — gated on the workspace's `live` feature /// AND `MX_LIVE` env var being non-empty AND `MX_GALAXY_DB` being /// set to a parseable ADO connection string. Populated by /// `tools/Setup-LiveProbeEnv.ps1`. #[cfg(feature = "live")] #[tokio::test(flavor = "current_thread")] #[ignore = "requires a live Galaxy DB; gated on MX_LIVE + MX_GALAXY_DB"] async fn live_resolve_test_child_object_test_int() { if std::env::var_os("MX_LIVE").is_none() { eprintln!("MX_LIVE not set; skipping"); return; } let conn = match std::env::var("MX_GALAXY_DB") { Ok(s) if !s.is_empty() => s, _ => { eprintln!("MX_GALAXY_DB not set; skipping"); return; } }; let resolver = SqlTagResolver::from_ado_string(&conn).unwrap(); let m = resolver .resolve("TestChildObject.TestInt") .await .expect("resolve live tag"); assert_eq!(m.object_tag_name, "TestChildObject"); assert_eq!(m.attribute_name, "TestInt"); // mx_data_type 2 = Int32 per the Galaxy attribute table. assert_eq!(m.mx_data_type, 2); assert!(!m.is_array); } #[cfg(feature = "live")] #[tokio::test(flavor = "current_thread")] #[ignore = "requires a live Galaxy DB; gated on MX_LIVE + MX_GALAXY_DB"] async fn live_browse_test_child_object() { if std::env::var_os("MX_LIVE").is_none() { return; } let conn = match std::env::var("MX_GALAXY_DB") { Ok(s) if !s.is_empty() => s, _ => return, }; let resolver = SqlTagResolver::from_ado_string(&conn).unwrap(); let rows = resolver .browse("TestChildObject", "%", 50) .await .expect("browse live tag"); // The TestChildObject template ships with at least TestInt + // TestString + a few framework attributes; assert non-empty. assert!( !rows.is_empty(), "expected at least one attribute on TestChildObject" ); } }