memoir_core/migration/
mod.rs1use once_cell::sync::Lazy;
8use regex::Regex;
9use sea_orm_migration::prelude::*;
10use sea_orm_migration::sea_orm::{ConnectionTrait, DatabaseConnection};
11
12mod m20000000_000001_create_memories;
13mod m20000000_000002_create_memory_jobs;
14mod m20000000_000003_add_superseded_by;
15mod m20000000_000004_add_event_at;
16mod m20000000_000005_create_supersession_events;
17mod m20000000_000006_add_confidence_category_retirement;
18mod m20000000_000007_add_categorize_job_kind;
19mod m20000000_000008_add_reprocess_job_kind;
20mod m20000000_000009_add_relational_extract_job_kind;
21mod m20000000_000010_add_synthesize_job_kind;
22mod m20000000_000011_create_graph_triple_staging;
23
24pub const DEFAULT_SCHEMA: &str = "memoir";
26
27static SCHEMA_NAME_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[a-z_][a-z0-9_]*$").unwrap());
28
29#[derive(Debug, thiserror::Error)]
31pub enum MigrationError {
32 #[error("invalid schema name '{0}': must match [a-z_][a-z0-9_]*")]
33 InvalidSchema(String),
34
35 #[error("database error: {0}")]
36 Database(#[from] DbErr),
37}
38
39pub struct Migrator;
41
42#[async_trait::async_trait]
43impl MigratorTrait for Migrator {
44 fn migration_table_name() -> DynIden {
52 Alias::new("memoir_core_migrations").into_iden()
53 }
54
55 fn migrations() -> Vec<Box<dyn MigrationTrait>> {
56 vec![
57 Box::new(m20000000_000001_create_memories::Migration),
58 Box::new(m20000000_000002_create_memory_jobs::Migration),
59 Box::new(m20000000_000003_add_superseded_by::Migration),
60 Box::new(m20000000_000004_add_event_at::Migration),
61 Box::new(m20000000_000005_create_supersession_events::Migration),
62 Box::new(m20000000_000006_add_confidence_category_retirement::Migration),
63 Box::new(m20000000_000007_add_categorize_job_kind::Migration),
64 Box::new(m20000000_000008_add_reprocess_job_kind::Migration),
65 Box::new(m20000000_000009_add_relational_extract_job_kind::Migration),
66 Box::new(m20000000_000010_add_synthesize_job_kind::Migration),
67 Box::new(m20000000_000011_create_graph_triple_staging::Migration),
68 ]
69 }
70}
71
72pub async fn bootstrap_and_migrate(db: &DatabaseConnection, schema: &str) -> Result<(), MigrationError> {
85 validate_schema_name(schema)?;
86
87 db.execute_unprepared(&format!("CREATE SCHEMA IF NOT EXISTS {schema}"))
88 .await?;
89 db.execute_unprepared(&format!("SET search_path TO {schema}, public"))
90 .await?;
91
92 Migrator::up(db, None).await?;
93
94 Ok(())
95}
96
97fn validate_schema_name(schema: &str) -> Result<(), MigrationError> {
98 if SCHEMA_NAME_RE.is_match(schema) {
99 Ok(())
100 } else {
101 Err(MigrationError::InvalidSchema(schema.to_string()))
102 }
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 #[test]
110 fn should_accept_simple_lowercase_schema_name() {
111 assert!(validate_schema_name("memoir").is_ok());
112 assert!(validate_schema_name("project_a").is_ok());
113 assert!(validate_schema_name("_underscore_start").is_ok());
114 assert!(validate_schema_name("with123digits").is_ok());
115 }
116
117 #[test]
118 fn should_reject_schema_with_sql_injection_attempt() {
119 let result = validate_schema_name("memoir; DROP TABLE users; --");
120 assert!(matches!(result, Err(MigrationError::InvalidSchema(_))));
121 }
122
123 #[test]
124 fn should_reject_schema_starting_with_digit() {
125 let result = validate_schema_name("1memoir");
126 assert!(matches!(result, Err(MigrationError::InvalidSchema(_))));
127 }
128
129 #[test]
130 fn should_reject_empty_schema() {
131 let result = validate_schema_name("");
132 assert!(matches!(result, Err(MigrationError::InvalidSchema(_))));
133 }
134
135 #[test]
136 fn should_reject_uppercase_schema() {
137 let result = validate_schema_name("Memoir");
138 assert!(matches!(result, Err(MigrationError::InvalidSchema(_))));
139 }
140
141 #[test]
142 fn should_reject_schema_with_special_chars() {
143 assert!(matches!(
144 validate_schema_name("memoir-project"),
145 Err(MigrationError::InvalidSchema(_))
146 ));
147 assert!(matches!(
148 validate_schema_name("memoir.project"),
149 Err(MigrationError::InvalidSchema(_))
150 ));
151 assert!(matches!(
152 validate_schema_name("memoir project"),
153 Err(MigrationError::InvalidSchema(_))
154 ));
155 }
156}