41f2d4c0f2
rust / build / test / clippy / fmt (push) Has been cancelled
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>
669 lines
24 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|