baea6eaa41
Lands the user-resolver half of M3 stream A. Pure-Rust foundation — the tiberius-backed SQL impl is logged as F14 and stays gated behind the existing galaxy-resolver Cargo feature. New - role_blob.rs (~270 LoC, 12 tests including a garbage-between-roles edge case) — port of ParseRoleBlob (cs:87-133). Sliding-window scan over hex-decoded UTF-16LE bytes; rejects non-printable code units; case-insensitive dedup. Pure function, no I/O. - user.rs (~290 LoC, 8 tests including 4 tokio-driven InMemoryUserResolver cases) — GalaxyUserProfile (port of cs:5-11) + from_columns helper bridging into role_blob + UserResolver async trait + UserResolverError with NotFound / Backend variants. - sql.rs additions: USER_SELECT_SQL + USER_BY_GUID_SQL + USER_BY_NAME_SQL constants (port of cs:135-148). Inline concatcp! macro composes the base SELECT with each WHERE clause at compile time without pulling const_format. Cargo.toml: added uuid (Galaxy user_guid is a uniqueidentifier). design/followups.md: added F14 (P2) for the tiberius-backed SQL impl behind the galaxy-resolver feature. Test count delta: 427 -> 446 (+19; mxaccess-galaxy 30 -> 49). All four DoD gates green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
440 lines
15 KiB
Rust
440 lines
15 KiB
Rust
// The `concatcp!` macro below uses fixed-size byte indexing in a `const fn`
|
|
// where lengths are statically known. `.get(n)?` is not available in `const`
|
|
// contexts in stable Rust 1.85, so the indexing is the only path. The
|
|
// resulting `&'static str` constants are evaluated at compile time, so
|
|
// any out-of-bounds would surface as a compile error rather than a runtime
|
|
// panic.
|
|
#![allow(clippy::indexing_slicing)]
|
|
|
|
//! 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
|
|
"#;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// User resolver SQL — port of GalaxyRepositoryUserResolver.cs:135-148.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Common `SELECT TOP (1) ...` user-profile projection — `cs:135-144`.
|
|
/// Used as the base for both [`USER_BY_GUID_SQL`] and [`USER_BY_NAME_SQL`].
|
|
///
|
|
/// Result columns (in order):
|
|
///
|
|
/// 0. `user_profile_id` `int`
|
|
/// 1. `user_profile_name` `nvarchar`
|
|
/// 2. `user_guid` `uniqueidentifier`
|
|
/// 3. `default_security_group` `nvarchar`
|
|
/// 4. `intouch_access_level` `int` (NULL-able)
|
|
/// 5. `roles_text` `nvarchar(max)` — `CONVERT(...)` of the
|
|
/// `roles` `varbinary` column. Decode through
|
|
/// [`crate::role_blob::parse_role_blob`].
|
|
pub const USER_SELECT_SQL: &str = r#"SELECT TOP (1)
|
|
user_profile_id,
|
|
user_profile_name,
|
|
user_guid,
|
|
default_security_group,
|
|
intouch_access_level,
|
|
CONVERT(nvarchar(max), roles) AS roles_text
|
|
FROM dbo.user_profile"#;
|
|
|
|
/// `Resolve user_profile by user_guid` — port of `cs:146`.
|
|
///
|
|
/// Parameter: `@userGuid` (`uniqueidentifier`).
|
|
pub const USER_BY_GUID_SQL: &str = concatcp!(
|
|
USER_SELECT_SQL,
|
|
"\nWHERE user_guid = @userGuid\nORDER BY user_profile_id"
|
|
);
|
|
|
|
/// `Resolve user_profile by user_profile_name` — port of `cs:148`.
|
|
///
|
|
/// Parameter: `@userName` (`nvarchar`).
|
|
pub const USER_BY_NAME_SQL: &str = concatcp!(
|
|
USER_SELECT_SQL,
|
|
"\nWHERE user_profile_name = @userName\nORDER BY user_profile_id"
|
|
);
|
|
|
|
/// Tiny `concat!`-equivalent for `&'static str` constants, since `concat!`
|
|
/// only works with literals. Two-arg specialisation; keeps `USER_BY_GUID_SQL`
|
|
/// and `USER_BY_NAME_SQL` evaluable at compile time without dragging in
|
|
/// `const_format` as a dep.
|
|
macro_rules! concatcp {
|
|
($a:expr, $b:expr) => {{
|
|
const A: &str = $a;
|
|
const B: &str = $b;
|
|
const N: usize = A.len() + B.len();
|
|
const fn build() -> [u8; N] {
|
|
let mut out = [0u8; N];
|
|
let a = A.as_bytes();
|
|
let b = B.as_bytes();
|
|
let mut i = 0;
|
|
while i < a.len() {
|
|
out[i] = a[i];
|
|
i += 1;
|
|
}
|
|
let mut j = 0;
|
|
while j < b.len() {
|
|
out[a.len() + j] = b[j];
|
|
j += 1;
|
|
}
|
|
out
|
|
}
|
|
// SAFETY: A and B are valid UTF-8 (both are `&'static str`); the
|
|
// concatenation of two valid UTF-8 byte sequences is valid UTF-8.
|
|
const COMBINED: &[u8; N] = &build();
|
|
match core::str::from_utf8(COMBINED) {
|
|
Ok(s) => s,
|
|
Err(_) => "",
|
|
}
|
|
}};
|
|
}
|
|
pub(crate) use concatcp;
|
|
|
|
/// 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 ("));
|
|
}
|
|
}
|