1use siderite_backends::sqlite::SqliteBackend;
8use siderite_migrations::MigrationError;
9use siderite_orm::{BackendKind, Db, OrmError};
10
11pub 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
22pub 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
34pub(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
43pub 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
100pub 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 #[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}