use std::sync::Arc;
use std::time::Duration;
use camel_matchers::RowsExpectation;
use super::ScenarioFailure;
use crate::document::SqlTarget;
#[cfg(feature = "sql")]
const SQL_VALIDATE_POLL_INTERVAL: Duration = Duration::from_millis(100);
#[cfg(feature = "sql")]
pub(crate) async fn sql_validate_action(
index: usize,
target: &SqlTarget,
expected: &RowsExpectation,
deadline: Option<Duration>,
catalog: Option<&Arc<dyn camel_api::datasource::DatasourceCatalog>>,
) -> Result<(), ScenarioFailure> {
let Some(catalog) = catalog else {
return Err(ScenarioFailure::ActionTransport {
action: index,
source: crate::adapters::TransportError::Other {
message: "sql validation: no datasource catalog is available; the \
boot-owning caller must pass the cascade's catalog"
.to_string(),
},
});
};
let name = &target.datasource;
let Some(config) = catalog.get_config(name) else {
return Err(ScenarioFailure::ActionTransport {
action: index,
source: crate::adapters::TransportError::Other {
message: format!("sql validation: unknown datasource '{name}'"),
},
});
};
let db_url = config.db_url.clone();
let sanitize = |err_text: String| crate::sql_action::sanitize_db_error(&err_text, &db_url);
let handle = catalog
.get_pool(name)
.await
.map_err(|e| apparatus(index, name, sanitize(e.to_string())))?;
let pool = handle
.downcast::<sqlx::AnyPool>()
.map_err(|e| apparatus(index, name, sanitize(e.to_string())))?;
match deadline {
None => {
let snapshot = snapshot(index, target, expected, &pool, &db_url).await?;
decide(index, name, expected, &snapshot)
}
Some(deadline) => {
let until = tokio::time::Instant::now() + deadline;
loop {
let snapshot = snapshot(index, target, expected, &pool, &db_url).await?;
if let Some(bound) = expected.bound.as_ref()
&& camel_matchers::above_ceiling(bound, snapshot.tuples.len())
{
return Err(mismatch(index, name, expected, &snapshot));
}
let now = tokio::time::Instant::now();
if now >= until {
return decide(index, name, expected, &snapshot);
}
tokio::time::sleep((until - now).min(SQL_VALIDATE_POLL_INTERVAL)).await;
}
}
}
}
#[cfg(feature = "sql")]
fn apparatus(index: usize, name: &str, text: String) -> ScenarioFailure {
ScenarioFailure::ActionTransport {
action: index,
source: crate::adapters::TransportError::Other {
message: format!("sql validation: datasource '{name}': {text}"),
},
}
}
#[cfg(not(feature = "sql"))]
pub(crate) async fn sql_validate_action(
index: usize,
target: &SqlTarget,
expected: &RowsExpectation,
deadline: Option<Duration>,
catalog: Option<&Arc<dyn camel_api::datasource::DatasourceCatalog>>,
) -> Result<(), ScenarioFailure> {
let _ = (target, expected, deadline, catalog);
Err(ScenarioFailure::ValidationMismatch {
action: index,
detail: "sql validation requires the `sql` feature".to_string(),
})
}
#[cfg(feature = "sql")]
struct Snapshot {
columns: Vec<String>,
tuples: Vec<Vec<camel_api::Value>>,
}
#[cfg(feature = "sql")]
async fn snapshot(
index: usize,
target: &SqlTarget,
expected: &RowsExpectation,
pool: &sqlx::AnyPool,
db_url: &str,
) -> Result<Snapshot, ScenarioFailure> {
let rows = sqlx::query(&target.query)
.fetch_all(pool)
.await
.map_err(|e| {
apparatus(
index,
&target.datasource,
crate::sql_action::sanitize_db_error(&e.to_string(), db_url),
)
})?;
if rows.is_empty() {
return Ok(Snapshot {
columns: Vec::new(),
tuples: Vec::new(),
});
}
let columns: Vec<String> = sqlx::Row::columns(&rows[0])
.iter()
.map(|column| sqlx::Column::name(column).to_string())
.collect();
let projection: Result<Vec<usize>, ScenarioFailure> = match &expected.columns {
Some(declared) => declared
.iter()
.map(|want| {
columns.iter().position(|have| have == want).ok_or_else(|| {
ScenarioFailure::ValidationMismatch {
action: index,
detail: format!("sql validation: unknown projection column `{want}`"),
}
})
})
.collect(),
None => Ok((0..columns.len()).collect()),
};
let projection = projection?;
let mut tuples = Vec::with_capacity(rows.len());
for row in &rows {
let tuple = any_row_to_tuple(row, &columns).map_err(|detail| {
ScenarioFailure::ValidationMismatch {
action: index,
detail,
}
})?;
tuples.push(projection.iter().map(|&i| tuple[i].clone()).collect());
}
Ok(Snapshot { columns, tuples })
}
#[cfg(feature = "sql")]
fn decide(
index: usize,
datasource: &str,
expected: &RowsExpectation,
snapshot: &Snapshot,
) -> Result<(), ScenarioFailure> {
let passed = match (&expected.rows, &expected.bound) {
(Some(rows), _) => camel_matchers::rows_match(rows, &snapshot.tuples, expected.unordered),
(None, Some(bound)) => camel_matchers::bound_holds(bound, snapshot.tuples.len()),
(None, None) => false,
};
if passed {
Ok(())
} else {
Err(mismatch(index, datasource, expected, snapshot))
}
}
#[cfg(feature = "sql")]
fn mismatch(
index: usize,
datasource: &str,
expected: &RowsExpectation,
snapshot: &Snapshot,
) -> ScenarioFailure {
let columns: Vec<String> = match &expected.columns {
Some(declared) => declared.clone(),
None => snapshot.columns.clone(),
};
ScenarioFailure::ValidationMismatch {
action: index,
detail: sql_mismatch_detail(datasource, expected, snapshot.tuples.len(), &columns),
}
}
#[cfg(feature = "sql")]
fn sql_mismatch_detail(
datasource: &str,
expected: &RowsExpectation,
actual_count: usize,
columns: &[String],
) -> String {
let shape = if let Some(bound) = &expected.bound {
camel_matchers::render_bound(bound)
} else {
let rows = expected.rows.as_ref().map_or(0, Vec::len);
format!(
"expected {rows} rows ({})",
if expected.unordered {
"unordered"
} else {
"ordered"
}
)
};
format!(
"sql {datasource}, {shape}, actual {actual_count} rows, columns: [{}]",
columns.join(", ")
)
}
#[cfg(feature = "sql")]
pub(crate) fn any_row_to_tuple(
row: &sqlx::any::AnyRow,
names: &[String],
) -> Result<Vec<camel_api::Value>, String> {
use sqlx::Row;
use sqlx::ValueRef as _;
let mut tuple = Vec::with_capacity(names.len());
for (i, name) in names.iter().enumerate() {
let raw = row
.try_get_raw(i)
.map_err(|e| format!("sql validation: column `{name}`: {e}"))?;
if raw.is_null() {
tuple.push(camel_api::Value::Null);
continue;
}
let type_info = raw.type_info();
if let Ok(v) = row.try_get::<i64, _>(i) {
tuple.push(camel_api::Value::from(v));
} else if let Ok(v) = row.try_get::<f64, _>(i) {
tuple.push(camel_api::Value::from(v));
} else if let Ok(v) = row.try_get::<bool, _>(i) {
tuple.push(camel_api::Value::Bool(v));
} else if let Ok(v) = row.try_get::<String, _>(i) {
tuple.push(camel_api::Value::String(v));
} else if let Ok(v) = row.try_get::<Vec<u8>, _>(i) {
tuple.push(super::reply_bytes_value(&v));
} else {
return Err(format!(
"sql validation: column `{name}` has unsupported type {type_info}; \
no any-tier decode arm matches (fail-closed)"
));
}
}
Ok(tuple)
}