Skip to main content

heliosdb_proxy/
request.rs

1//! Per-Request View
2//!
3//! A read-only bridge trait that modules can consume instead of depending
4//! on a concrete per-request carrier type. Today per-request state is
5//! scattered across `plugins::QueryContext`, `multi_tenancy::RequestContext`,
6//! `analytics::QueryExecution`, and `auth::role_mapper::AuthorizationContext`;
7//! merging them into a single struct would churn every consumer. The trait
8//! here lets new code accept `&dyn RequestView` (or `impl RequestView`)
9//! and work against any of them, while existing code keeps its native
10//! concrete types.
11//!
12//! This is the foundation for T0-d. As new plugins and modules are added
13//! (T2.2–T2.4 especially), they should accept `impl RequestView` rather
14//! than reach for a module-specific carrier.
15
16/// Read-only view of per-request metadata.
17///
18/// Every method returns `Option` where appropriate so implementations can
19/// return `None` when the underlying carrier doesn't track that field.
20/// Consumers that need a field unconditionally should guard with
21/// `.ok_or(Error::…)` at the call site.
22pub trait RequestView {
23    /// SQL text of the query being processed, if this request carries a
24    /// query. Returns `None` for non-SQL protocol messages
25    /// (e.g. `Terminate`, `Sync`) or for contexts that don't carry SQL.
26    fn query(&self) -> Option<&str>;
27
28    /// Whether the query is a read-only operation (SELECT / SHOW / …).
29    /// Defaults to `false` for safety — an implementation that cannot
30    /// classify a query should leave the default.
31    fn is_read_only(&self) -> bool {
32        false
33    }
34
35    /// Target database from the client's startup message.
36    fn database(&self) -> Option<&str> {
37        None
38    }
39
40    /// Stable client session identifier.
41    fn client_id(&self) -> Option<&str> {
42        None
43    }
44
45    /// Tenant identifier, for multi-tenant deployments.
46    fn tenant_id(&self) -> Option<&str> {
47        None
48    }
49}
50
51// --- Implementation for plugins::QueryContext -----------------------------
52//
53// Feature-gated because QueryContext is only compiled when `wasm-plugins`
54// is enabled. Other modules can add their own impls beside their carriers
55// as they adopt the trait.
56
57#[cfg(feature = "wasm-plugins")]
58impl RequestView for crate::plugins::QueryContext {
59    fn query(&self) -> Option<&str> {
60        Some(self.query.as_str())
61    }
62
63    fn is_read_only(&self) -> bool {
64        self.is_read_only
65    }
66
67    fn database(&self) -> Option<&str> {
68        self.hook_context.database.as_deref()
69    }
70
71    fn client_id(&self) -> Option<&str> {
72        self.hook_context.client_id.as_deref()
73    }
74
75    fn tenant_id(&self) -> Option<&str> {
76        // HookContext carries a free-form attribute map; tenants land there
77        // once multi-tenancy populates it.
78        self.hook_context
79            .attributes
80            .get("tenant_id")
81            .map(String::as_str)
82    }
83}
84
85#[cfg(all(test, feature = "wasm-plugins"))]
86mod tests {
87    use super::*;
88    use crate::plugins::{HookContext, QueryContext};
89    use std::collections::HashMap;
90
91    fn make_ctx(sql: &str, is_read_only: bool) -> QueryContext {
92        let mut hc = HookContext {
93            client_id: Some("session-abc".to_string()),
94            database: Some("app".to_string()),
95            ..Default::default()
96        };
97        hc.attributes
98            .insert("tenant_id".to_string(), "acme".to_string());
99
100        QueryContext {
101            query: sql.to_string(),
102            normalized: sql.to_string(),
103            tables: Vec::new(),
104            is_read_only,
105            hook_context: hc,
106        }
107    }
108
109    /// Demonstrate the trait abstraction: a generic function that only
110    /// sees `RequestView` can read every field regardless of the
111    /// concrete carrier.
112    fn summarise<V: RequestView>(v: &V) -> String {
113        format!(
114            "sql={:?} ro={} db={:?} client={:?} tenant={:?}",
115            v.query(),
116            v.is_read_only(),
117            v.database(),
118            v.client_id(),
119            v.tenant_id(),
120        )
121    }
122
123    #[test]
124    fn test_request_view_for_query_context() {
125        let ctx = make_ctx("SELECT 1", true);
126        assert_eq!(ctx.query(), Some("SELECT 1"));
127        assert!(ctx.is_read_only());
128        assert_eq!(ctx.database(), Some("app"));
129        assert_eq!(ctx.client_id(), Some("session-abc"));
130        assert_eq!(ctx.tenant_id(), Some("acme"));
131    }
132
133    #[test]
134    fn test_request_view_for_query_context_write() {
135        let ctx = make_ctx("INSERT INTO orders VALUES (1)", false);
136        assert_eq!(ctx.query(), Some("INSERT INTO orders VALUES (1)"));
137        assert!(!ctx.is_read_only());
138    }
139
140    /// A missing tenant attribute yields `None`, not a panic.
141    #[test]
142    fn test_request_view_tenant_missing() {
143        let hc = HookContext {
144            client_id: None,
145            database: None,
146            attributes: HashMap::new(),
147            ..HookContext::default()
148        };
149        let ctx = QueryContext {
150            query: "SELECT 1".to_string(),
151            normalized: "SELECT 1".to_string(),
152            tables: Vec::new(),
153            is_read_only: true,
154            hook_context: hc,
155        };
156        assert_eq!(ctx.tenant_id(), None);
157    }
158
159    /// Generic consumer works against a concrete type — proves the
160    /// dispatch pattern future plugin code will rely on.
161    #[test]
162    fn test_generic_consumer_over_trait() {
163        let ctx = make_ctx("SELECT 42", true);
164        let summary = summarise(&ctx);
165        assert!(summary.contains("sql=Some(\"SELECT 42\")"));
166        assert!(summary.contains("ro=true"));
167        assert!(summary.contains("tenant=Some(\"acme\")"));
168    }
169}