Skip to main content

rskit_database/
tenant.rs

1//! Tenant-scoping helpers for multi-tenant database queries.
2//!
3//! Mirrors gokit's `database/tenant.go`.  Provides:
4//!
5//! - [`TenantScope`] — a builder for constructing tenant-filtered query predicate fragments.
6
7use std::num::NonZeroUsize;
8
9/// Helper for building tenant-scoped SQL `WHERE` clauses.
10///
11/// Mirrors gokit's `ScopeToTenant` by associating a field name with a tenant value.
12/// Adapter crates decide how to bind the returned value.
13///
14/// # Examples
15///
16/// ```
17/// use rskit_database::TenantScope;
18/// use std::num::NonZeroUsize;
19///
20/// let scope = TenantScope::new("workspace_id", "ws-123").unwrap();
21/// assert_eq!(
22///     scope.where_clause(NonZeroUsize::new(1).unwrap()),
23///     "workspace_id = $1",
24/// );
25/// assert_eq!(scope.value(), "ws-123");
26/// ```
27#[derive(Debug, Clone)]
28pub struct TenantScope {
29    column: String,
30    value: String,
31}
32
33impl TenantScope {
34    /// Create a new [`TenantScope`] for the given column and value.
35    ///
36    /// # Errors
37    /// Returns an error when the column is not a safe SQL identifier path or the tenant value is empty.
38    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    /// Return a `WHERE` clause fragment like `"workspace_id = $1"`.
55    ///
56    /// `param_index` is the positional parameter number for the bind placeholder (1-based for PostgreSQL `$N` syntax).
57    #[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    /// Return the tenant value to bind.
64    #[must_use]
65    pub fn value(&self) -> &str {
66        &self.value
67    }
68
69    /// Return the column name.
70    #[must_use]
71    pub fn column(&self) -> &str {
72        &self.column
73    }
74
75    /// Append a tenant-scoped `WHERE` clause to the given SQL query.
76    ///
77    /// Returns the modified query with ` WHERE column = $N` appended, where `N` is `param_index`.
78    #[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    /// Append a tenant-scoped `AND` clause to the given SQL query.
84    ///
85    /// Use this when the query already has a `WHERE` clause.
86    #[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}