Skip to main content

camel_integration_test/
sql_action.rs

1//! Scenario `sql:` action — datasource state preparation for the
2//! integration tier (bd rc-25lup.1).
3//!
4//! Papal-locked architecture (bd rc-25lup, consult 2026-09-08): this is
5//! the scenario state branch. `sql:` seeds mutable state at rest
6//! through a datasource's pool before route assertions run; reads stay
7//! in the `validate` sql target and the two vocabularies are never
8//! mixed (Citrus precedent: fixture setup and assertions are separate
9//! actions). [`is_read_statement`] + [`validate_sql_action`] enforce
10//! that split at document load time.
11//!
12//! Executor diagnostics pass through [`sanitize_db_error`], which
13//! redacts the datasource URL per ADR-0051 (Credential Redaction at
14//! Diagnostic Boundaries): database URLs carry credential bytes and
15//! must not reach test failure output.
16
17use camel_api::CamelError;
18use camel_config::config::CamelConfig;
19use serde::Deserialize;
20
21/// Key under which a scenario action selects this vocabulary.
22pub const SQL_ACTION_KEY: &str = "sql";
23
24/// Diagnostic key for the boot-time lint that rejects per-connection
25/// sqlite `:memory:` datasource URLs (spa-3, bd rc-25lup.1).
26pub const SQL_MEMORY_NOT_SHARED: &str = "sql-memory-not-shared";
27
28/// Raw serde shape of the scenario `sql:` action as parsed from the
29/// document. Field names stay snake_case despite the `camelCase`
30/// rename (no multi-word fields today) — the attribute is load-bearing
31/// for future fields.
32#[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/// Validated, typed twin of [`RawSqlAction`].
40#[derive(Debug, Clone)]
41pub struct SqlAction {
42    pub datasource: String,
43    pub prepare: Vec<String>,
44}
45
46/// Whether `stmt` is a read (`select`/`with` prefix), tolerating
47/// leading whitespace and a wrapping parenthesis group. Re-trimming
48/// after each dropped `(` is what makes `"  ( select 1 )"` a read.
49pub 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
63/// Validates the raw shape into the typed action. Returns owned error
64/// strings; the `document.rs` hook wraps them into `DocError`.
65pub 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
85/// Replaces every occurrence of `db_url` in `err_text` with
86/// `[REDACTED]` (ADR-0051). An empty `db_url` is a no-op — an empty
87/// pattern would corrupt the text.
88pub 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
95/// Boot-time lint (ungated): rejects per-connection sqlite `:memory:`
96/// datasource URLs, which give every pooled connection its own private
97/// in-memory database — an INSERT through one connection and a SELECT
98/// through another can hit different databases.
99///
100/// The remediation message names two accepted forms:
101///
102/// - Named shared-memory URI, the scenario-tier convention, e.g.
103///   `sqlite:file:memdb_appdb?mode=memory&cache=shared`. All pool
104///   connections share one named in-memory database; the name is stable
105///   across connections and parses with the sqlx driver. `sqlite:file:`
106///   matches no automatic datasource factory prefix (factories match
107///   `scheme://` or `scheme::`), so the config must also pin
108///   `provider = "sqlx"`.
109/// - Bare shared cache, `sqlite::memory:?cache=shared`. Accepted, but
110///   each parse gets a sqlx-assigned private name, and pool connections
111///   under the Any driver can hold private databases — pin
112///   `max_connections = 1` with this form.
113///
114/// Iterates `config.datasources` in BTreeMap order (the config stores a
115/// `HashMap`, whose iteration order is unspecified) so diagnostics are
116/// deterministic. The error names the datasource only, never its URL
117/// (ADR-0051 credential redaction).
118pub 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/// Executes the prepare statements in order against the named
153/// datasource's pool. Stops at the first failure, reporting the
154/// statement index and a sanitized error; never emits the datasource
155/// URL.
156#[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}