1use camel_api::CamelError;
18use camel_config::config::CamelConfig;
19use serde::Deserialize;
20
21pub const SQL_ACTION_KEY: &str = "sql";
23
24pub const SQL_MEMORY_NOT_SHARED: &str = "sql-memory-not-shared";
27
28#[derive(Debug, Clone, Deserialize)]
33#[serde(deny_unknown_fields, rename_all = "camelCase")]
34pub struct RawSqlAction {
35 datasource: String,
36 prepare: Vec<String>,
37}
38
39#[derive(Debug, Clone)]
41pub struct SqlAction {
42 pub datasource: String,
43 pub prepare: Vec<String>,
44}
45
46pub fn is_read_statement(stmt: &str) -> bool {
50 let mut rest = stmt;
51 loop {
52 rest = rest.trim_start();
53 if let Some(without_paren) = rest.strip_prefix('(') {
54 rest = without_paren;
55 } else {
56 break;
57 }
58 }
59 let lowered = rest.to_lowercase();
60 lowered.starts_with("select") || lowered.starts_with("with")
61}
62
63pub fn validate_sql_action(raw: &RawSqlAction, action_index: usize) -> Result<SqlAction, String> {
66 if raw.prepare.is_empty() {
67 return Err(format!(
68 "sql action {action_index}: prepare list must not be empty"
69 ));
70 }
71 for (i, stmt) in raw.prepare.iter().enumerate() {
72 if is_read_statement(stmt) {
73 return Err(format!(
74 "sql action {action_index}: prepare statement {i} is a read (select/with \
75 prefix); reads belong to the validate sql target"
76 ));
77 }
78 }
79 Ok(SqlAction {
80 datasource: raw.datasource.clone(),
81 prepare: raw.prepare.clone(),
82 })
83}
84
85pub fn sanitize_db_error(err_text: &str, db_url: &str) -> String {
89 if db_url.is_empty() {
90 return err_text.to_string();
91 }
92 err_text.replace(db_url, "[REDACTED]")
93}
94
95pub fn ensure_sqlite_memory_shared(config: &CamelConfig) -> Result<(), CamelError> {
119 let mut names: Vec<&String> = config.datasources.keys().collect();
120 names.sort();
121 for name in names {
122 let lowered = config.datasources[name].db_url.to_lowercase();
123 let prefix = ["sqlite::memory:", "sqlite://:memory:"]
124 .iter()
125 .find(|p| lowered.starts_with(**p))
126 .copied();
127 if let Some(prefix) = prefix {
128 let remainder = &lowered[prefix.len()..];
129 if !remainder.contains("cache=shared") {
130 return Err(CamelError::Config(format!(
131 "{}: datasource '{}' uses a per-connection sqlite :memory: URL without \
132 cache=shared; INSERT and SELECT can hit different databases. Prefer the \
133 named shared-memory URI sqlite:file:memdb_{}?mode=memory&cache=shared; \
134 sqlite:file: matches no automatic datasource prefix, so also set \
135 provider = \"sqlx\". The bare sqlite::memory:?cache=shared URL is also \
136 accepted, but cross-connection sharing is not guaranteed; set \
137 max_connections = 1 so state stays on one connection",
138 SQL_MEMORY_NOT_SHARED, name, name
139 )));
140 }
141 }
142 }
143 Ok(())
144}
145
146#[cfg(feature = "sql")]
147use std::sync::Arc;
148
149#[cfg(feature = "sql")]
150use camel_api::datasource::DatasourceCatalog;
151
152#[cfg(feature = "sql")]
157pub async fn execute_sql_prepare(
158 catalog: &Arc<dyn DatasourceCatalog>,
159 action: &SqlAction,
160) -> Result<(), String> {
161 let name = &action.datasource;
162 let Some(config) = catalog.get_config(name) else {
163 return Err(format!("sql action: unknown datasource '{name}'"));
164 };
165 let handle = catalog.get_pool(name).await.map_err(|e| {
166 format!(
167 "sql action: datasource '{name}': {}",
168 sanitize_db_error(&e.to_string(), &config.db_url)
169 )
170 })?;
171 let pool = handle.downcast::<sqlx::AnyPool>().map_err(|e| {
172 format!(
173 "sql action: datasource '{name}': {}",
174 sanitize_db_error(&e.to_string(), &config.db_url)
175 )
176 })?;
177 for (i, stmt) in action.prepare.iter().enumerate() {
178 sqlx::query(stmt).execute(&*pool).await.map_err(|e| {
179 format!(
180 "datasource '{name}' statement [{i}]: {}",
181 sanitize_db_error(&e.to_string(), &config.db_url)
182 )
183 })?;
184 }
185 Ok(())
186}
187
188#[cfg(test)]
189mod tests {
190 use std::collections::HashMap;
191
192 use super::*;
193 use camel_api::datasource::DatasourceConfig;
194
195 fn lint_config(db_url: &str) -> CamelConfig {
196 let mut config = CamelConfig::default();
197 config.datasources.insert(
198 "appdb".to_string(),
199 DatasourceConfig {
200 db_url: db_url.to_string(),
201 provider: None,
202 max_connections: None,
203 min_connections: None,
204 idle_timeout_secs: None,
205 max_lifetime_secs: None,
206 ssl_mode: None,
207 ssl_root_cert: None,
208 ssl_cert: None,
209 ssl_key: None,
210 extra: HashMap::new(),
211 },
212 );
213 config
214 }
215
216 fn raw(datasource: &str, prepare: Vec<&str>) -> RawSqlAction {
217 RawSqlAction {
218 datasource: datasource.to_string(),
219 prepare: prepare.into_iter().map(str::to_string).collect(),
220 }
221 }
222
223 #[test]
224 fn read_statement_detection() {
225 assert!(is_read_statement("SELECT 1"));
226 assert!(is_read_statement(" ( select 1 )"));
227 assert!(is_read_statement("With x AS (SELECT 1) SELECT * FROM x"));
228 assert!(!is_read_statement("INSERT INTO t VALUES (1)"));
229 assert!(!is_read_statement("(CREATE TABLE t (x INTEGER))"));
230 }
231
232 #[test]
233 fn validation_rejects_empty_prepare() {
234 let err = validate_sql_action(&raw("appdb", vec![]), 3).unwrap_err();
235 assert!(err.contains("sql action 3"), "got: {err}");
236 assert!(err.contains("prepare list must not be empty"), "got: {err}");
237 }
238
239 #[test]
240 fn validation_rejects_read_prefixes() {
241 let err = validate_sql_action(&raw("appdb", vec!["(SELECT 1)"]), 0).unwrap_err();
242 assert!(err.contains("statement 0"), "got: {err}");
243
244 let err = validate_sql_action(
245 &raw(
246 "appdb",
247 vec![
248 "CREATE TABLE t (x INTEGER)",
249 "with cte as (select 1) select * from cte",
250 ],
251 ),
252 0,
253 )
254 .unwrap_err();
255 assert!(err.contains("statement 1"), "got: {err}");
256 }
257
258 #[test]
259 fn validation_accepts_mutations() {
260 let action = validate_sql_action(
261 &raw(
262 "appdb",
263 vec![
264 "CREATE TABLE t (x INTEGER)",
265 "INSERT INTO t VALUES (1)",
266 "DELETE FROM t WHERE x = 1",
267 ],
268 ),
269 0,
270 )
271 .unwrap();
272 assert_eq!(action.datasource, "appdb");
273 assert_eq!(
274 action.prepare,
275 vec![
276 "CREATE TABLE t (x INTEGER)".to_string(),
277 "INSERT INTO t VALUES (1)".to_string(),
278 "DELETE FROM t WHERE x = 1".to_string(),
279 ]
280 );
281 }
282
283 #[test]
284 fn sanitizer_redacts_db_url() {
285 let sanitized = sanitize_db_error(
286 "connect failed sqlite::memory:?cache=shared&x=1",
287 "sqlite::memory:?cache=shared&x=1",
288 );
289 assert!(sanitized.contains("[REDACTED]"), "got: {sanitized}");
290 assert!(!sanitized.contains("cache=shared&x=1"), "got: {sanitized}");
291 }
292
293 #[test]
294 fn sanitizer_empty_url_noop() {
295 assert_eq!(sanitize_db_error("boom", ""), "boom");
296 }
297
298 #[test]
299 fn lint_message_steers_to_named_form() {
300 let err = ensure_sqlite_memory_shared(&lint_config("sqlite::memory:")).unwrap_err();
301 let text = err.to_string();
302 assert!(text.contains(SQL_MEMORY_NOT_SHARED), "got: {text}");
303 assert!(
304 text.contains("sqlite:file:memdb_appdb?mode=memory&cache=shared"),
305 "got: {text}"
306 );
307 assert!(text.contains("provider"), "got: {text}");
308 assert!(text.contains("max_connections = 1"), "got: {text}");
309 assert!(!text.contains("<name>"), "got: {text}");
310 assert!(!text.contains("<scenario>"), "got: {text}");
311 }
312
313 #[test]
314 fn lint_accepts_bare_shared_form() {
315 let config = lint_config("sqlite::memory:?cache=shared");
316 assert!(ensure_sqlite_memory_shared(&config).is_ok());
317 }
318
319 #[test]
320 fn lint_named_file_uri_passes() {
321 let config = lint_config("sqlite:file:memdb_appdb?mode=memory&cache=shared");
322 assert!(ensure_sqlite_memory_shared(&config).is_ok());
323 }
324}