camel_integration_test/
sql_action.rs1use 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> {
107 let mut names: Vec<&String> = config.datasources.keys().collect();
108 names.sort();
109 for name in names {
110 let lowered = config.datasources[name].db_url.to_lowercase();
111 let prefix = ["sqlite::memory:", "sqlite://:memory:"]
112 .iter()
113 .find(|p| lowered.starts_with(**p))
114 .copied();
115 if let Some(prefix) = prefix {
116 let remainder = &lowered[prefix.len()..];
117 if !remainder.contains("cache=shared") {
118 return Err(CamelError::Config(format!(
119 "{}: datasource '{}' uses a per-connection sqlite :memory: URL without \
120 cache=shared; INSERT and SELECT can hit different databases. Use \
121 sqlite::memory:?cache=shared",
122 SQL_MEMORY_NOT_SHARED, name
123 )));
124 }
125 }
126 }
127 Ok(())
128}
129
130#[cfg(feature = "sql")]
131use std::sync::Arc;
132
133#[cfg(feature = "sql")]
134use camel_api::datasource::DatasourceCatalog;
135
136#[cfg(feature = "sql")]
141pub async fn execute_sql_prepare(
142 catalog: &Arc<dyn DatasourceCatalog>,
143 action: &SqlAction,
144) -> Result<(), String> {
145 let name = &action.datasource;
146 let Some(config) = catalog.get_config(name) else {
147 return Err(format!("sql action: unknown datasource '{name}'"));
148 };
149 let handle = catalog.get_pool(name).await.map_err(|e| {
150 format!(
151 "sql action: datasource '{name}': {}",
152 sanitize_db_error(&e.to_string(), &config.db_url)
153 )
154 })?;
155 let pool = handle.downcast::<sqlx::AnyPool>().map_err(|e| {
156 format!(
157 "sql action: datasource '{name}': {}",
158 sanitize_db_error(&e.to_string(), &config.db_url)
159 )
160 })?;
161 for (i, stmt) in action.prepare.iter().enumerate() {
162 sqlx::query(stmt).execute(&*pool).await.map_err(|e| {
163 format!(
164 "datasource '{name}' statement [{i}]: {}",
165 sanitize_db_error(&e.to_string(), &config.db_url)
166 )
167 })?;
168 }
169 Ok(())
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 fn raw(datasource: &str, prepare: Vec<&str>) -> RawSqlAction {
177 RawSqlAction {
178 datasource: datasource.to_string(),
179 prepare: prepare.into_iter().map(str::to_string).collect(),
180 }
181 }
182
183 #[test]
184 fn read_statement_detection() {
185 assert!(is_read_statement("SELECT 1"));
186 assert!(is_read_statement(" ( select 1 )"));
187 assert!(is_read_statement("With x AS (SELECT 1) SELECT * FROM x"));
188 assert!(!is_read_statement("INSERT INTO t VALUES (1)"));
189 assert!(!is_read_statement("(CREATE TABLE t (x INTEGER))"));
190 }
191
192 #[test]
193 fn validation_rejects_empty_prepare() {
194 let err = validate_sql_action(&raw("appdb", vec![]), 3).unwrap_err();
195 assert!(err.contains("sql action 3"), "got: {err}");
196 assert!(err.contains("prepare list must not be empty"), "got: {err}");
197 }
198
199 #[test]
200 fn validation_rejects_read_prefixes() {
201 let err = validate_sql_action(&raw("appdb", vec!["(SELECT 1)"]), 0).unwrap_err();
202 assert!(err.contains("statement 0"), "got: {err}");
203
204 let err = validate_sql_action(
205 &raw(
206 "appdb",
207 vec![
208 "CREATE TABLE t (x INTEGER)",
209 "with cte as (select 1) select * from cte",
210 ],
211 ),
212 0,
213 )
214 .unwrap_err();
215 assert!(err.contains("statement 1"), "got: {err}");
216 }
217
218 #[test]
219 fn validation_accepts_mutations() {
220 let action = validate_sql_action(
221 &raw(
222 "appdb",
223 vec![
224 "CREATE TABLE t (x INTEGER)",
225 "INSERT INTO t VALUES (1)",
226 "DELETE FROM t WHERE x = 1",
227 ],
228 ),
229 0,
230 )
231 .unwrap();
232 assert_eq!(action.datasource, "appdb");
233 assert_eq!(
234 action.prepare,
235 vec![
236 "CREATE TABLE t (x INTEGER)".to_string(),
237 "INSERT INTO t VALUES (1)".to_string(),
238 "DELETE FROM t WHERE x = 1".to_string(),
239 ]
240 );
241 }
242
243 #[test]
244 fn sanitizer_redacts_db_url() {
245 let sanitized = sanitize_db_error(
246 "connect failed sqlite::memory:?cache=shared&x=1",
247 "sqlite::memory:?cache=shared&x=1",
248 );
249 assert!(sanitized.contains("[REDACTED]"), "got: {sanitized}");
250 assert!(!sanitized.contains("cache=shared&x=1"), "got: {sanitized}");
251 }
252
253 #[test]
254 fn sanitizer_empty_url_noop() {
255 assert_eq!(sanitize_db_error("boom", ""), "boom");
256 }
257}