Skip to main content

siderite_cli/
connect.rs

1//! Open a [`Db`] from a database URL.
2//!
3//! The URL scheme picks the backend: `sqlite:`, `postgres://` (feature
4//! `postgres`) or `mysql://` (feature `mysql`). Error messages name the
5//! scheme only, never the URL, which may hold a password.
6
7use siderite_backends::sqlite::SqliteBackend;
8use siderite_migrations::MigrationError;
9use siderite_orm::{BackendKind, Db, OrmError};
10
11/// The scheme of `url` (text before the first `:`), lower-cased, when it is
12/// a valid URL scheme.
13pub fn url_scheme(url: &str) -> Option<String> {
14    let (scheme, _) = url.split_once(':')?;
15    let mut chars = scheme.chars();
16    let first = chars.next()?;
17    let valid = first.is_ascii_alphabetic()
18        && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
19    valid.then(|| scheme.to_ascii_lowercase())
20}
21
22/// The backend a URL scheme selects, or `None` for an unknown scheme.
23pub fn backend_kind(url: &str) -> Option<BackendKind> {
24    match url_scheme(url)?.as_str() {
25        "sqlite" => Some(BackendKind::Sqlite),
26        "postgres" | "postgresql" => Some(BackendKind::Postgres),
27        "mysql" | "mariadb" => Some(BackendKind::MySql),
28        "mongodb" | "mongodb+srv" => Some(BackendKind::MongoDb),
29        "redis" | "rediss" => Some(BackendKind::Redis),
30        _ => None,
31    }
32}
33
34/// The file of a `sqlite:` URL (`sqlite://app.db?mode=rwc` -> `app.db`),
35/// or `None` for an in-memory database.
36pub(crate) fn sqlite_path(url: &str) -> Option<&str> {
37    let rest = url.split_once(':').map_or("", |(_, rest)| rest);
38    let rest = rest.trim_start_matches("//");
39    let path = rest.split('?').next().unwrap_or_default();
40    (!path.is_empty() && path != ":memory:").then_some(path)
41}
42
43/// Open a [`Db`] from a `sqlite:`, `postgres://` or `mysql://` URL.
44///
45/// PostgreSQL and MySQL URLs need the `postgres` and `mysql` features of this
46/// crate.
47///
48/// # Errors
49/// A usage error for an unsupported scheme or a missing feature (the message
50/// never contains the URL), or a connection failure.
51pub async fn connect_url(url: &str) -> Result<Db, MigrationError> {
52    match backend_kind(url) {
53        Some(BackendKind::Sqlite) => {
54            let backend = SqliteBackend::connect(url).await.map_err(OrmError::from)?;
55            Ok(Db::new(backend))
56        }
57        Some(BackendKind::Postgres) => connect_postgres(url).await,
58        Some(BackendKind::MySql) => connect_mysql(url).await,
59        _ => Err(MigrationError::usage(match url_scheme(url) {
60            Some(scheme) => format!(
61                "unsupported database URL scheme `{scheme}` (expected sqlite:, postgres:// or mysql://)"
62            ),
63            None => {
64                "database URL has no scheme (expected sqlite:, postgres:// or mysql://)".to_owned()
65            }
66        })),
67    }
68}
69
70#[cfg(feature = "postgres")]
71async fn connect_postgres(url: &str) -> Result<Db, MigrationError> {
72    let backend = siderite_backends::postgres::PgBackend::connect(url)
73        .await
74        .map_err(OrmError::from)?;
75    Ok(Db::new(backend))
76}
77
78#[cfg(not(feature = "postgres"))]
79async fn connect_postgres(_url: &str) -> Result<Db, MigrationError> {
80    Err(MigrationError::usage(
81        "PostgreSQL URLs require siderite-cli built with `--features postgres`",
82    ))
83}
84
85#[cfg(feature = "mysql")]
86async fn connect_mysql(url: &str) -> Result<Db, MigrationError> {
87    let backend = siderite_backends::mysql::MySqlBackend::connect(url)
88        .await
89        .map_err(OrmError::from)?;
90    Ok(Db::new(backend))
91}
92
93#[cfg(not(feature = "mysql"))]
94async fn connect_mysql(_url: &str) -> Result<Db, MigrationError> {
95    Err(MigrationError::usage(
96        "MySQL URLs require siderite-cli built with `--features mysql`",
97    ))
98}
99
100/// An in-memory SQLite database, for commands that need a [`Db`] but never
101/// touch it (`makemigrations`, `squashmigrations`).
102///
103/// # Errors
104/// A connection failure.
105pub async fn scratch_db() -> Result<Db, MigrationError> {
106    connect_url("sqlite::memory:").await
107}
108
109#[cfg(test)]
110mod tests {
111    #![allow(clippy::unwrap_used)]
112
113    use super::*;
114
115    #[test]
116    fn schemes_select_backends() {
117        assert_eq!(backend_kind("sqlite::memory:"), Some(BackendKind::Sqlite));
118        assert_eq!(backend_kind("SQLITE://a.db"), Some(BackendKind::Sqlite));
119        assert_eq!(backend_kind("postgres://h/db"), Some(BackendKind::Postgres));
120        assert_eq!(
121            backend_kind("postgresql://h/db"),
122            Some(BackendKind::Postgres)
123        );
124        assert_eq!(backend_kind("mysql://h/db"), Some(BackendKind::MySql));
125        assert_eq!(backend_kind("mongodb+srv://h"), Some(BackendKind::MongoDb));
126        assert_eq!(backend_kind("redis://h"), Some(BackendKind::Redis));
127        assert_eq!(backend_kind("ftp://h"), None);
128        assert_eq!(backend_kind("no-scheme"), None);
129        assert_eq!(backend_kind("1bad://x"), None);
130    }
131
132    #[tokio::test]
133    async fn sqlite_connects() {
134        let db = connect_url("sqlite::memory:").await.unwrap();
135        assert_eq!(db.capabilities().kind, BackendKind::Sqlite);
136    }
137
138    #[tokio::test]
139    async fn unknown_scheme_error_hides_the_url() {
140        let err = connect_url("ftp://user:hunter2@host/db").await.unwrap_err();
141        let text = err.to_string();
142        assert!(
143            text.contains("unsupported database URL scheme `ftp`"),
144            "{text}"
145        );
146        assert!(!text.contains("hunter2"), "{text}");
147        assert!(!text.contains("host"), "{text}");
148    }
149
150    #[tokio::test]
151    async fn missing_scheme_is_a_usage_error() {
152        let err = connect_url("just-a-secret").await.unwrap_err();
153        let text = err.to_string();
154        assert!(text.contains("no scheme"), "{text}");
155        assert!(!text.contains("secret"), "{text}");
156    }
157
158    #[cfg(not(feature = "postgres"))]
159    #[tokio::test]
160    async fn postgres_urls_need_the_feature() {
161        let err = connect_url("postgres://u:hunter2@localhost/db")
162            .await
163            .unwrap_err();
164        let text = err.to_string();
165        assert!(text.contains("--features postgres"), "{text}");
166        assert!(!text.contains("hunter2"), "{text}");
167    }
168
169    #[cfg(not(feature = "mysql"))]
170    #[tokio::test]
171    async fn mysql_urls_need_the_feature() {
172        let err = connect_url("mysql://u:hunter2@localhost/db")
173            .await
174            .unwrap_err();
175        let text = err.to_string();
176        assert!(text.contains("--features mysql"), "{text}");
177        assert!(!text.contains("hunter2"), "{text}");
178    }
179
180    /// Needs a server: `MYSQL_URL=mysql://... cargo test -p siderite-cli --features mysql -- --ignored`.
181    #[cfg(feature = "mysql")]
182    #[tokio::test]
183    #[ignore = "needs a MySQL server: set MYSQL_URL"]
184    async fn mysql_connects() {
185        let Some(url) = ["MYSQL_URL", "DATABASE_URL"]
186            .into_iter()
187            .filter_map(|key| std::env::var(key).ok())
188            .find(|url| backend_kind(url) == Some(BackendKind::MySql))
189        else {
190            eprintln!("MYSQL_URL not set; skipping");
191            return;
192        };
193        let db = connect_url(&url).await.unwrap();
194        assert_eq!(db.capabilities().kind, BackendKind::MySql);
195    }
196}