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}