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