Skip to main content

helios_persistence/strategy/
schema_per_tenant.rs

1//! Schema-per-tenant tenancy strategy.
2//!
3//! In this strategy, each tenant has a separate PostgreSQL schema.
4//! This provides logical isolation while sharing the same database
5//! and connection pool.
6
7use serde::{Deserialize, Serialize};
8
9use crate::tenant::TenantId;
10
11use super::{TenantResolution, TenantResolver, TenantValidationError};
12
13/// Configuration for schema-per-tenant tenancy.
14///
15/// # Example
16///
17/// ```
18/// use helios_persistence::strategy::SchemaPerTenantConfig;
19///
20/// let config = SchemaPerTenantConfig {
21///     schema_prefix: "tenant_".to_string(),
22///     shared_schema: "shared".to_string(),
23///     auto_create_schema: true,
24///     ..Default::default()
25/// };
26/// ```
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct SchemaPerTenantConfig {
29    /// Prefix for tenant schema names.
30    ///
31    /// The full schema name is `{prefix}{tenant_id}`.
32    #[serde(default = "default_schema_prefix")]
33    pub schema_prefix: String,
34
35    /// Name of the shared schema for system resources.
36    #[serde(default = "default_shared_schema")]
37    pub shared_schema: String,
38
39    /// Whether to automatically create schemas for new tenants.
40    #[serde(default = "default_true")]
41    pub auto_create_schema: bool,
42
43    /// Whether to use the public schema for the system tenant.
44    #[serde(default = "default_true")]
45    pub system_uses_public: bool,
46
47    /// Maximum schema name length (PostgreSQL limit is 63).
48    #[serde(default = "default_max_schema_length")]
49    pub max_schema_length: usize,
50
51    /// Characters allowed in schema names (derived from tenant IDs).
52    #[serde(default = "default_schema_pattern")]
53    pub schema_pattern: String,
54
55    /// Whether to drop schema on tenant deletion.
56    #[serde(default)]
57    pub drop_on_delete: bool,
58
59    /// Template schema to copy when creating new tenants.
60    ///
61    /// If set, new tenant schemas are created by copying this template.
62    pub template_schema: Option<String>,
63}
64
65fn default_schema_prefix() -> String {
66    "tenant_".to_string()
67}
68
69fn default_shared_schema() -> String {
70    "shared".to_string()
71}
72
73fn default_true() -> bool {
74    true
75}
76
77fn default_max_schema_length() -> usize {
78    63 // PostgreSQL identifier limit
79}
80
81fn default_schema_pattern() -> String {
82    r"^[a-z][a-z0-9_]*$".to_string()
83}
84
85impl Default for SchemaPerTenantConfig {
86    fn default() -> Self {
87        Self {
88            schema_prefix: default_schema_prefix(),
89            shared_schema: default_shared_schema(),
90            auto_create_schema: true,
91            system_uses_public: true,
92            max_schema_length: default_max_schema_length(),
93            schema_pattern: default_schema_pattern(),
94            drop_on_delete: false,
95            template_schema: None,
96        }
97    }
98}
99
100impl SchemaPerTenantConfig {
101    /// Creates a new configuration with defaults.
102    pub fn new() -> Self {
103        Self::default()
104    }
105
106    /// Sets the schema prefix.
107    pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
108        self.schema_prefix = prefix.into();
109        self
110    }
111
112    /// Sets the shared schema name.
113    pub fn with_shared_schema(mut self, schema: impl Into<String>) -> Self {
114        self.shared_schema = schema.into();
115        self
116    }
117
118    /// Sets the template schema.
119    pub fn with_template(mut self, template: impl Into<String>) -> Self {
120        self.template_schema = Some(template.into());
121        self
122    }
123
124    /// Enables dropping schemas on tenant deletion.
125    pub fn with_drop_on_delete(mut self) -> Self {
126        self.drop_on_delete = true;
127        self
128    }
129}
130
131/// Schema-per-tenant tenancy strategy implementation.
132///
133/// This strategy uses PostgreSQL schemas to isolate tenant data.
134/// Each tenant has its own schema, and the connection's `search_path`
135/// is set to include the tenant's schema.
136///
137/// # Schema Naming
138///
139/// Tenant IDs are converted to valid PostgreSQL schema names:
140/// - Prefixed with the configured prefix (default: `tenant_`)
141/// - Converted to lowercase
142/// - Hierarchical separators (`/`) replaced with underscores
143/// - Invalid characters removed
144///
145/// # Search Path
146///
147/// For each request, the search_path is set to:
148/// ```sql
149/// SET search_path TO tenant_acme, shared, public;
150/// ```
151///
152/// This allows:
153/// - Tenant-specific tables in the tenant schema
154/// - Shared resources (CodeSystems, etc.) in the shared schema
155/// - Extension functions in public
156///
157/// # Schema Creation
158///
159/// Schemas can be created:
160/// - Automatically on first access (if `auto_create_schema` is true)
161/// - From a template schema (copying structure)
162/// - Manually via migrations
163#[derive(Debug, Clone)]
164pub struct SchemaPerTenantStrategy {
165    config: SchemaPerTenantConfig,
166    schema_pattern: regex::Regex,
167}
168
169impl SchemaPerTenantStrategy {
170    /// Creates a new schema-per-tenant strategy with the given configuration.
171    pub fn new(config: SchemaPerTenantConfig) -> Result<Self, regex::Error> {
172        let schema_pattern = regex::Regex::new(&config.schema_pattern)?;
173        Ok(Self {
174            config,
175            schema_pattern,
176        })
177    }
178
179    /// Returns the configuration.
180    pub fn config(&self) -> &SchemaPerTenantConfig {
181        &self.config
182    }
183
184    /// Returns the shared schema name.
185    pub fn shared_schema(&self) -> &str {
186        &self.config.shared_schema
187    }
188
189    /// Converts a tenant ID to a schema name.
190    pub fn tenant_to_schema(&self, tenant_id: &TenantId) -> String {
191        let normalized = self.normalize_tenant_id(tenant_id.as_str());
192        format!("{}{}", self.config.schema_prefix, normalized)
193    }
194
195    /// Normalizes a tenant ID to a valid schema name component.
196    fn normalize_tenant_id(&self, id: &str) -> String {
197        id.to_lowercase()
198            .replace(['/', '-'], "_")
199            .chars()
200            .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
201            .collect()
202    }
203
204    /// Generates SQL to set the search_path for a tenant.
205    pub fn set_search_path_sql(&self, tenant_id: &TenantId) -> String {
206        let schema = self.tenant_to_schema(tenant_id);
207        format!(
208            "SET search_path TO {}, {}, public",
209            self.escape_identifier(&schema),
210            self.escape_identifier(&self.config.shared_schema)
211        )
212    }
213
214    /// Generates SQL to set the search_path for the system tenant.
215    pub fn set_system_search_path_sql(&self) -> String {
216        if self.config.system_uses_public {
217            format!(
218                "SET search_path TO {}, public",
219                self.escape_identifier(&self.config.shared_schema)
220            )
221        } else {
222            format!(
223                "SET search_path TO {}",
224                self.escape_identifier(&self.config.shared_schema)
225            )
226        }
227    }
228
229    /// Generates SQL to reset the search_path.
230    pub fn reset_search_path_sql(&self) -> String {
231        "RESET search_path".to_string()
232    }
233
234    /// Generates SQL to create a schema for a tenant.
235    pub fn create_schema_sql(&self, tenant_id: &TenantId) -> String {
236        let schema = self.tenant_to_schema(tenant_id);
237
238        if let Some(ref template) = self.config.template_schema {
239            // Create from template (PostgreSQL 15+)
240            format!(
241                "CREATE SCHEMA IF NOT EXISTS {} TEMPLATE {}",
242                self.escape_identifier(&schema),
243                self.escape_identifier(template)
244            )
245        } else {
246            format!(
247                "CREATE SCHEMA IF NOT EXISTS {}",
248                self.escape_identifier(&schema)
249            )
250        }
251    }
252
253    /// Generates SQL to drop a schema for a tenant.
254    pub fn drop_schema_sql(&self, tenant_id: &TenantId, cascade: bool) -> String {
255        let schema = self.tenant_to_schema(tenant_id);
256        let cascade_str = if cascade { " CASCADE" } else { "" };
257        format!(
258            "DROP SCHEMA IF EXISTS {}{}",
259            self.escape_identifier(&schema),
260            cascade_str
261        )
262    }
263
264    /// Generates SQL to check if a schema exists.
265    pub fn schema_exists_sql(&self, tenant_id: &TenantId) -> String {
266        let schema = self.tenant_to_schema(tenant_id);
267        format!(
268            "SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = '{}')",
269            self.escape_sql_string(&schema)
270        )
271    }
272
273    /// Generates SQL to list all tenant schemas.
274    pub fn list_tenant_schemas_sql(&self) -> String {
275        format!(
276            "SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE '{}%' ORDER BY schema_name",
277            self.escape_sql_string(&self.config.schema_prefix)
278        )
279    }
280
281    /// Escapes a SQL identifier (schema name, table name, etc.).
282    fn escape_identifier(&self, id: &str) -> String {
283        format!("\"{}\"", id.replace('"', "\"\""))
284    }
285
286    /// Escapes a string for safe inclusion in SQL.
287    fn escape_sql_string(&self, s: &str) -> String {
288        s.replace('\'', "''")
289    }
290
291    /// Validates that a schema name is valid.
292    fn validate_schema_name(&self, schema: &str) -> Result<(), TenantValidationError> {
293        if schema.len() > self.config.max_schema_length {
294            return Err(TenantValidationError {
295                tenant_id: schema.to_string(),
296                reason: format!(
297                    "schema name exceeds maximum length of {} characters",
298                    self.config.max_schema_length
299                ),
300            });
301        }
302
303        if !self.schema_pattern.is_match(schema) {
304            return Err(TenantValidationError {
305                tenant_id: schema.to_string(),
306                reason: format!(
307                    "schema name does not match required pattern: {}",
308                    self.config.schema_pattern
309                ),
310            });
311        }
312
313        Ok(())
314    }
315}
316
317impl TenantResolver for SchemaPerTenantStrategy {
318    fn resolve(&self, tenant_id: &TenantId) -> TenantResolution {
319        TenantResolution::Schema {
320            schema_name: self.tenant_to_schema(tenant_id),
321        }
322    }
323
324    fn validate(&self, tenant_id: &TenantId) -> Result<(), TenantValidationError> {
325        let schema = self.tenant_to_schema(tenant_id);
326        self.validate_schema_name(&schema)
327    }
328
329    fn system_tenant(&self) -> TenantResolution {
330        TenantResolution::Schema {
331            schema_name: self.config.shared_schema.clone(),
332        }
333    }
334}
335
336/// Manages schema lifecycle operations.
337#[derive(Debug)]
338#[allow(dead_code)]
339pub struct SchemaManager<'a> {
340    strategy: &'a SchemaPerTenantStrategy,
341}
342
343#[allow(dead_code)]
344impl<'a> SchemaManager<'a> {
345    /// Creates a new schema manager.
346    pub fn new(strategy: &'a SchemaPerTenantStrategy) -> Self {
347        Self { strategy }
348    }
349
350    /// Generates DDL to create the shared schema.
351    pub fn create_shared_schema_ddl(&self) -> String {
352        format!(
353            "CREATE SCHEMA IF NOT EXISTS {}",
354            self.strategy
355                .escape_identifier(&self.strategy.config.shared_schema)
356        )
357    }
358
359    /// Generates DDL to create a table in a specific schema.
360    #[allow(dead_code)]
361    pub fn create_table_ddl(&self, schema: &str, table_ddl: &str) -> String {
362        // Prepend SET search_path to ensure table is created in correct schema
363        format!(
364            "SET search_path TO {};\n{}",
365            self.strategy.escape_identifier(schema),
366            table_ddl
367        )
368    }
369
370    /// Generates SQL to migrate all tenant schemas.
371    ///
372    /// This creates a DO block that applies the migration to each tenant schema.
373    #[allow(dead_code)]
374    pub fn migrate_all_schemas_sql(&self, migration_sql: &str) -> String {
375        format!(
376            r#"
377DO $$
378DECLARE
379    schema_name TEXT;
380BEGIN
381    FOR schema_name IN
382        SELECT s.schema_name
383        FROM information_schema.schemata s
384        WHERE s.schema_name LIKE '{}%'
385    LOOP
386        EXECUTE format('SET search_path TO %I', schema_name);
387        {}
388    END LOOP;
389END $$;
390"#,
391            self.strategy
392                .escape_sql_string(&self.strategy.config.schema_prefix),
393            migration_sql.replace('\'', "''")
394        )
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn test_schema_per_tenant_config_default() {
404        let config = SchemaPerTenantConfig::default();
405        assert_eq!(config.schema_prefix, "tenant_");
406        assert_eq!(config.shared_schema, "shared");
407        assert!(config.auto_create_schema);
408    }
409
410    #[test]
411    fn test_schema_per_tenant_config_builder() {
412        let config = SchemaPerTenantConfig::new()
413            .with_prefix("org_")
414            .with_shared_schema("common")
415            .with_template("template_tenant")
416            .with_drop_on_delete();
417
418        assert_eq!(config.schema_prefix, "org_");
419        assert_eq!(config.shared_schema, "common");
420        assert_eq!(config.template_schema, Some("template_tenant".to_string()));
421        assert!(config.drop_on_delete);
422    }
423
424    #[test]
425    fn test_tenant_to_schema() {
426        let strategy = SchemaPerTenantStrategy::new(SchemaPerTenantConfig::default()).unwrap();
427
428        assert_eq!(
429            strategy.tenant_to_schema(&TenantId::new("acme")),
430            "tenant_acme"
431        );
432        assert_eq!(
433            strategy.tenant_to_schema(&TenantId::new("Acme-Corp")),
434            "tenant_acme_corp"
435        );
436        assert_eq!(
437            strategy.tenant_to_schema(&TenantId::new("acme/research")),
438            "tenant_acme_research"
439        );
440    }
441
442    #[test]
443    fn test_tenant_resolution() {
444        let strategy = SchemaPerTenantStrategy::new(SchemaPerTenantConfig::default()).unwrap();
445        let resolution = strategy.resolve(&TenantId::new("acme"));
446
447        match resolution {
448            TenantResolution::Schema { schema_name } => {
449                assert_eq!(schema_name, "tenant_acme");
450            }
451            _ => panic!("expected Schema resolution"),
452        }
453    }
454
455    #[test]
456    fn test_set_search_path_sql() {
457        let strategy = SchemaPerTenantStrategy::new(SchemaPerTenantConfig::default()).unwrap();
458        let sql = strategy.set_search_path_sql(&TenantId::new("acme"));
459        assert_eq!(
460            sql,
461            "SET search_path TO \"tenant_acme\", \"shared\", public"
462        );
463    }
464
465    #[test]
466    fn test_create_schema_sql() {
467        let strategy = SchemaPerTenantStrategy::new(SchemaPerTenantConfig::default()).unwrap();
468        let sql = strategy.create_schema_sql(&TenantId::new("acme"));
469        assert_eq!(sql, "CREATE SCHEMA IF NOT EXISTS \"tenant_acme\"");
470    }
471
472    #[test]
473    fn test_create_schema_sql_with_template() {
474        let config = SchemaPerTenantConfig::new().with_template("tenant_template");
475        let strategy = SchemaPerTenantStrategy::new(config).unwrap();
476        let sql = strategy.create_schema_sql(&TenantId::new("acme"));
477        assert!(sql.contains("TEMPLATE"));
478        assert!(sql.contains("tenant_template"));
479    }
480
481    #[test]
482    fn test_drop_schema_sql() {
483        let strategy = SchemaPerTenantStrategy::new(SchemaPerTenantConfig::default()).unwrap();
484
485        let sql = strategy.drop_schema_sql(&TenantId::new("acme"), false);
486        assert_eq!(sql, "DROP SCHEMA IF EXISTS \"tenant_acme\"");
487
488        let sql_cascade = strategy.drop_schema_sql(&TenantId::new("acme"), true);
489        assert_eq!(sql_cascade, "DROP SCHEMA IF EXISTS \"tenant_acme\" CASCADE");
490    }
491
492    #[test]
493    fn test_schema_exists_sql() {
494        let strategy = SchemaPerTenantStrategy::new(SchemaPerTenantConfig::default()).unwrap();
495        let sql = strategy.schema_exists_sql(&TenantId::new("acme"));
496        assert!(sql.contains("information_schema.schemata"));
497        assert!(sql.contains("tenant_acme"));
498    }
499
500    #[test]
501    fn test_list_tenant_schemas_sql() {
502        let strategy = SchemaPerTenantStrategy::new(SchemaPerTenantConfig::default()).unwrap();
503        let sql = strategy.list_tenant_schemas_sql();
504        assert!(sql.contains("LIKE 'tenant_%'"));
505    }
506
507    #[test]
508    fn test_system_tenant_resolution() {
509        let strategy = SchemaPerTenantStrategy::new(SchemaPerTenantConfig::default()).unwrap();
510        let resolution = strategy.system_tenant();
511
512        match resolution {
513            TenantResolution::Schema { schema_name } => {
514                assert_eq!(schema_name, "shared");
515            }
516            _ => panic!("expected Schema resolution"),
517        }
518    }
519
520    #[test]
521    fn test_schema_manager_create_shared() {
522        let strategy = SchemaPerTenantStrategy::new(SchemaPerTenantConfig::default()).unwrap();
523        let manager = SchemaManager::new(&strategy);
524        let ddl = manager.create_shared_schema_ddl();
525        assert!(ddl.contains("CREATE SCHEMA IF NOT EXISTS"));
526        assert!(ddl.contains("shared"));
527    }
528
529    #[test]
530    fn test_tenant_validation_valid() {
531        let strategy = SchemaPerTenantStrategy::new(SchemaPerTenantConfig::default()).unwrap();
532        assert!(strategy.validate(&TenantId::new("acme")).is_ok());
533        assert!(strategy.validate(&TenantId::new("acme-corp")).is_ok());
534    }
535
536    #[test]
537    fn test_escape_identifier() {
538        let strategy = SchemaPerTenantStrategy::new(SchemaPerTenantConfig::default()).unwrap();
539        let escaped = strategy.escape_identifier("test\"schema");
540        assert_eq!(escaped, "\"test\"\"schema\"");
541    }
542}