Files
mxaccess/rust/crates/mxaccess-galaxy/src/sql_resolver.rs
T
Joseph Doherty 41f2d4c0f2
rust / build / test / clippy / fmt (push) Has been cancelled
[F14] mxaccess-galaxy: tiberius-backed SQL Resolver + UserResolver
New module crates/mxaccess-galaxy/src/sql_resolver.rs (~480 LoC) gated
behind the existing galaxy-resolver Cargo feature. Adds SqlTagResolver
+ SqlUserResolver, both constructed via from_ado_string(&str)
accepting the same connection-string shape the .NET reference uses by
default (Server=localhost;Database=ZB;Integrated Security=True;
Encrypt=False;TrustServerCertificate=True). Integrated Security=True
resolves to Windows auth via tiberius's winauth feature.

Each top-level call (resolve / browse / resolve_by_guid /
resolve_by_name) opens a fresh Client<Compat<TcpStream>> and drops it
on return — matches the .NET `await using` lifecycle at
GalaxyRepositoryTagResolver.cs:93-95. tiberius's Client::query only
accepts positional @P1..@PN placeholders (delegates to sp_executesql);
the canonical RESOLVE_SQL / BROWSE_SQL / USER_BY_GUID_SQL /
USER_BY_NAME_SQL constants are rewritten once-per-process via
OnceLock<String> (@objectTagName → @P1, etc.). The unrewritten
constants stay byte-identical with the .NET reference for ad-hoc
diagnostic copy/paste.

read_metadata mirrors ReadMetadata (cs:149-165) byte-by-byte: signed
smallint → i16 widened to u16 for platform/engine/object IDs (matches
the .NET checked((ushort)reader.GetInt16(N)) shape), int → i32
checked-cast to i16 for property_id, nullable nvarchar for
primitive_name. read_user_profile mirrors ReadProfile (cs:76-85)
including the roles_text blob → parse_role_blob round-trip.

Deps added (gated): tiberius 0.12 (default-features = false; tds73 +
rustls + winauth — no chrono / rust_decimal pulled), tokio-util's
compat feature for the futures-rs ↔ tokio AsyncRead bridge,
futures-util for TryStreamExt::try_next. Default-feature build still
pulls only mxaccess-codec + async-trait + thiserror + uuid (slim
foot-print preserved per the design doc's intent).

New `live` feature on this crate (`live = ["galaxy-resolver"]`) for
parity with the workspace pattern.

11 offline unit tests pin: SQL named→positional rewriting (no @named
left, @P1/@P2/@P3 present), line-count preserved, ado-string
acceptance (default Galaxy shape parses, garbage rejected), input
validation (max_rows=0 rejected, empty LIKE rejected, empty user_name
rejected, all checked before connect attempt).

Two #[cfg(feature = "live")] #[ignore]'d tests round-trip against a
real Galaxy DB (gated on MX_LIVE + MX_GALAXY_DB env vars per
tools/Setup-LiveProbeEnv.ps1). Live verification on this host:
live_resolve_test_child_object_test_int and
live_browse_test_child_object both pass against the local AVEVA
install — TestChildObject.TestInt resolves with mx_data_type=2
(Int32), is_array=false.

Closes F14 in design/followups.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:54:43 -04:00

669 lines
24 KiB
Rust

//! `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<Compat<TcpStream>>;
// ---------------------------------------------------------------------------
// 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<Self, ResolverError> {
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<SqlClient, ResolverError> {
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<GalaxyTagMetadata, ResolverError> {
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<Vec<GalaxyTagMetadata>, 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<Self, UserResolverError> {
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<SqlClient, UserResolverError> {
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<GalaxyUserProfile, UserResolverError> {
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<GalaxyUserProfile, UserResolverError> {
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<SqlClient, String> {
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<GalaxyTagMetadata, String> {
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::<i16, _>(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::<i16, _>(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::<i16, _>(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::<i16, _>(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::<i16, _>(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::<i32, _>(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::<i16, _>(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::<bool, _>(10)
.map_err(|e| format!("col 10 is_array: {e}"))?
.ok_or("col 10 is_array: NULL")?;
let security_classification: i16 = row
.try_get::<i16, _>(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<GalaxyUserProfile, String> {
let user_profile_id: i32 = row
.try_get::<i32, _>(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::<Uuid, _>(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<i32> = row
.try_get::<i32, _>(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<String> = 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<String> = 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<String> = 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<String> = 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"
);
}
}