1use async_trait::async_trait;
3use base64::Engine as _;
4use ecat_data::{RdbmsClient, RdbmsError, Row, TransactionInner};
5use ecat_tls::TlsClientConfig;
6use serde::Deserialize;
7use sqlx::any::AnyRow;
8use sqlx::{AnyPool, Column as SqlxColumn, Row as SqlxRow};
9
10#[derive(Debug, Clone, Deserialize)]
11pub struct SqlxConfig {
12 pub url: String,
13 #[serde(default)]
14 pub username: Option<String>,
15 #[serde(default)]
16 pub password: Option<String>,
17 #[serde(default)]
20 pub tls: Option<TlsClientConfig>,
21}
22
23fn percent_encode(s: &str) -> String {
24 s.chars()
25 .map(|c| match c {
26 ':' | '/' | '@' | '#' | '?' | '&' | '=' | '%' | '+' | ' ' => {
27 format!("%{:02X}", c as u8)
28 }
29 _ => c.to_string(),
30 })
31 .collect()
32}
33
34static DRIVERS_INSTALLED: std::sync::Once = std::sync::Once::new();
38
39fn ensure_drivers() {
40 DRIVERS_INSTALLED.call_once(sqlx::any::install_default_drivers);
41}
42
43pub struct SqlxClient {
44 pool: AnyPool,
45}
46
47impl SqlxClient {
48 pub async fn connect(url: &str) -> Result<Self, sqlx::Error> {
49 ensure_drivers();
50 let pool = AnyPool::connect(url).await?;
51 Ok(Self { pool })
52 }
53
54 pub async fn connect_with_auth(
55 url: &str,
56 username: &str,
57 password: &str,
58 ) -> Result<Self, sqlx::Error> {
59 let url = if url.contains('@') {
60 url.to_string()
61 } else {
62 let encoded_user = percent_encode(username);
63 let encoded_pass = percent_encode(password);
64 url.replacen("://", &format!("://{encoded_user}:{encoded_pass}@"), 1)
65 };
66 Self::connect(&url).await
67 }
68
69 pub async fn from_config(cfg: SqlxConfig) -> Result<Self, sqlx::Error> {
70 match (&cfg.username, &cfg.password) {
71 (Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
72 Self::connect_with_auth(&cfg.url, u, p).await
73 }
74 _ => Self::connect(&cfg.url).await,
75 }
76 }
77
78 pub fn from_pool(pool: AnyPool) -> Self {
79 Self { pool }
80 }
81}
82
83fn cell_to_json(row: &AnyRow, col: &str) -> serde_json::Value {
88 row.try_get::<bool, _>(col)
89 .map(serde_json::Value::Bool)
90 .or_else(|_| {
91 row.try_get::<i64, _>(col)
92 .map(|n| serde_json::Value::Number(n.into()))
93 })
94 .or_else(|_| {
95 row.try_get::<i32, _>(col)
96 .map(|n| serde_json::Value::Number((n as i64).into()))
97 })
98 .or_else(|_| {
99 row.try_get::<f64, _>(col)
100 .ok()
101 .and_then(|n| {
102 if n.is_finite() {
103 serde_json::Number::from_f64(n).map(serde_json::Value::Number)
104 } else if n.is_nan() {
105 Some(serde_json::Value::String("NaN".into()))
106 } else if n > 0.0 {
107 Some(serde_json::Value::String("Infinity".into()))
108 } else {
109 Some(serde_json::Value::String("-Infinity".into()))
110 }
111 })
112 .ok_or(())
113 })
114 .or_else(|_| row.try_get::<String, _>(col).map(serde_json::Value::String))
115 .or_else(|_| {
116 row.try_get::<Vec<u8>, _>(col).map(|b| {
117 serde_json::Value::String(base64::engine::general_purpose::STANDARD.encode(b))
118 })
119 })
120 .unwrap_or(serde_json::Value::Null)
121}
122
123fn rows_to_result(rows: Vec<AnyRow>) -> Vec<Row> {
124 if rows.is_empty() {
125 return Vec::new();
126 }
127 let columns: Vec<String> = rows[0]
128 .columns()
129 .iter()
130 .map(|c| c.name().to_string())
131 .collect();
132 rows.iter()
133 .map(|row| {
134 let values: Vec<serde_json::Value> =
135 columns.iter().map(|col| cell_to_json(row, col)).collect();
136 Row::new(columns.clone(), values)
137 })
138 .collect()
139}
140
141#[async_trait]
142impl RdbmsClient for SqlxClient {
143 async fn execute(&self, sql: &str) -> Result<u64, RdbmsError> {
144 sqlx::query(sql)
145 .execute(&self.pool)
146 .await
147 .map(|r| r.rows_affected())
148 .map_err(|e| RdbmsError::Database(e.to_string()))
149 }
150
151 async fn query(&self, sql: &str) -> Result<Vec<Row>, RdbmsError> {
152 let rows: Vec<AnyRow> = sqlx::query(sql)
153 .fetch_all(&self.pool)
154 .await
155 .map_err(|e| RdbmsError::Database(e.to_string()))?;
156 Ok(rows_to_result(rows))
157 }
158
159 async fn execute_with(
160 &self,
161 sql: &str,
162 params: &[serde_json::Value],
163 ) -> Result<u64, RdbmsError> {
164 let mut q = sqlx::query(sql);
165 for p in params {
166 q = match p {
167 serde_json::Value::String(s) => q.bind(s.as_str()),
168 serde_json::Value::Number(n) => {
169 if let Some(i) = n.as_i64() {
170 q.bind(i)
171 } else if let Some(f) = n.as_f64() {
172 q.bind(f)
173 } else {
174 q.bind(n.to_string())
175 }
176 }
177 serde_json::Value::Bool(b) => q.bind(*b),
178 serde_json::Value::Null => q.bind(None::<String>),
179 _ => q.bind(p.to_string()),
180 };
181 }
182 q.execute(&self.pool)
183 .await
184 .map(|r| r.rows_affected())
185 .map_err(|e| RdbmsError::Database(e.to_string()))
186 }
187
188 async fn query_with(
189 &self,
190 sql: &str,
191 params: &[serde_json::Value],
192 ) -> Result<Vec<Row>, RdbmsError> {
193 let mut q = sqlx::query(sql);
194 for p in params {
195 q = match p {
196 serde_json::Value::String(s) => q.bind(s.as_str()),
197 serde_json::Value::Number(n) => {
198 if let Some(i) = n.as_i64() {
199 q.bind(i)
200 } else if let Some(f) = n.as_f64() {
201 q.bind(f)
202 } else {
203 q.bind(n.to_string())
204 }
205 }
206 serde_json::Value::Bool(b) => q.bind(*b),
207 serde_json::Value::Null => q.bind(None::<String>),
208 _ => q.bind(p.to_string()),
209 };
210 }
211 let rows: Vec<sqlx::any::AnyRow> = q
212 .fetch_all(&self.pool)
213 .await
214 .map_err(|e| RdbmsError::Database(e.to_string()))?;
215 Ok(rows_to_result(rows))
216 }
217
218 async fn transaction(&self) -> Result<ecat_data::Transaction, RdbmsError> {
219 let tx = self
220 .pool
221 .begin()
222 .await
223 .map_err(|e| RdbmsError::Database(e.to_string()))?;
224 Ok(ecat_data::Transaction::with_inner(Box::new(
225 SqlxTransactionWrapper { inner: Some(tx) },
226 )))
227 }
228}
229
230struct SqlxTransactionWrapper {
231 inner: Option<sqlx::Transaction<'static, sqlx::Any>>,
232}
233
234#[async_trait]
235impl TransactionInner for SqlxTransactionWrapper {
236 async fn commit(&mut self) -> Result<(), RdbmsError> {
237 if let Some(tx) = self.inner.take() {
238 tx.commit()
239 .await
240 .map_err(|e| RdbmsError::Database(e.to_string()))?;
241 }
242 Ok(())
243 }
244
245 async fn rollback(&mut self) -> Result<(), RdbmsError> {
246 if let Some(tx) = self.inner.take() {
247 tx.rollback()
248 .await
249 .map_err(|e| RdbmsError::Database(e.to_string()))?;
250 }
251 Ok(())
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 #[test]
260 fn percent_encode_special_chars() {
261 assert_eq!(percent_encode("user:pass"), "user%3Apass");
262 assert_eq!(percent_encode("a/b@c"), "a%2Fb%40c");
263 assert_eq!(percent_encode("a#b?c&d=e"), "a%23b%3Fc%26d%3De");
264 assert_eq!(percent_encode("100%"), "100%25");
265 assert_eq!(percent_encode("a+b"), "a%2Bb");
266 assert_eq!(percent_encode("hello world"), "hello%20world");
267 }
268
269 #[test]
270 fn percent_encode_no_special_chars() {
271 assert_eq!(percent_encode("simple"), "simple");
272 assert_eq!(percent_encode("user123"), "user123");
273 assert_eq!(percent_encode(""), "");
274 }
275
276 #[test]
277 fn config_deserialize_basic() {
278 let cfg: SqlxConfig =
279 serde_json::from_str(r#"{"url": "postgres://localhost/db"}"#).unwrap();
280 assert_eq!(cfg.url, "postgres://localhost/db");
281 assert!(cfg.username.is_none());
282 assert!(cfg.password.is_none());
283 assert!(cfg.tls.is_none());
284 }
285
286 #[test]
287 fn config_deserialize_with_auth() {
288 let cfg: SqlxConfig = serde_json::from_str(
289 r#"{"url": "mysql://localhost/db", "username": "root", "password": "secret"}"#,
290 )
291 .unwrap();
292 assert_eq!(cfg.url, "mysql://localhost/db");
293 assert_eq!(cfg.username.as_deref(), Some("root"));
294 assert_eq!(cfg.password.as_deref(), Some("secret"));
295 }
296
297 #[test]
298 fn config_deserialize_with_tls() {
299 let cfg: SqlxConfig = serde_json::from_str(
300 r#"{"url": "postgres://localhost/db", "tls": {"skip_verify": true}}"#,
301 )
302 .unwrap();
303 assert!(cfg.tls.is_some());
304 let tls = cfg.tls.unwrap();
305 assert_eq!(tls.skip_verify, Some(true));
306 }
307
308 #[test]
309 fn config_missing_url_is_error() {
310 let result: Result<SqlxConfig, _> = serde_json::from_str(r#"{}"#);
311 assert!(result.is_err());
312 }
313
314 #[test]
315 fn from_pool_is_constructible() {
316 fn _check_sig(pool: sqlx::AnyPool) -> SqlxClient {
318 SqlxClient::from_pool(pool)
319 }
320 }
321
322 fn mem_sqlite(name: &str) -> String {
325 static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
326 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
327 format!("sqlite:ecat-test-{name}{n}?mode=memory&cache=shared")
328 }
329
330 fn init_drivers() {
332 sqlx::any::install_default_drivers();
333 }
334
335 async fn single_conn_client(name: &str) -> SqlxClient {
338 init_drivers();
339 let pool = sqlx::any::AnyPoolOptions::new()
340 .max_connections(1)
341 .connect(&mem_sqlite(name))
342 .await
343 .unwrap();
344 SqlxClient::from_pool(pool)
345 }
346
347 #[tokio::test]
348 async fn connect_and_execute_query_round_trip() {
349 let client = single_conn_client("t1").await;
350 client
351 .execute("CREATE TABLE t (id INTEGER, name TEXT)")
352 .await
353 .unwrap();
354 client
355 .execute("INSERT INTO t VALUES (1, 'alice'), (2, 'bob')")
356 .await
357 .unwrap();
358 let rows = client
359 .query("SELECT id, name FROM t ORDER BY id")
360 .await
361 .unwrap();
362 assert_eq!(rows.len(), 2);
363 assert_eq!(rows[0].get("id"), Some(&serde_json::json!(1)));
364 assert_eq!(rows[0].get("name"), Some(&serde_json::json!("alice")));
365 assert_eq!(rows[1].get("id"), Some(&serde_json::json!(2)));
366 assert_eq!(rows[1].get("missing"), None);
367 }
368
369 #[tokio::test]
370 async fn execute_with_binds_all_json_value_types() {
371 let client = single_conn_client("t1").await;
372 client
373 .execute("CREATE TABLE t (s TEXT, i INTEGER, f REAL, b INTEGER, n TEXT)")
374 .await
375 .unwrap();
376 let affected = client
377 .execute_with(
378 "INSERT INTO t VALUES (?, ?, ?, ?, ?)",
379 &[
380 serde_json::json!("str"),
381 serde_json::json!(42),
382 serde_json::json!(1.5),
383 serde_json::json!(true),
384 serde_json::Value::Null,
385 ],
386 )
387 .await
388 .unwrap();
389 assert_eq!(affected, 1);
390 let rows = client.query("SELECT * FROM t").await.unwrap();
391 assert_eq!(rows.len(), 1);
392 assert_eq!(rows[0].get("s"), Some(&serde_json::json!("str")));
393 assert_eq!(rows[0].get("i"), Some(&serde_json::json!(42)));
394 assert_eq!(rows[0].get("f"), Some(&serde_json::json!(1.5)));
395 assert_eq!(rows[0].get("b"), Some(&serde_json::json!(1)));
397 assert_eq!(rows[0].get("n"), Some(&serde_json::Value::Null));
398 }
399
400 #[tokio::test]
401 async fn query_with_parameterized_sql() {
402 let client = single_conn_client("t1").await;
403 client.execute("CREATE TABLE t (name TEXT)").await.unwrap();
404 client
405 .execute_with("INSERT INTO t VALUES (?)", &[serde_json::json!("x")])
406 .await
407 .unwrap();
408 let rows = client
409 .query_with(
410 "SELECT name FROM t WHERE name = ?",
411 &[serde_json::json!("x")],
412 )
413 .await
414 .unwrap();
415 assert_eq!(rows.len(), 1);
416 assert_eq!(rows[0].get("name"), Some(&serde_json::json!("x")));
417 }
418
419 #[tokio::test]
420 async fn cell_to_json_encodes_blob_as_base64() {
421 let client = single_conn_client("t1").await;
422 client.execute("CREATE TABLE t (data BLOB)").await.unwrap();
423 client
425 .execute("INSERT INTO t VALUES (x'010203')")
426 .await
427 .unwrap();
428 let rows = client.query("SELECT data FROM t").await.unwrap();
429 assert_eq!(rows[0].get("data"), Some(&serde_json::json!("AQID")));
430 }
431
432 #[tokio::test]
433 async fn connect_with_auth_does_not_inject_into_non_url_scheme() {
434 init_drivers();
436 let client = SqlxClient::connect_with_auth(&mem_sqlite("t2"), "user", "pass")
437 .await
438 .unwrap();
439 client.execute("SELECT 1").await.unwrap();
440 }
441
442 #[tokio::test]
443 async fn from_config_without_auth_connects_and_queries() {
444 let cfg = SqlxConfig {
448 url: mem_sqlite("t3"),
449 username: None,
450 password: None,
451 tls: None,
452 };
453 let client = SqlxClient::from_config(cfg).await.unwrap();
454 let rows = client.query("SELECT 1 AS one").await.unwrap();
455 assert_eq!(rows.len(), 1);
456 assert_eq!(rows[0].get("one"), Some(&serde_json::json!(1)));
457 }
458
459 #[tokio::test]
460 async fn from_config_with_empty_credentials_connects_plain() {
461 for (u, p) in [
464 (Some("".to_string()), Some("".to_string())),
465 (Some("".to_string()), None),
466 ] {
467 let cfg = SqlxConfig {
468 url: mem_sqlite("t3"),
469 username: u,
470 password: p,
471 tls: None,
472 };
473 let client = SqlxClient::from_config(cfg).await.unwrap();
474 client.execute("SELECT 1").await.unwrap();
475 }
476 }
477}