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::default();
93        hc.client_id = Some("session-abc".to_string());
94        hc.database = Some("app".to_string());
95        hc.attributes
96            .insert("tenant_id".to_string(), "acme".to_string());
97
98        QueryContext {
99            query: sql.to_string(),
100            normalized: sql.to_string(),
101            tables: Vec::new(),
102            is_read_only,
103            hook_context: hc,
104        }
105    }
106
107    /// Demonstrate the trait abstraction: a generic function that only
108    /// sees `RequestView` can read every field regardless of the
109    /// concrete carrier.
110    fn summarise<V: RequestView>(v: &V) -> String {
111        format!(
112            "sql={:?} ro={} db={:?} client={:?} tenant={:?}",
113            v.query(),
114            v.is_read_only(),
115            v.database(),
116            v.client_id(),
117            v.tenant_id(),
118        )
119    }
120
121    #[test]
122    fn test_request_view_for_query_context() {
123        let ctx = make_ctx("SELECT 1", true);
124        assert_eq!(ctx.query(), Some("SELECT 1"));
125        assert!(ctx.is_read_only());
126        assert_eq!(ctx.database(), Some("app"));
127        assert_eq!(ctx.client_id(), Some("session-abc"));
128        assert_eq!(ctx.tenant_id(), Some("acme"));
129    }
130
131    #[test]
132    fn test_request_view_for_query_context_write() {
133        let ctx = make_ctx("INSERT INTO orders VALUES (1)", false);
134        assert_eq!(ctx.query(), Some("INSERT INTO orders VALUES (1)"));
135        assert!(!ctx.is_read_only());
136    }
137
138    /// A missing tenant attribute yields `None`, not a panic.
139    #[test]
140    fn test_request_view_tenant_missing() {
141        let hc = HookContext {
142            client_id: None,
143            database: None,
144            attributes: HashMap::new(),
145            ..HookContext::default()
146        };
147        let ctx = QueryContext {
148            query: "SELECT 1".to_string(),
149            normalized: "SELECT 1".to_string(),
150            tables: Vec::new(),
151            is_read_only: true,
152            hook_context: hc,
153        };
154        assert_eq!(ctx.tenant_id(), None);
155    }
156
157    /// Generic consumer works against a concrete type — proves the
158    /// dispatch pattern future plugin code will rely on.
159    #[test]
160    fn test_generic_consumer_over_trait() {
161        let ctx = make_ctx("SELECT 42", true);
162        let summary = summarise(&ctx);
163        assert!(summary.contains("sql=Some(\"SELECT 42\")"));
164        assert!(summary.contains("ro=true"));
165        assert!(summary.contains("tenant=Some(\"acme\")"));
166    }
167}