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}