Skip to main content

doido_model/
create.rs

1//! Database creation, backing `doido db create`.
2//!
3//! SeaORM has no "create database" primitive, so this implements the common
4//! per-backend approach:
5//!   * **SQLite** — ensure the parent directory exists and open the file with
6//!     `mode=rwc`, which creates it.
7//!   * **PostgreSQL / MySQL** — connect to the server *without* selecting the
8//!     target database, then issue `CREATE DATABASE`.
9
10use sea_orm::{ConnectionTrait, Database, DatabaseBackend, DbErr};
11
12/// Creates the database named in `url`.
13///
14/// Returns `Ok(())` once the database exists. Callers may inspect the error for
15/// an "already exists" message to treat re-creation as a no-op.
16pub async fn create_database(url: &str) -> Result<(), DbErr> {
17    if is_sqlite(url) {
18        return create_sqlite(url).await;
19    }
20
21    let (server_url, db_name) = split_server_and_database(url)?;
22    let db = Database::connect(server_url).await?;
23    let backend = db.get_database_backend();
24    let quoted = quote_identifier(backend, &db_name)?;
25    db.execute_unprepared(&format!("CREATE DATABASE {quoted}"))
26        .await?;
27    Ok(())
28}
29
30fn is_sqlite(url: &str) -> bool {
31    url.starts_with("sqlite:")
32}
33
34/// Creates a SQLite database file (and its parent directory) by opening it with
35/// `mode=rwc`.
36async fn create_sqlite(url: &str) -> Result<(), DbErr> {
37    let raw = url
38        .strip_prefix("sqlite://")
39        .or_else(|| url.strip_prefix("sqlite:"))
40        .unwrap_or(url);
41    let path = raw.split('?').next().unwrap_or(raw);
42
43    if !path.is_empty() && !path.contains(":memory:") {
44        if let Some(parent) = std::path::Path::new(path).parent() {
45            if !parent.as_os_str().is_empty() {
46                std::fs::create_dir_all(parent)
47                    .map_err(|e| DbErr::Custom(format!("failed to create directory: {e}")))?;
48            }
49        }
50    }
51
52    Database::connect(ensure_sqlite_rwc(url)).await?;
53    Ok(())
54}
55
56/// Appends `mode=rwc` so SQLite creates the file when missing.
57fn ensure_sqlite_rwc(url: &str) -> String {
58    if url.contains("mode=") {
59        url.to_string()
60    } else if url.contains('?') {
61        format!("{url}&mode=rwc")
62    } else {
63        format!("{url}?mode=rwc")
64    }
65}
66
67/// Splits `url` into a server-level connection URL (no target database) and the
68/// target database name.
69fn split_server_and_database(url: &str) -> Result<(String, String), DbErr> {
70    let mut parsed =
71        url::Url::parse(url).map_err(|e| DbErr::Custom(format!("invalid database URL: {e}")))?;
72    let db_name = parsed.path().trim_start_matches('/').to_string();
73    if db_name.is_empty() {
74        return Err(DbErr::Custom(
75            "database URL is missing a database name".to_string(),
76        ));
77    }
78
79    let scheme = parsed.scheme().to_string();
80    if scheme.starts_with("postgres") {
81        // PostgreSQL requires connecting to an existing database; use the
82        // always-present `postgres` maintenance database.
83        parsed.set_path("/postgres");
84    } else if scheme.starts_with("mysql") {
85        // MySQL can connect without selecting a database.
86        parsed.set_path("/");
87    } else {
88        return Err(DbErr::Custom(format!(
89            "unsupported database scheme '{scheme}' for create"
90        )));
91    }
92
93    Ok((parsed.to_string(), db_name))
94}
95
96/// Quotes a database identifier for the given backend.
97fn quote_identifier(backend: DatabaseBackend, name: &str) -> Result<String, DbErr> {
98    match backend {
99        DatabaseBackend::Postgres => Ok(format!("\"{}\"", name.replace('"', "\"\""))),
100        DatabaseBackend::MySql => Ok(format!("`{}`", name.replace('`', "``"))),
101        DatabaseBackend::Sqlite => Err(DbErr::Custom(
102            "SQLite databases are created as files, not via CREATE DATABASE".to_string(),
103        )),
104        other => Err(DbErr::Custom(format!(
105            "unsupported database backend for create: {other:?}"
106        ))),
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn splits_postgres_url_to_maintenance_db() {
116        let (server, name) =
117            split_server_and_database("postgres://user:pass@localhost:5432/my_app_development")
118                .unwrap();
119        assert_eq!(server, "postgres://user:pass@localhost:5432/postgres");
120        assert_eq!(name, "my_app_development");
121    }
122
123    #[test]
124    fn splits_mysql_url_dropping_database() {
125        let (server, name) =
126            split_server_and_database("mysql://root@localhost:3306/my_app_test").unwrap();
127        assert_eq!(server, "mysql://root@localhost:3306/");
128        assert_eq!(name, "my_app_test");
129    }
130
131    #[test]
132    fn errors_when_database_name_missing() {
133        assert!(split_server_and_database("postgres://localhost").is_err());
134    }
135
136    #[test]
137    fn appends_rwc_to_sqlite_url() {
138        assert_eq!(
139            ensure_sqlite_rwc("sqlite://db/development.db"),
140            "sqlite://db/development.db?mode=rwc"
141        );
142        assert_eq!(
143            ensure_sqlite_rwc("sqlite://db/development.db?cache=shared"),
144            "sqlite://db/development.db?cache=shared&mode=rwc"
145        );
146        assert_eq!(
147            ensure_sqlite_rwc("sqlite://db/x.db?mode=rwc"),
148            "sqlite://db/x.db?mode=rwc"
149        );
150    }
151
152    #[test]
153    fn quotes_identifiers_per_backend() {
154        assert_eq!(
155            quote_identifier(DatabaseBackend::Postgres, "app").unwrap(),
156            "\"app\""
157        );
158        assert_eq!(
159            quote_identifier(DatabaseBackend::MySql, "app").unwrap(),
160            "`app`"
161        );
162    }
163}