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