helios_persistence/strategy/
schema_per_tenant.rs1use serde::{Deserialize, Serialize};
8
9use crate::tenant::TenantId;
10
11use super::{TenantResolution, TenantResolver, TenantValidationError};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct SchemaPerTenantConfig {
29 #[serde(default = "default_schema_prefix")]
33 pub schema_prefix: String,
34
35 #[serde(default = "default_shared_schema")]
37 pub shared_schema: String,
38
39 #[serde(default = "default_true")]
41 pub auto_create_schema: bool,
42
43 #[serde(default = "default_true")]
45 pub system_uses_public: bool,
46
47 #[serde(default = "default_max_schema_length")]
49 pub max_schema_length: usize,
50
51 #[serde(default = "default_schema_pattern")]
53 pub schema_pattern: String,
54
55 #[serde(default)]
57 pub drop_on_delete: bool,
58
59 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 }
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 pub fn new() -> Self {
103 Self::default()
104 }
105
106 pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
108 self.schema_prefix = prefix.into();
109 self
110 }
111
112 pub fn with_shared_schema(mut self, schema: impl Into<String>) -> Self {
114 self.shared_schema = schema.into();
115 self
116 }
117
118 pub fn with_template(mut self, template: impl Into<String>) -> Self {
120 self.template_schema = Some(template.into());
121 self
122 }
123
124 pub fn with_drop_on_delete(mut self) -> Self {
126 self.drop_on_delete = true;
127 self
128 }
129}
130
131#[derive(Debug, Clone)]
164pub struct SchemaPerTenantStrategy {
165 config: SchemaPerTenantConfig,
166 schema_pattern: regex::Regex,
167}
168
169impl SchemaPerTenantStrategy {
170 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 pub fn config(&self) -> &SchemaPerTenantConfig {
181 &self.config
182 }
183
184 pub fn shared_schema(&self) -> &str {
186 &self.config.shared_schema
187 }
188
189 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 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 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 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 pub fn reset_search_path_sql(&self) -> String {
231 "RESET search_path".to_string()
232 }
233
234 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 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 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 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 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 fn escape_identifier(&self, id: &str) -> String {
283 format!("\"{}\"", id.replace('"', "\"\""))
284 }
285
286 fn escape_sql_string(&self, s: &str) -> String {
288 s.replace('\'', "''")
289 }
290
291 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#[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 pub fn new(strategy: &'a SchemaPerTenantStrategy) -> Self {
347 Self { strategy }
348 }
349
350 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 #[allow(dead_code)]
361 pub fn create_table_ddl(&self, schema: &str, table_ddl: &str) -> String {
362 format!(
364 "SET search_path TO {};\n{}",
365 self.strategy.escape_identifier(schema),
366 table_ddl
367 )
368 }
369
370 #[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}