1use std::num::NonZeroUsize;
9
10#[derive(Debug, Clone)]
29pub struct TenantScope {
30 column: String,
31 value: String,
32}
33
34impl TenantScope {
35 pub fn new(
41 column: impl Into<String>,
42 value: impl Into<String>,
43 ) -> rskit_errors::AppResult<Self> {
44 let column = column.into();
45 let value = value.into();
46 validate_identifier_path(&column)?;
47 if value.is_empty() {
48 return Err(rskit_errors::AppError::invalid_input(
49 "tenant.value",
50 "tenant value is required",
51 ));
52 }
53 Ok(Self { column, value })
54 }
55
56 #[must_use]
61 pub fn where_clause(&self, param_index: NonZeroUsize) -> String {
62 let param_index = param_index.get();
63 format!("{} = ${param_index}", self.column)
64 }
65
66 #[must_use]
68 pub fn value(&self) -> &str {
69 &self.value
70 }
71
72 #[must_use]
74 pub fn column(&self) -> &str {
75 &self.column
76 }
77
78 #[must_use]
83 pub fn apply(&self, query: &str, param_index: NonZeroUsize) -> String {
84 format!("{query} WHERE {}", self.where_clause(param_index))
85 }
86
87 #[must_use]
91 pub fn apply_and(&self, query: &str, param_index: NonZeroUsize) -> String {
92 format!("{query} AND {}", self.where_clause(param_index))
93 }
94}
95
96pub(crate) fn validate_identifier_path(identifier: &str) -> rskit_errors::AppResult<()> {
97 if identifier.is_empty() || identifier.len() > 128 {
98 return Err(rskit_errors::AppError::invalid_input(
99 "sql.identifier",
100 "SQL identifier must be 1..=128 bytes",
101 ));
102 }
103 if identifier.split('.').all(is_valid_identifier_segment) {
104 Ok(())
105 } else {
106 Err(rskit_errors::AppError::invalid_input(
107 "sql.identifier",
108 "SQL identifier must contain only dotted ASCII identifier segments",
109 ))
110 }
111}
112
113fn is_valid_identifier_segment(segment: &str) -> bool {
114 let mut chars = segment.chars();
115 let Some(first) = chars.next() else {
116 return false;
117 };
118 (first == '_' || first.is_ascii_alphabetic())
119 && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 fn param(index: usize) -> NonZeroUsize {
127 NonZeroUsize::new(index).unwrap()
128 }
129
130 #[test]
131 fn where_clause_basic() {
132 let scope = TenantScope::new("workspace_id", "ws-123").unwrap();
133 assert_eq!(scope.where_clause(param(1)), "workspace_id = $1");
134 }
135
136 #[test]
137 fn where_clause_higher_index() {
138 let scope = TenantScope::new("tenant_id", "t-456").unwrap();
139 assert_eq!(scope.where_clause(param(3)), "tenant_id = $3");
140 }
141
142 #[test]
143 fn zero_parameter_index_is_not_representable() {
144 assert!(NonZeroUsize::new(0).is_none());
145 }
146
147 #[test]
148 fn value_accessor() {
149 let scope = TenantScope::new("workspace_id", "ws-abc").unwrap();
150 assert_eq!(scope.value(), "ws-abc");
151 }
152
153 #[test]
154 fn column_accessor() {
155 let scope = TenantScope::new("org_id", "org-1").unwrap();
156 assert_eq!(scope.column(), "org_id");
157 }
158
159 #[test]
160 fn apply_where() {
161 let scope = TenantScope::new("workspace_id", "ws-1").unwrap();
162 let sql = scope.apply("SELECT * FROM tasks", param(1));
163 assert_eq!(sql, "SELECT * FROM tasks WHERE workspace_id = $1");
164 }
165
166 #[test]
167 fn apply_and() {
168 let scope = TenantScope::new("workspace_id", "ws-1").unwrap();
169 let sql = scope.apply_and("SELECT * FROM tasks WHERE status = $1", param(2));
170 assert_eq!(
171 sql,
172 "SELECT * FROM tasks WHERE status = $1 AND workspace_id = $2"
173 );
174 }
175
176 #[test]
177 fn rejects_empty_value() {
178 assert!(TenantScope::new("workspace_id", "").is_err());
179 }
180
181 #[test]
182 fn dotted_identifier_column() {
183 let scope = TenantScope::new("my_schema.workspace_id", "ws-1").unwrap();
184 assert_eq!(scope.where_clause(param(1)), "my_schema.workspace_id = $1");
185 }
186
187 #[test]
188 fn rejects_unsafe_column_identifiers() {
189 for column in [
190 "",
191 "1tenant_id",
192 "tenant id",
193 "tenant_id; DROP TABLE users",
194 "tenant_id--",
195 "\"tenant_id\"",
196 ".tenant_id",
197 "tenant_id.",
198 "tenant..id",
199 ] {
200 assert!(TenantScope::new(column, "tenant-1").is_err(), "{column}");
201 }
202 }
203
204 #[test]
205 fn tenant_value_is_bound_data_not_identifier() {
206 let scope = TenantScope::new("workspace_id", "ws-1' OR '1'='1").unwrap();
207 assert_eq!(scope.where_clause(param(2)), "workspace_id = $2");
208 assert_eq!(scope.value(), "ws-1' OR '1'='1");
209 }
210
211 #[test]
212 fn clone_preserves_values() {
213 let scope = TenantScope::new("workspace_id", "ws-clone").unwrap();
214 let cloned = scope.clone();
215 assert_eq!(cloned.column(), scope.column());
216 assert_eq!(cloned.value(), scope.value());
217 }
218
219 #[test]
220 fn debug_format() {
221 let scope = TenantScope::new("workspace_id", "ws-1").unwrap();
222 let debug = format!("{scope:?}");
223 assert!(debug.contains("workspace_id"));
224 assert!(debug.contains("ws-1"));
225 }
226}