Skip to main content

camel_component_sql/
producer.rs

1use std::future::Future;
2use std::pin::Pin;
3use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::task::{Context, Poll};
6use std::time::Duration;
7
8use bytes::Bytes;
9use camel_api::datasource::DatasourceCatalog;
10use serde_json::json;
11use sqlx::AnyPool;
12use sqlx::any::AnyRow;
13use sqlx::pool::PoolOptions;
14use tokio::sync::OnceCell;
15use tower::Service;
16use tracing::{debug, error, info, warn};
17
18use crate::config::{SqlEndpointConfig, SqlOutputType, enrich_db_url_with_ssl, redact_db_url};
19use crate::headers;
20use crate::query::{PreparedQuery, is_select_query, parse_query_template, resolve_params};
21use crate::utils::{bind_json_values, is_retryable_sqlx_error, row_to_json};
22use camel_component_api::retry_async;
23use camel_component_api::{
24    Body, CamelError, Exchange, Message, RuntimeObservability, StreamBody, StreamMetadata,
25};
26
27#[derive(Clone)]
28pub struct SqlProducer {
29    pub(crate) config: SqlEndpointConfig,
30    pub(crate) pool: Arc<OnceCell<Arc<AnyPool>>>,
31    pub(crate) catalog: Option<Arc<dyn DatasourceCatalog>>,
32    pub(crate) stopped: Arc<AtomicBool>,
33    pub(crate) runtime: Arc<dyn RuntimeObservability>,
34    /// Route identifier for ADR-0012 (g) health/observability calls.
35    pub(crate) route_id: String,
36}
37
38impl SqlProducer {
39    pub fn new(
40        config: SqlEndpointConfig,
41        pool: Arc<OnceCell<Arc<AnyPool>>>,
42        catalog: Option<Arc<dyn DatasourceCatalog>>,
43        runtime: Arc<dyn RuntimeObservability>,
44        route_id: impl Into<String>,
45    ) -> Self {
46        Self {
47            config,
48            pool,
49            catalog,
50            stopped: Arc::new(AtomicBool::new(false)),
51            runtime,
52            route_id: route_id.into(),
53        }
54    }
55
56    pub fn stop(&self) {
57        self.stopped.store(true, Ordering::Relaxed);
58        // Close the pool asynchronously in the background with a timeout
59        if let Some(pool) = self.pool.get() {
60            let pool = pool.clone();
61            tokio::spawn(async move {
62                if tokio::time::timeout(Duration::from_secs(5), pool.close())
63                    .await
64                    .is_err()
65                {
66                    tracing::warn!("SQL producer pool did not close within 5s");
67                }
68            });
69        }
70    }
71
72    /// Resolves the query source based on priority:
73    /// 1. Header `CamelSql.Query` (only if `allow_dynamic_query=true`)
74    /// 2. Body (only if `allow_dynamic_query=true` AND `use_message_body_for_sql=true`)
75    /// 3. Config query
76    ///
77    /// H7: header/body are untrusted exchange data (ADR-0032). Sourcing the
78    /// query from them is full SQLi (incl. DDL). Both routes require the
79    /// explicit `allow_dynamic_query=true` opt-in.
80    pub(crate) fn resolve_query_source(exchange: &Exchange, config: &SqlEndpointConfig) -> String {
81        if config.allow_dynamic_query {
82            // Priority 1: Header
83            if let Some(query_value) = exchange.input.header(headers::QUERY)
84                && let Some(query_str) = query_value.as_str()
85            {
86                return query_str.to_string();
87            }
88
89            // Priority 2: Body (requires BOTH flags)
90            if config.use_message_body_for_sql
91                && let Some(body_text) = exchange.input.body.as_text()
92            {
93                return body_text.to_string();
94            }
95        }
96
97        // Priority 3: Config query
98        config.query.clone()
99    }
100
101    /// Health check: runs a simple `SELECT 1` query against the connection pool
102    /// to verify database connectivity.
103    ///
104    /// Returns `Ok(())` if the database is reachable, or an error with details
105    /// if the connection fails.
106    pub async fn check_connection(&self) -> Result<(), CamelError> {
107        let pool =
108            if let (Some(catalog), Some(ds_name)) = (&self.catalog, &self.config.datasource_name) {
109                let handle = catalog.get_pool(ds_name).await?;
110                let arc_pool: Arc<AnyPool> = handle.downcast()?;
111                // We don't cache this here — check_connection is a one-shot call
112                arc_pool
113            } else {
114                self.pool
115                    .get()
116                    .ok_or_else(|| {
117                        CamelError::ProcessorError("SQL connection pool not initialized".into())
118                    })?
119                    .clone()
120            };
121
122        debug!("Running health check: SELECT 1");
123        sqlx::query("SELECT 1").execute(&*pool).await.map_err(|e| {
124            warn!(error = %e, "SQL health check failed");
125            CamelError::ProcessorError(format!("SQL health check failed: {}", e))
126        })?;
127
128        debug!("SQL health check passed");
129        Ok(())
130    }
131}
132
133impl Service<Exchange> for SqlProducer {
134    type Response = Exchange;
135    type Error = CamelError;
136    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
137
138    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
139        if self.stopped.load(Ordering::Relaxed) {
140            return Poll::Ready(Err(CamelError::ProcessorError(
141                "SQL producer stopped".into(),
142            )));
143        }
144        if let Some(pool) = self.pool.get()
145            && pool.is_closed()
146        {
147            return Poll::Ready(Err(CamelError::ProcessorError(
148                "SQL connection pool is closed".into(),
149            )));
150        }
151        Poll::Ready(Ok(()))
152    }
153
154    fn call(&mut self, mut exchange: Exchange) -> Self::Future {
155        let mut config = self.config.clone();
156        let pool_cell = Arc::clone(&self.pool);
157        let catalog = self.catalog.clone();
158        let runtime = Arc::clone(&self.runtime);
159        let route_id = self.route_id.clone();
160
161        Box::pin(async move {
162            // SQL-014: resolve file-based query before pool init, regardless of pool source
163            config.resolve_defaults();
164            config.resolve_file_query().await?;
165
166            // Get or initialize the connection pool
167            let pool: &Arc<AnyPool> = pool_cell
168                .get_or_try_init(|| async {
169                    // Catalog path: resolve shared pool from the datasource catalog
170                    let ds_name = config.datasource_name.clone();
171                    if let (Some(ref cat), Some(ref name)) = (catalog, ds_name) {
172                        let handle = cat.get_pool(name).await?;
173                        return handle.downcast::<AnyPool>();
174                    }
175
176                    let db_url = enrich_db_url_with_ssl(&config.db_url, &config)?;
177
178                    // Install all compiled-in sqlx drivers so AnyPool can resolve them.
179                    // This is idempotent; safe to call multiple times.
180                    sqlx::any::install_default_drivers();
181
182                    let max_conn = config.max_connections.ok_or_else(|| {
183                        CamelError::Config("max_connections not resolved for SQL pool".into())
184                    })?;
185                    let min_conn = config.min_connections.ok_or_else(|| {
186                        CamelError::Config("min_connections not resolved for SQL pool".into())
187                    })?;
188                    let idle_timeout = config.idle_timeout_secs.ok_or_else(|| {
189                        CamelError::Config("idle_timeout_secs not resolved for SQL pool".into())
190                    })?;
191                    let max_lifetime = config.max_lifetime_secs.ok_or_else(|| {
192                        CamelError::Config("max_lifetime_secs not resolved for SQL pool".into())
193                    })?;
194
195                    info!(
196                        db_url = %redact_db_url(&config.db_url),
197                        "SQL producer pool initializing"
198                    );
199                    let retry_policy = &config.retry;
200                    let pool = retry_async::<_, _, _, _, sqlx::Error>(
201                        retry_policy,
202                        "sql",
203                        "producer-pool-init",
204                        || {
205                            async {
206                                PoolOptions::new()
207                                    .max_connections(max_conn)
208                                    .min_connections(min_conn)
209                                    .idle_timeout(Duration::from_secs(idle_timeout))
210                                    .max_lifetime(Duration::from_secs(max_lifetime))
211                                    .connect(&db_url)
212                                    .await
213                            }
214                        },
215                        is_retryable_sqlx_error,
216                        Some(runtime.metrics().as_ref()),
217                    )
218                    .await
219                    .map_err(|e| {
220                        runtime.health().force_unhealthy_for_route(
221                            &route_id,
222                            "g:sql:producer-pool-init",
223                            &e.to_string(),
224                        );
225                        // log-policy: outside-contract
226                        error!(error = %e, db_url = %redact_db_url(&config.db_url), "Failed to connect to database");
227                        CamelError::EndpointCreationFailed(format!(
228                            "Failed to connect to database: {}",
229                            e
230                        ))
231                    })?;
232                    Ok(Arc::new(pool))
233                })
234                .await
235                .map_err(|e: CamelError| e.clone())?;
236
237            // Resolve query string
238            let query_str = Self::resolve_query_source(&exchange, &config);
239
240            // SQL-002: warn if Managed transaction mode requested
241            if config.transaction_mode == crate::config::TransactionMode::Managed {
242                warn!("transactionManager not yet implemented; using Auto mode");
243            }
244
245            debug!(
246                query = %query_str,
247                "executing SQL query"
248            );
249
250            // Execute based on mode
251            if config.batch {
252                // Batch mode: execute_batch handles its own template parsing per item
253                execute_batch(pool.as_ref(), &config, &mut exchange).await?;
254            } else if config.use_placeholder {
255                // Placeholder processing enabled (default): parse template, resolve params, apply header override
256                let template = parse_query_template(&query_str, config.placeholder)?;
257                let mut prepared = resolve_params(&template, &exchange, &config.in_separator)?;
258
259                // CamelSql.Parameters header override
260                if let Some(params_value) = exchange.input.header(headers::PARAMETERS) {
261                    if let Some(arr) = params_value.as_array() {
262                        if arr.len() != prepared.bindings.len() {
263                            warn!(
264                                expected = prepared.bindings.len(),
265                                got = arr.len(),
266                                header = headers::PARAMETERS,
267                                "Parameter count mismatch — SQL has {} placeholders but header provides {} values",
268                                prepared.bindings.len(),
269                                arr.len()
270                            );
271                        }
272                        debug!(
273                            "Overriding bindings from {} header with {} parameters",
274                            headers::PARAMETERS,
275                            arr.len()
276                        );
277                        prepared.bindings = arr.clone();
278                    } else {
279                        warn!(
280                            header = headers::PARAMETERS,
281                            "Header is present but not a JSON array — ignoring parameter override"
282                        );
283                    }
284                }
285
286                debug!(
287                    "Executing prepared SQL ({} bindings)",
288                    prepared.bindings.len()
289                );
290
291                if is_select_query(&prepared.sql) {
292                    execute_select(pool.as_ref(), &prepared, &config, &mut exchange).await?;
293                } else {
294                    execute_modify(pool.as_ref(), &prepared, &config, &mut exchange).await?;
295                }
296            } else {
297                // use_placeholder=false: execute query as-is without template parsing
298                debug!("Executing raw SQL (placeholder processing disabled)");
299                let prepared = PreparedQuery {
300                    sql: query_str,
301                    bindings: vec![],
302                };
303
304                if is_select_query(&prepared.sql) {
305                    execute_select(pool.as_ref(), &prepared, &config, &mut exchange).await?;
306                } else {
307                    execute_modify(pool.as_ref(), &prepared, &config, &mut exchange).await?;
308                }
309            }
310
311            Ok(exchange)
312        })
313    }
314}
315
316/// Executes a SELECT query and populates the exchange body with results.
317async fn execute_select(
318    pool: &AnyPool,
319    prepared: &PreparedQuery,
320    config: &SqlEndpointConfig,
321    exchange: &mut Exchange,
322) -> Result<(), CamelError> {
323    match config.output_type {
324        SqlOutputType::SelectOne => {
325            // fetch_all and take first row
326            let mut query = sqlx::query(&prepared.sql);
327            query = bind_json_values(query, &prepared.bindings);
328
329            let rows: Vec<AnyRow> = query.fetch_all(pool).await.map_err(|e| {
330                warn!(error = %e, "SQL query failed");
331                CamelError::ProcessorError(format!("Query execution failed: {}", e))
332            })?;
333
334            let count = rows.len();
335            debug!(rows = count, "SQL query completed");
336            let json_rows: Vec<serde_json::Value> = rows
337                .iter()
338                .map(row_to_json)
339                .collect::<Result<Vec<_>, _>>()?;
340
341            if let Some(first_row) = json_rows.into_iter().next() {
342                exchange.input.body = Body::Json(first_row);
343            } else {
344                exchange.input.body = Body::Empty;
345            }
346            debug!("SelectOne returned {} row", if count > 0 { 1 } else { 0 });
347            exchange
348                .input
349                .set_header(headers::ROW_COUNT, serde_json::json!(count));
350        }
351        SqlOutputType::SelectList => {
352            // fetch_all for list output
353            let mut query = sqlx::query(&prepared.sql);
354            query = bind_json_values(query, &prepared.bindings);
355
356            let rows: Vec<AnyRow> = query.fetch_all(pool).await.map_err(|e| {
357                warn!(error = %e, "SQL query failed");
358                CamelError::ProcessorError(format!("Query execution failed: {}", e))
359            })?;
360
361            let count = rows.len();
362            debug!(rows = count, "SQL query completed");
363            let json_rows: Vec<serde_json::Value> = rows
364                .iter()
365                .map(row_to_json)
366                .collect::<Result<Vec<_>, _>>()?;
367
368            exchange.input.body = Body::Json(serde_json::Value::Array(json_rows));
369            debug!("SelectList returned {} rows", count);
370            exchange
371                .input
372                .set_header(headers::ROW_COUNT, serde_json::json!(count));
373        }
374        SqlOutputType::StreamList => {
375            // Use fetch() for true streaming - avoids loading all rows into memory
376            use futures::TryStreamExt;
377
378            let pool_clone = pool.clone();
379            let sql_str = prepared.sql.clone();
380            let bindings = prepared.bindings.clone();
381
382            // Build the stream that reads rows on demand and serializes to NDJSON bytes
383            let byte_stream = async_stream::try_stream! {
384                let mut q = sqlx::query(&sql_str);
385                q = bind_json_values(q, &bindings);
386                let mut rows = q.fetch(&pool_clone);
387                while let Some(row) = rows.try_next().await.map_err(|e| {
388                    CamelError::ProcessorError(format!("Query execution failed: {}", e))
389                })? {
390                    let json_val = row_to_json(&row).map_err(|e| {
391                        CamelError::ProcessorError(format!("JSON serialization failed: {}", e))
392                    })?;
393                    let mut bytes = serde_json::to_vec(&json_val)
394                        .map_err(|e| CamelError::ProcessorError(format!("JSON serialization failed: {}", e)))?;
395                    bytes.push(b'\n');
396                    yield Bytes::from(bytes);
397                }
398            };
399
400            exchange.input.body = Body::Stream(StreamBody {
401                stream: Arc::new(tokio::sync::Mutex::new(Some(Box::pin(byte_stream)))),
402                metadata: StreamMetadata {
403                    content_type: Some("application/x-ndjson".to_string()),
404                    size_hint: None,
405                    origin: None,
406                },
407            });
408            debug!("StreamList: created lazy stream (rows fetched on demand)");
409            // Note: ROW_COUNT not set for StreamList since row count is unknown until exhausted
410        }
411    }
412
413    Ok(())
414}
415
416/// Executes a modification query (INSERT/UPDATE/DELETE).
417async fn execute_modify(
418    pool: &AnyPool,
419    prepared: &PreparedQuery,
420    config: &SqlEndpointConfig,
421    exchange: &mut Exchange,
422) -> Result<(), CamelError> {
423    let mut query = sqlx::query(&prepared.sql);
424    query = bind_json_values(query, &prepared.bindings);
425
426    let result = query.execute(pool).await.map_err(|e| {
427        warn!(error = %e, "SQL query failed");
428        CamelError::ProcessorError(format!("Query execution failed: {}", e))
429    })?;
430
431    let rows_affected = result.rows_affected();
432
433    // Fix 4: Implement expected_update_count validation
434    if let Some(expected) = config.expected_update_count
435        && rows_affected as i64 != expected
436    {
437        warn!(expected, actual = rows_affected, "Row count mismatch");
438        return Err(CamelError::ProcessorError(format!(
439            "Expected {} rows affected, got {}",
440            expected, rows_affected
441        )));
442    }
443
444    exchange
445        .input
446        .set_header(headers::UPDATE_COUNT, serde_json::json!(rows_affected));
447
448    if config.noop {
449        // Preserve original body
450    } else {
451        exchange.input.body = Body::Json(json!({ "rowsAffected": rows_affected }));
452    }
453
454    debug!(rows = rows_affected, "SQL modify query completed");
455
456    Ok(())
457}
458
459/// Executes a batch of queries from a JSON array body.
460async fn execute_batch(
461    pool: &AnyPool,
462    config: &SqlEndpointConfig,
463    exchange: &mut Exchange,
464) -> Result<(), CamelError> {
465    // Body must be JSON array of arrays
466    let body_json = match &exchange.input.body {
467        Body::Json(val) => val,
468        _ => {
469            return Err(CamelError::ProcessorError(
470                "Batch mode requires body to be a JSON array of arrays".to_string(),
471            ));
472        }
473    };
474
475    let batch_data = body_json
476        .as_array()
477        .ok_or_else(|| {
478            CamelError::ProcessorError("Batch mode requires body to be a JSON array".to_string())
479        })?
480        .clone();
481
482    // Parse template from config query
483    let template = parse_query_template(&config.query, config.placeholder)?;
484
485    // Fix 2: Batch operations must be wrapped in a transaction
486    let mut tx = pool.begin().await.map_err(|e| {
487        // log-policy: handler-owned
488        // (category a: tx begin failure inside producer pipeline)
489        warn!(error = %e, "Failed to begin transaction");
490        CamelError::ProcessorError(format!("Failed to begin transaction: {}", e))
491    })?;
492
493    let mut total_rows_affected: u64 = 0;
494
495    for (batch_idx, params_array) in batch_data.into_iter().enumerate() {
496        // Each item must be an array of parameters
497        params_array.as_array().ok_or_else(|| {
498            CamelError::ProcessorError(format!(
499                "Batch item at index {} must be a JSON array of parameters",
500                batch_idx
501            ))
502        })?;
503
504        // Create a temporary exchange with the params as body for resolution
505        let temp_msg = Message::new(Body::Json(params_array.clone()));
506        let temp_exchange = Exchange::new(temp_msg);
507
508        // Resolve parameters for this batch item
509        let prepared = resolve_params(&template, &temp_exchange, &config.in_separator)?;
510
511        // Execute against transaction
512        let mut query = sqlx::query(&prepared.sql);
513        query = bind_json_values(query, &prepared.bindings);
514
515        let result = query.execute(&mut *tx).await.map_err(|e| {
516            // log-policy: handler-owned
517            // (category a: batch query execution failure inside producer pipeline)
518            warn!("Batch query execution failed at index {}: {}", batch_idx, e);
519            CamelError::ProcessorError(format!("Batch query execution failed: {}", e))
520        })?;
521
522        // Validate expected_update_count per batch item
523        if let Some(expected) = config.expected_update_count
524            && result.rows_affected() as i64 != expected
525        {
526            // log-policy: handler-owned
527            // (category a: expected_update_count mismatch inside producer pipeline)
528            warn!(
529                "Batch item {}: expected {} rows affected, got {}",
530                batch_idx,
531                expected,
532                result.rows_affected()
533            );
534            return Err(CamelError::ProcessorError(format!(
535                "Batch item {}: expected {} rows affected, got {}",
536                batch_idx,
537                expected,
538                result.rows_affected()
539            )));
540        }
541
542        total_rows_affected += result.rows_affected();
543    }
544
545    // Commit the transaction
546    tx.commit().await.map_err(|e| {
547        // log-policy: handler-owned
548        // (category a: tx commit failure inside producer pipeline)
549        warn!(error = %e, "Failed to commit transaction");
550        CamelError::ProcessorError(format!("Failed to commit transaction: {}", e))
551    })?;
552
553    exchange.input.set_header(
554        headers::UPDATE_COUNT,
555        serde_json::json!(total_rows_affected),
556    );
557
558    debug!(
559        "Batch execution completed, total rows affected: {}",
560        total_rows_affected
561    );
562
563    Ok(())
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569    use camel_api::MetricsCollector;
570    use camel_component_api::HealthCheckRegistry;
571    use camel_component_api::test_support::PanicRuntimeObservability;
572    fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
573        std::sync::Arc::new(PanicRuntimeObservability)
574    }
575    use camel_component_api::Message;
576    use camel_component_api::UriConfig;
577    use sqlx::any::AnyPoolOptions;
578    use std::sync::Arc;
579    use std::sync::Mutex;
580    use tokio::sync::OnceCell;
581
582    // ── Recording health fixture for ADR-0012 (g) tests ───────────────────
583
584    #[derive(Debug, Default)]
585    struct RecordingHealth {
586        forced: Mutex<Vec<(String, String, String)>>,
587    }
588
589    impl HealthCheckRegistry for RecordingHealth {
590        fn force_unhealthy_for_route(&self, route_id: &str, name: &str, reason: &str) {
591            self.forced.lock().unwrap().push((
592                route_id.to_string(),
593                name.to_string(),
594                reason.to_string(),
595            ));
596        }
597    }
598
599    struct NoopMetrics;
600
601    impl MetricsCollector for NoopMetrics {
602        fn record_exchange_duration(&self, _: &str, _: Duration) {}
603        fn increment_errors(&self, _: &str, _: &str) {}
604        fn increment_exchanges(&self, _: &str) {}
605        fn set_queue_depth(&self, _: &str, _: usize) {}
606        fn record_circuit_breaker_change(&self, _: &str, _: &str, _: &str) {}
607    }
608
609    struct RecordingRuntime {
610        health: Arc<RecordingHealth>,
611    }
612
613    impl RuntimeObservability for RecordingRuntime {
614        fn metrics(&self) -> Arc<dyn MetricsCollector> {
615            Arc::new(NoopMetrics)
616        }
617        fn health(&self) -> Arc<dyn HealthCheckRegistry> {
618            self.health.clone()
619        }
620    }
621
622    async fn sqlite_pool() -> AnyPool {
623        sqlx::any::install_default_drivers();
624        // Per-attempt deadline (lintwiden D4.3): a wedged pool connect
625        // must fail the helper instead of parking it.
626        tokio::time::timeout(
627            std::time::Duration::from_secs(10),
628            AnyPoolOptions::new()
629                .max_connections(1)
630                .connect("sqlite::memory:"),
631        )
632        .await
633        .expect("sqlite pool connect timed out after 10s")
634        .expect("sqlite pool")
635    }
636
637    async fn seed_items_table(pool: &AnyPool) {
638        sqlx::query(
639            "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT, done INTEGER DEFAULT 0)",
640        )
641        .execute(pool)
642        .await
643        .expect("create table");
644        sqlx::query("INSERT INTO items (id, name, done) VALUES (1, 'a', 0), (2, 'b', 0)")
645            .execute(pool)
646            .await
647            .expect("seed rows");
648    }
649
650    fn config() -> SqlEndpointConfig {
651        let mut c =
652            SqlEndpointConfig::from_uri("sql:select 1?db_url=postgres://localhost/test").unwrap();
653        c.resolve_defaults();
654        c
655    }
656
657    #[test]
658    fn producer_clone_shares_pool() {
659        let p1 = SqlProducer::new(
660            config(),
661            Arc::new(OnceCell::new()),
662            None,
663            test_rt(),
664            "sql-producer-test-route",
665        );
666        let p2 = p1.clone();
667        assert!(Arc::ptr_eq(&p1.pool, &p2.pool));
668        assert!(Arc::ptr_eq(&p1.stopped, &p2.stopped));
669    }
670
671    #[test]
672    fn resolve_query_from_config() {
673        let config = config();
674        let ex = Exchange::new(Message::default());
675        let q = SqlProducer::resolve_query_source(&ex, &config);
676        assert_eq!(q, "select 1");
677    }
678
679    #[test]
680    fn resolve_query_from_header() {
681        let mut config = config();
682        config.allow_dynamic_query = true;
683        let mut msg = Message::default();
684        msg.set_header(headers::QUERY, serde_json::json!("select 2"));
685        let ex = Exchange::new(msg);
686        let q = SqlProducer::resolve_query_source(&ex, &config);
687        assert_eq!(q, "select 2");
688    }
689
690    #[test]
691    fn resolve_query_from_body() {
692        let mut config = config();
693        config.use_message_body_for_sql = true;
694        config.allow_dynamic_query = true;
695        let msg = Message::new(Body::Text("select 3".to_string()));
696        let ex = Exchange::new(msg);
697        let q = SqlProducer::resolve_query_source(&ex, &config);
698        assert_eq!(q, "select 3");
699    }
700
701    #[test]
702    fn resolve_query_header_priority_over_body() {
703        let mut config = config();
704        config.use_message_body_for_sql = true;
705        config.allow_dynamic_query = true;
706        let mut msg = Message::new(Body::Text("select from body".to_string()));
707        msg.set_header(headers::QUERY, serde_json::json!("select from header"));
708        let ex = Exchange::new(msg);
709        let q = SqlProducer::resolve_query_source(&ex, &config);
710        assert_eq!(q, "select from header");
711    }
712
713    #[test]
714    fn resolve_query_body_priority_over_config() {
715        let mut config = config();
716        config.use_message_body_for_sql = true;
717        config.allow_dynamic_query = true;
718        let msg = Message::new(Body::Text("select from body".to_string()));
719        let ex = Exchange::new(msg);
720        let q = SqlProducer::resolve_query_source(&ex, &config);
721        assert_eq!(q, "select from body");
722    }
723
724    // H7: dynamic queries from exchange data must be denied by default.
725    // The CamelSql.Query header and body are untrusted (ADR-0032). Reading
726    // them as a SQL source is a SQLi vector including DDL. Default is closed.
727    #[test]
728    fn dynamic_query_denied_by_default() {
729        let mut msg = Message::default();
730        msg.set_header(headers::QUERY, "DROP TABLE users; --");
731        let ex = Exchange::new(msg);
732
733        let mut config = config();
734        config.query = "SELECT 1".to_string();
735        // allow_dynamic_query defaults to false → header ignored
736
737        let q = SqlProducer::resolve_query_source(&ex, &config);
738        // Must return the config query, NOT the header
739        assert_eq!(q, "SELECT 1");
740    }
741
742    // H7: with explicit opt-in (allow_dynamic_query=true), header takes priority.
743    #[test]
744    fn dynamic_query_allowed_with_opt_in() {
745        let mut msg = Message::default();
746        msg.set_header(headers::QUERY, "SELECT 1 FROM users WHERE id = :#id");
747        let ex = Exchange::new(msg);
748
749        let mut config = config();
750        config.query = "SELECT 1".to_string();
751        config.allow_dynamic_query = true;
752
753        let q = SqlProducer::resolve_query_source(&ex, &config);
754        // With opt-in, header takes priority
755        assert_eq!(q, "SELECT 1 FROM users WHERE id = :#id");
756    }
757
758    #[test]
759    fn bind_json_null() {
760        let query = sqlx::query("SELECT ?");
761        let values = vec![serde_json::Value::Null];
762        let _bound = bind_json_values(query, &values);
763        // Compilation test - ensure it binds
764    }
765
766    #[test]
767    fn bind_json_bool() {
768        let query = sqlx::query("SELECT ?");
769        let values = vec![serde_json::Value::Bool(true)];
770        let _bound = bind_json_values(query, &values);
771    }
772
773    #[test]
774    fn bind_json_number_i64() {
775        let query = sqlx::query("SELECT ?");
776        let values = vec![serde_json::json!(42)];
777        let _bound = bind_json_values(query, &values);
778    }
779
780    #[test]
781    fn bind_json_number_f64() {
782        let query = sqlx::query("SELECT ?");
783        let values = vec![serde_json::json!(std::f64::consts::PI)];
784        let _bound = bind_json_values(query, &values);
785    }
786
787    #[test]
788    fn bind_json_string() {
789        let query = sqlx::query("SELECT ?");
790        let values = vec![serde_json::json!("hello world")];
791        let _bound = bind_json_values(query, &values);
792    }
793
794    #[test]
795    fn bind_json_array() {
796        let query = sqlx::query("SELECT ?");
797        let values = vec![serde_json::json!([1, 2, 3])];
798        let _bound = bind_json_values(query, &values);
799    }
800
801    #[test]
802    fn bind_json_object() {
803        let query = sqlx::query("SELECT ?");
804        let values = vec![serde_json::json!({"key": "value"})];
805        let _bound = bind_json_values(query, &values);
806    }
807
808    #[test]
809    fn bind_multiple_values() {
810        let query = sqlx::query("SELECT ?, ?, ?");
811        let values = vec![
812            serde_json::json!(1),
813            serde_json::json!("test"),
814            serde_json::Value::Null,
815        ];
816        let _bound = bind_json_values(query, &values);
817    }
818
819    // Test for Fix 4: expected_update_count config field presence
820    #[test]
821    fn expected_update_count_validation() {
822        // Test that expected_update_count is parsed from URI
823        let config = SqlEndpointConfig::from_uri(
824            "sql:update t set x=1?db_url=postgres://localhost/test&expectedUpdateCount=5",
825        )
826        .unwrap();
827        assert_eq!(config.expected_update_count, Some(5));
828
829        // Test default (no expected_update_count)
830        let config_default = self::config();
831        assert_eq!(config_default.expected_update_count, None);
832
833        // Test negative value (should parse)
834        let config_neg = SqlEndpointConfig::from_uri(
835            "sql:update t set x=1?db_url=postgres://localhost/test&expectedUpdateCount=-1",
836        )
837        .unwrap();
838        assert_eq!(config_neg.expected_update_count, Some(-1));
839    }
840
841    // Test for Fix 3: parameters header override logic
842    #[test]
843    fn parameters_header_override_logic() {
844        // Create a PreparedQuery manually
845        let mut prepared = PreparedQuery {
846            sql: "SELECT * FROM t WHERE id = $1".to_string(),
847            bindings: vec![serde_json::json!(42)],
848        };
849
850        // Simulate the header override logic
851        let header_params = serde_json::json!([99, "extra"]);
852        if let Some(arr) = header_params.as_array() {
853            prepared.bindings = arr.clone();
854        }
855
856        // Verify bindings were overridden
857        assert_eq!(prepared.bindings.len(), 2);
858        assert_eq!(prepared.bindings[0], serde_json::json!(99));
859        assert_eq!(prepared.bindings[1], serde_json::json!("extra"));
860
861        // Test with non-array header (should not override)
862        let mut prepared2 = PreparedQuery {
863            sql: "SELECT * FROM t WHERE id = $1".to_string(),
864            bindings: vec![serde_json::json!(42)],
865        };
866        let header_non_array = serde_json::json!({"not": "an array"});
867        if let Some(arr) = header_non_array.as_array() {
868            prepared2.bindings = arr.clone();
869        }
870        // Should remain unchanged
871        assert_eq!(prepared2.bindings.len(), 1);
872        assert_eq!(prepared2.bindings[0], serde_json::json!(42));
873    }
874
875    #[tokio::test]
876    async fn execute_select_one_sets_body_and_row_count() {
877        let pool = sqlite_pool().await;
878        seed_items_table(&pool).await;
879
880        let mut config = SqlEndpointConfig::from_uri(
881            "sql:select id, name from items order by id?db_url=sqlite::memory:&outputType=SelectOne",
882        )
883        .unwrap();
884        config.resolve_defaults();
885
886        let prepared = PreparedQuery {
887            sql: "select id, name from items order by id".to_string(),
888            bindings: vec![],
889        };
890        let mut exchange = Exchange::new(Message::default());
891
892        execute_select(&pool, &prepared, &config, &mut exchange)
893            .await
894            .expect("select one");
895
896        assert_eq!(exchange.input.header(headers::ROW_COUNT), Some(&json!(2)));
897        assert_eq!(
898            exchange.input.body,
899            Body::Json(json!({"id": 1, "name": "a"}))
900        );
901    }
902
903    #[tokio::test]
904    async fn execute_stream_list_materializes_ndjson() {
905        let pool = sqlite_pool().await;
906        seed_items_table(&pool).await;
907
908        let mut config = SqlEndpointConfig::from_uri(
909            "sql:select id from items order by id?db_url=sqlite::memory:&outputType=StreamList",
910        )
911        .unwrap();
912        config.resolve_defaults();
913
914        let prepared = PreparedQuery {
915            sql: "select id from items order by id".to_string(),
916            bindings: vec![],
917        };
918        let mut exchange = Exchange::new(Message::default());
919
920        execute_select(&pool, &prepared, &config, &mut exchange)
921            .await
922            .expect("stream list");
923
924        let bytes = exchange
925            .input
926            .body
927            .clone()
928            .into_bytes(1024)
929            .await
930            .expect("stream bytes");
931        let text = String::from_utf8(bytes.to_vec()).expect("utf8");
932        assert!(text.contains("{\"id\":1}"));
933        assert!(text.contains("{\"id\":2}"));
934        assert_eq!(exchange.input.header(headers::ROW_COUNT), None);
935    }
936
937    #[tokio::test]
938    async fn execute_modify_expected_update_count_mismatch_returns_error() {
939        let pool = sqlite_pool().await;
940        seed_items_table(&pool).await;
941
942        let mut config = SqlEndpointConfig::from_uri(
943            "sql:update items set done=1 where id = #?db_url=sqlite::memory:&expectedUpdateCount=2",
944        )
945        .unwrap();
946        config.resolve_defaults();
947
948        let prepared = PreparedQuery {
949            sql: "update items set done=1 where id = $1".to_string(),
950            bindings: vec![json!(1)],
951        };
952        let mut exchange = Exchange::new(Message::default());
953
954        let err = execute_modify(&pool, &prepared, &config, &mut exchange)
955            .await
956            .expect_err("must fail due expected row count mismatch");
957        assert!(err.to_string().contains("Expected 2 rows affected, got 1"));
958    }
959
960    #[tokio::test]
961    async fn execute_batch_rollback_when_any_item_fails_expected_count() {
962        let pool = sqlite_pool().await;
963        seed_items_table(&pool).await;
964
965        let mut config = SqlEndpointConfig::from_uri(
966            "sql:update items set done=1 where id = #?db_url=sqlite::memory:&batch=true&expectedUpdateCount=1",
967        )
968        .unwrap();
969        config.resolve_defaults();
970
971        let mut exchange = Exchange::new(Message::new(Body::Json(json!([[1], [999]]))));
972
973        let err = execute_batch(&pool, &config, &mut exchange)
974            .await
975            .expect_err("second batch item should fail expectedUpdateCount");
976        assert!(
977            err.to_string()
978                .contains("Batch item 1: expected 1 rows affected, got 0")
979        );
980
981        let row = sqlx::query("select done from items where id = 1")
982            .fetch_one(&pool)
983            .await
984            .expect("query row");
985        let done: i64 = sqlx::Row::try_get(&row, 0).expect("done column");
986        assert_eq!(done, 0, "transaction must rollback first update");
987    }
988
989    // --- Phase B hardening tests ---
990
991    // SQL-001: Direct producer construction without resolve_defaults does not panic.
992    // The producer defensively calls resolve_defaults() during pool init, so the pool
993    // fields get resolved. This test verifies no panic occurs and the pool initializes.
994    #[tokio::test]
995    async fn producer_no_panic_without_prior_resolve_defaults() {
996        // Create config without calling resolve_defaults() — pool fields are None
997        let config = SqlEndpointConfig::from_uri("sql:select 1?db_url=sqlite::memory:").unwrap();
998        assert!(config.max_connections.is_none());
999
1000        let mut producer = SqlProducer::new(
1001            config,
1002            Arc::new(OnceCell::new()),
1003            None,
1004            // Noop runtime: pool-init retries now record per-attempt
1005            // telemetry by design, which the panic runtime would reject.
1006            std::sync::Arc::new(camel_component_api::test_support::NoopRuntimeObservability),
1007            "sql-producer-test-route",
1008        );
1009        let exchange = Exchange::new(Message::default());
1010
1011        // Should NOT panic — producer calls resolve_defaults() defensively
1012        let result = producer.call(exchange).await;
1013        assert!(
1014            result.is_ok(),
1015            "Producer should initialize pool without panic, got: {:?}",
1016            result
1017        );
1018    }
1019
1020    // SQL-001: Pool init returns CamelError::Config when pool params cannot be resolved
1021    #[tokio::test]
1022    async fn producer_pool_init_returns_config_error_for_invalid_db() {
1023        // Create config with an invalid db_url that will fail to connect.
1024        // Disable retry for this test — the purpose is to verify error handling
1025        // on a known-bad connect, not to exercise the retry loop.
1026        let mut config = SqlEndpointConfig::from_uri(
1027            "sql:select 1?db_url=postgres://nonexistent-host:5432/nonexistent_db&retryEnabled=false",
1028        )
1029        .unwrap();
1030        // Set pool params explicitly so resolve_defaults doesn't help with connection
1031        config.max_connections = Some(1);
1032        config.min_connections = Some(0);
1033        config.idle_timeout_secs = Some(300);
1034        config.max_lifetime_secs = Some(1800);
1035
1036        let health = Arc::new(RecordingHealth::default());
1037        let rt: Arc<dyn RuntimeObservability> = Arc::new(RecordingRuntime {
1038            health: health.clone(),
1039        });
1040        let mut producer = SqlProducer::new(
1041            config,
1042            Arc::new(OnceCell::new()),
1043            None,
1044            rt,
1045            "sql-producer-test-route",
1046        );
1047        let exchange = Exchange::new(Message::default());
1048
1049        let result = producer.call(exchange).await;
1050        assert!(result.is_err());
1051        // Error should be EndpointCreationFailed (connection error), not a panic
1052        let err_msg = result.unwrap_err().to_string();
1053        assert!(
1054            err_msg.contains("Failed to connect") || err_msg.contains("database"),
1055            "Expected connection error, got: {}",
1056            err_msg
1057        );
1058        // Verify force_unhealthy_for_route was also called
1059        let forced = health.forced.lock().unwrap();
1060        assert_eq!(
1061            forced.len(),
1062            1,
1063            "expected one force_unhealthy_for_route call"
1064        );
1065        assert_eq!(forced[0].0, "sql-producer-test-route");
1066        assert_eq!(forced[0].1, "g:sql:producer-pool-init");
1067    }
1068
1069    // SQL-007: poll_ready returns Ready (pool lazily initialized on first call)
1070    #[test]
1071    fn poll_ready_returns_ready_for_uninitialized_pool() {
1072        let config = {
1073            let mut c = SqlEndpointConfig::from_uri("sql:select 1?db_url=sqlite::memory:").unwrap();
1074            c.resolve_defaults();
1075            c
1076        };
1077        let mut producer = SqlProducer::new(
1078            config,
1079            Arc::new(OnceCell::new()),
1080            None,
1081            test_rt(),
1082            "sql-producer-test-route",
1083        );
1084        let mut cx = Context::from_waker(futures::task::noop_waker_ref());
1085        let result = producer.poll_ready(&mut cx);
1086        assert!(matches!(result, Poll::Ready(Ok(()))));
1087    }
1088
1089    // SQL-007: poll_ready returns error when stopped flag is set
1090    #[test]
1091    fn poll_ready_returns_error_when_stopped() {
1092        let config = {
1093            let mut c = SqlEndpointConfig::from_uri("sql:select 1?db_url=sqlite::memory:").unwrap();
1094            c.resolve_defaults();
1095            c
1096        };
1097        let mut producer = SqlProducer::new(
1098            config,
1099            Arc::new(OnceCell::new()),
1100            None,
1101            test_rt(),
1102            "sql-producer-test-route",
1103        );
1104        producer.stop();
1105        let mut cx = Context::from_waker(futures::task::noop_waker_ref());
1106        let result = producer.poll_ready(&mut cx);
1107        assert!(matches!(result, Poll::Ready(Err(_))));
1108        let err_msg = match result {
1109            Poll::Ready(Err(e)) => e.to_string(),
1110            _ => unreachable!(),
1111        };
1112        assert!(err_msg.contains("SQL producer stopped"));
1113    }
1114
1115    // SQL-007: poll_ready returns error when pool is closed
1116    #[tokio::test]
1117    async fn poll_ready_returns_error_when_pool_closed() {
1118        let pool = sqlite_pool().await;
1119        pool.close().await;
1120
1121        let config = {
1122            let mut c = SqlEndpointConfig::from_uri("sql:select 1?db_url=sqlite::memory:").unwrap();
1123            c.resolve_defaults();
1124            c
1125        };
1126        let pool_cell = Arc::new(OnceCell::new());
1127        pool_cell.set(Arc::new(pool)).unwrap();
1128
1129        let mut producer = SqlProducer::new(
1130            config,
1131            pool_cell,
1132            None,
1133            test_rt(),
1134            "sql-producer-test-route",
1135        );
1136        let mut cx = Context::from_waker(futures::task::noop_waker_ref());
1137        let result = producer.poll_ready(&mut cx);
1138        assert!(matches!(result, Poll::Ready(Err(_))));
1139        let err_msg = match result {
1140            Poll::Ready(Err(e)) => e.to_string(),
1141            _ => unreachable!(),
1142        };
1143        assert!(err_msg.contains("SQL connection pool is closed"));
1144    }
1145
1146    // SQL-007: poll_ready returns Ok for healthy initialized pool
1147    #[tokio::test]
1148    async fn poll_ready_returns_ok_for_healthy_pool() {
1149        let pool = sqlite_pool().await;
1150
1151        let config = {
1152            let mut c = SqlEndpointConfig::from_uri("sql:select 1?db_url=sqlite::memory:").unwrap();
1153            c.resolve_defaults();
1154            c
1155        };
1156        let pool_cell = Arc::new(OnceCell::new());
1157        pool_cell.set(Arc::new(pool)).unwrap();
1158
1159        let mut producer = SqlProducer::new(
1160            config,
1161            pool_cell,
1162            None,
1163            test_rt(),
1164            "sql-producer-test-route",
1165        );
1166        let mut cx = Context::from_waker(futures::task::noop_waker_ref());
1167        let result = producer.poll_ready(&mut cx);
1168        assert!(matches!(result, Poll::Ready(Ok(()))));
1169    }
1170
1171    // SQL-008: stop() closes the pool
1172    #[tokio::test]
1173    async fn test_sql_stop_closes_pool() {
1174        let pool = sqlite_pool().await;
1175
1176        let config = {
1177            let mut c = SqlEndpointConfig::from_uri("sql:select 1?db_url=sqlite::memory:").unwrap();
1178            c.resolve_defaults();
1179            c
1180        };
1181        let pool_cell = Arc::new(OnceCell::new());
1182        pool_cell.set(Arc::new(pool.clone())).unwrap();
1183
1184        let producer = SqlProducer::new(
1185            config,
1186            pool_cell.clone(),
1187            None,
1188            test_rt(),
1189            "sql-producer-test-route",
1190        );
1191        assert!(!pool.is_closed(), "Pool should be open before stop");
1192
1193        producer.stop();
1194
1195        // Allow background close task to complete
1196        tokio::time::sleep(Duration::from_millis(100)).await;
1197
1198        assert!(
1199            pool.is_closed(),
1200            "Pool should be closed after producer.stop()"
1201        );
1202
1203        // Subsequent poll_ready should fail
1204        let mut producer2 = SqlProducer::new(
1205            {
1206                let mut c =
1207                    SqlEndpointConfig::from_uri("sql:select 1?db_url=sqlite::memory:").unwrap();
1208                c.resolve_defaults();
1209                c
1210            },
1211            pool_cell.clone(),
1212            None,
1213            test_rt(),
1214            "sql-producer-test-route",
1215        );
1216        let mut cx = Context::from_waker(futures::task::noop_waker_ref());
1217        let result = producer2.poll_ready(&mut cx);
1218        assert!(
1219            matches!(result, Poll::Ready(Err(_))),
1220            "poll_ready should fail after pool closed"
1221        );
1222    }
1223
1224    // SQL-004: use_placeholder=false skips template parsing
1225    #[tokio::test]
1226    async fn use_placeholder_false_executes_raw_sql() {
1227        let pool = sqlite_pool().await;
1228        seed_items_table(&pool).await;
1229
1230        let mut config = SqlEndpointConfig::from_uri(
1231            "sql:select id, name from items order by id?db_url=sqlite::memory:&usePlaceholder=false",
1232        )
1233        .unwrap();
1234        config.resolve_defaults();
1235        assert!(!config.use_placeholder);
1236
1237        let mut producer = SqlProducer::new(
1238            config,
1239            Arc::new(OnceCell::new()),
1240            None,
1241            test_rt(),
1242            "sql-producer-test-route",
1243        );
1244        // Pre-initialize the pool so we don't hit the pool init path
1245        producer.pool.set(Arc::new(pool.clone())).unwrap();
1246
1247        let exchange = Exchange::new(Message::default());
1248        let result = producer.call(exchange).await;
1249        assert!(result.is_ok());
1250        let exchange = result.unwrap();
1251        // Should return results, not rowsAffected
1252        assert!(matches!(exchange.input.body, Body::Json(_)));
1253    }
1254
1255    // SQL-004: use_placeholder=true (default) processes placeholders normally
1256    #[tokio::test]
1257    async fn use_placeholder_true_processes_placeholders() {
1258        let pool = sqlite_pool().await;
1259        seed_items_table(&pool).await;
1260
1261        let mut config = SqlEndpointConfig::from_uri(
1262            "sql:select id, name from items where id = #?db_url=sqlite::memory:",
1263        )
1264        .unwrap();
1265        config.resolve_defaults();
1266        assert!(config.use_placeholder);
1267
1268        let mut producer = SqlProducer::new(
1269            config,
1270            Arc::new(OnceCell::new()),
1271            None,
1272            test_rt(),
1273            "sql-producer-test-route",
1274        );
1275        producer.pool.set(Arc::new(pool.clone())).unwrap();
1276
1277        let msg = Message::new(Body::Json(json!([1])));
1278        let exchange = Exchange::new(msg);
1279        let result = producer.call(exchange).await;
1280        assert!(result.is_ok());
1281    }
1282
1283    /// Regression: max_attempts=N → exactly N invocations (caught OpenSearch off-by-one 1f5c4c2a).
1284    /// Replicates the exact retry loop from SqlProducer::start() (producer.rs:161-185):
1285    ///   attempt starts at 0, incremented at top, should_retry(attempt), delay_for(attempt-1)
1286    #[tokio::test]
1287    async fn retry_loop_invokes_operation_exactly_max_attempts_times() {
1288        use camel_component_api::NetworkRetryPolicy;
1289        use std::sync::Arc;
1290        use std::sync::atomic::{AtomicU32, Ordering};
1291
1292        let policy = NetworkRetryPolicy {
1293            max_attempts: 3,
1294            initial_delay: Duration::from_millis(1),
1295            max_delay: Duration::from_millis(1),
1296            multiplier: 1.0,
1297            ..NetworkRetryPolicy::default()
1298        };
1299
1300        let calls = Arc::new(AtomicU32::new(0));
1301        let calls_clone = Arc::clone(&calls);
1302
1303        let mut attempt: u32 = 0;
1304        let _result: Result<(), ()> = tokio::time::timeout(Duration::from_secs(30), async {
1305            loop {
1306                attempt += 1;
1307                calls_clone.fetch_add(1, Ordering::SeqCst);
1308                let op_result: Result<(), ()> = Err(());
1309                match op_result {
1310                    Ok(v) => break Ok(v),
1311                    Err(_) if policy.should_retry(attempt) => {
1312                        let delay = policy.delay_for(attempt - 1);
1313                        tokio::time::sleep(delay).await;
1314                        continue;
1315                    }
1316                    Err(_) => break Err(()),
1317                }
1318            }
1319        })
1320        .await
1321        .expect("retry loop must finish within 30s");
1322
1323        assert_eq!(
1324            calls.load(Ordering::SeqCst),
1325            3,
1326            "max_attempts=3 must yield exactly 3 invocations"
1327        );
1328    }
1329
1330    // ── ADR-0012 (g) regression tests ──────────────────────────────────
1331
1332    /// Regression: producer pool init failure calls force_unhealthy_for_route
1333    /// with correct route_id + name "g:sql:producer-pool-init" + non-empty reason.
1334    #[tokio::test]
1335    async fn producer_pool_init_failure_calls_force_unhealthy_for_route() {
1336        let health = Arc::new(RecordingHealth::default());
1337        let rt: Arc<dyn RuntimeObservability> = Arc::new(RecordingRuntime {
1338            health: health.clone(),
1339        });
1340
1341        let mut config = SqlEndpointConfig::from_uri(
1342            "sql:select 1?db_url=postgres://nonexistent-host:5432/nonexistent_db&retryEnabled=false",
1343        )
1344        .unwrap();
1345        config.max_connections = Some(1);
1346        config.min_connections = Some(0);
1347        config.idle_timeout_secs = Some(300);
1348        config.max_lifetime_secs = Some(1800);
1349
1350        let mut producer = SqlProducer::new(
1351            config,
1352            Arc::new(OnceCell::new()),
1353            None,
1354            rt,
1355            "sql-producer-test-route",
1356        );
1357        let exchange = Exchange::new(Message::default());
1358
1359        let result = producer.call(exchange).await;
1360        assert!(result.is_err());
1361
1362        let forced = health.forced.lock().unwrap();
1363        assert_eq!(
1364            forced.len(),
1365            1,
1366            "expected one force_unhealthy_for_route call"
1367        );
1368        assert_eq!(forced[0].0, "sql-producer-test-route");
1369        assert_eq!(forced[0].1, "g:sql:producer-pool-init");
1370        assert!(!forced[0].2.is_empty(), "reason should be non-empty");
1371    }
1372
1373    // H7: from_uri must default allow_dynamic_query to false when the
1374    // URI does not include the allowDynamicQuery parameter.
1375    #[test]
1376    fn sql_allow_dynamic_query_defaults_false_from_uri() {
1377        let config =
1378            SqlEndpointConfig::from_uri("sql:query?datasource=mydb&query=SELECT+1").unwrap();
1379        assert!(!config.allow_dynamic_query);
1380    }
1381}