use crate::compile::{Reloadable, build_connection, sql_escape, validate_query};
use crate::config::SqlTransformConfig;
use crate::shovel::{infer_schema, json_to_record_batch, record_batches_to_json, schema_eq};
use arrow::array::RecordBatch;
use arrow::datatypes::SchemaRef;
use duckdb::Connection;
use duckdb::vtab::arrow::arrow_recordbatch_to_query_params;
use faucet_core::FaucetError;
use faucet_core::stage::TransformStage;
use serde_json::Value;
use std::sync::{Arc, Mutex};
struct State {
conn: Connection,
query: String,
reloadables: Vec<Reloadable>,
cached_schema: Option<SchemaRef>,
pages_seen: u64,
aggregates: Option<bool>,
warned: bool,
}
pub struct SqlTransform {
state: Arc<Mutex<State>>,
}
impl std::fmt::Debug for SqlTransform {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut d = f.debug_struct("SqlTransform");
match self.state.lock() {
Ok(st) => d.field("query", &st.query),
Err(e) => d.field("query", &e.into_inner().query),
};
d.finish_non_exhaustive()
}
}
impl SqlTransform {
pub fn compile(cfg: &SqlTransformConfig) -> Result<Self, FaucetError> {
let (conn, reloadables) = build_connection(cfg)?;
validate_query(&conn, &cfg.query)?;
Ok(Self {
state: Arc::new(Mutex::new(State {
conn,
query: cfg.query.clone(),
reloadables,
cached_schema: None,
pages_seen: 0,
aggregates: None,
warned: false,
})),
})
}
pub fn into_page_stage(self) -> TransformStage {
let state = self.state;
TransformStage::PageFn(Arc::new(move |records: Vec<Value>| {
let mut st = state.lock().unwrap_or_else(|e| e.into_inner());
execute_page(&mut st, records)
}))
}
pub fn into_columnar_stage(self) -> (TransformStage, faucet_core::stage::PageFnBatchBox) {
let rows_state = self.state.clone();
let batch_state = self.state;
let stage = TransformStage::PageFn(Arc::new(move |records: Vec<Value>| {
let mut st = rows_state.lock().unwrap_or_else(|e| e.into_inner());
execute_page(&mut st, records)
}));
let batch: faucet_core::stage::PageFnBatchBox = Arc::new(move |batch: RecordBatch| {
let mut st = batch_state.lock().unwrap_or_else(|e| e.into_inner());
execute_batch(&mut st, batch)
});
(stage, batch)
}
}
fn run_query_batches(st: &mut State, batch: RecordBatch) -> Result<Vec<RecordBatch>, FaucetError> {
reload_relations(st)?;
register_batch_chunked(st, batch)?;
if st.aggregates.is_none() {
st.aggregates = Some(plan_has_aggregate(&st.conn, &st.query));
}
st.pages_seen += 1;
if st.pages_seen >= 2 && st.aggregates == Some(true) && !st.warned {
st.warned = true;
tracing::warn!(
target: "faucet::transform::sql",
"sql transform with aggregation received multiple pages; aggregation is \
per-page — set batch_size: 0 for global aggregation"
);
}
let mut stmt = st
.conn
.prepare(&st.query)
.map_err(|e| FaucetError::Transform(format!("sql transform: prepare: {e}")))?;
let batches: Vec<RecordBatch> = stmt
.query_arrow([])
.map_err(|e| FaucetError::Transform(format!("sql transform: execute: {e}")))?
.collect();
Ok(batches)
}
fn execute_page(st: &mut State, records: Vec<Value>) -> Result<Vec<Value>, FaucetError> {
if records.is_empty() {
return Ok(Vec::new());
}
let fresh = infer_schema(&records)?;
let schema = match &st.cached_schema {
Some(s) if schema_eq(s, &fresh) => s.clone(),
_ => {
st.cached_schema = Some(fresh.clone());
fresh
}
};
let batch = json_to_record_batch(&records, schema)?;
let batches = run_query_batches(st, batch)?;
record_batches_to_json(&batches)
}
fn execute_batch(st: &mut State, batch: RecordBatch) -> Result<RecordBatch, FaucetError> {
if batch.num_rows() == 0 {
return Ok(batch);
}
let batches = run_query_batches(st, batch)?;
if batches.is_empty() {
return Ok(RecordBatch::new_empty(std::sync::Arc::new(
arrow::datatypes::Schema::empty(),
)));
}
let schema = batches[0].schema();
arrow::compute::concat_batches(&schema, &batches)
.map_err(|e| FaucetError::Transform(format!("sql transform: concat result batches: {e}")))
}
const DUCKDB_VECTOR_SIZE: usize = 2048;
fn register_batch_chunked(st: &mut State, batch: RecordBatch) -> Result<(), FaucetError> {
let total = batch.num_rows();
let mut offset = 0;
let mut first = true;
loop {
let len = (total - offset).min(DUCKDB_VECTOR_SIZE);
let slice = batch.slice(offset, len);
let params = arrow_recordbatch_to_query_params(slice);
let sql = if first {
"CREATE OR REPLACE TEMP TABLE batch AS SELECT * FROM arrow(?, ?)"
} else {
"INSERT INTO batch SELECT * FROM arrow(?, ?)"
};
st.conn
.execute(sql, params)
.map_err(|e| FaucetError::Transform(format!("sql transform: register batch: {e}")))?;
first = false;
offset += len;
if offset >= total {
break;
}
}
Ok(())
}
fn reload_relations(st: &mut State) -> Result<(), FaucetError> {
for r in st.reloadables.iter_mut() {
let cur = std::fs::metadata(&r.path).and_then(|m| m.modified()).ok();
if cur != r.last_mtime {
let stmt = if r.is_csv {
format!(
"CREATE OR REPLACE TABLE \"{}\" AS SELECT * FROM read_csv_auto('{}', header={});",
r.name,
sql_escape(&r.path),
r.has_header
)
} else {
format!(
"CREATE OR REPLACE TABLE \"{}\" AS SELECT * FROM read_json_auto('{}', format='newline_delimited');",
r.name,
sql_escape(&r.path)
)
};
st.conn.execute_batch(&stmt).map_err(|e| {
FaucetError::Transform(format!("sql transform: reload '{}': {e}", r.name))
})?;
r.last_mtime = cur;
}
}
Ok(())
}
fn plan_has_aggregate(conn: &Connection, query: &str) -> bool {
let explain = format!("EXPLAIN {query}");
let mut found = false;
if let Ok(mut stmt) = conn.prepare(&explain)
&& let Ok(rows) = stmt.query_map([], |row| row.get::<_, String>(1))
{
for r in rows.flatten() {
let u = r.to_uppercase();
if u.contains("AGGREGATE") || u.contains("WINDOW") {
found = true;
break;
}
}
}
found
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::SqlTransformConfig;
use faucet_core::stage::{apply_stages_to_page, compile_stage};
use serde_json::json;
fn run(query: &str, rows: Vec<Value>) -> Vec<Value> {
let cfg = SqlTransformConfig {
query: query.into(),
relations: vec![],
memory_limit: None,
threads: Some(1),
};
let stage = compile_stage(&SqlTransform::compile(&cfg).unwrap().into_page_stage()).unwrap();
apply_stages_to_page(rows, std::slice::from_ref(&stage)).unwrap()
}
#[test]
fn large_page_passthrough_does_not_abort() {
let rows: Vec<Value> = (0..10_000).map(|i| json!({"id": i, "v": i * 2})).collect();
let out = run("SELECT * FROM batch", rows);
assert_eq!(out.len(), 10_000, "every row of a >2048-row page survives");
assert_eq!(out[5_000]["id"], json!(5_000));
}
#[test]
fn large_page_aggregate_is_global_over_the_whole_page() {
let rows: Vec<Value> = (0..5_000).map(|i| json!({"k": i % 4, "v": 1})).collect();
let out = run(
"SELECT k, COUNT(*) AS n FROM batch GROUP BY k ORDER BY k",
rows,
);
assert_eq!(out.len(), 4, "one group per key");
let total: i64 = out.iter().map(|r| r["n"].as_i64().unwrap()).sum();
assert_eq!(total, 5_000, "every row counted exactly once across chunks");
for r in &out {
assert_eq!(r["n"], json!(1_250), "5000 rows / 4 keys = 1250 each");
}
}
#[test]
fn small_page_still_works() {
let out = run(
"SELECT id FROM batch WHERE id >= 1 ORDER BY id",
vec![json!({"id": 0}), json!({"id": 1}), json!({"id": 2})],
);
assert_eq!(out.len(), 2);
assert_eq!(out[0]["id"], json!(1));
}
fn batch_fn(query: &str) -> faucet_core::stage::PageFnBatchBox {
let cfg = SqlTransformConfig {
query: query.into(),
relations: vec![],
memory_limit: None,
threads: Some(1),
};
let (stage, batch) = SqlTransform::compile(&cfg).unwrap().into_columnar_stage();
assert!(matches!(stage, TransformStage::PageFn(_)));
batch
}
#[test]
fn columnar_batch_fn_transforms_record_batch() {
let bf = batch_fn("SELECT id, v * 2 AS doubled FROM batch WHERE id >= 1 ORDER BY id");
let input = faucet_core::columnar::values_to_record_batch_inferred(&[
json!({"id": 0, "v": 5}),
json!({"id": 1, "v": 10}),
json!({"id": 2, "v": 20}),
])
.unwrap();
let out = bf(input).unwrap();
let rows = faucet_core::columnar::record_batch_to_values(&out).unwrap();
assert_eq!(rows.len(), 2, "WHERE id >= 1 keeps two rows");
assert_eq!(rows[0]["id"], json!(1));
assert_eq!(rows[0]["doubled"], json!(20));
assert_eq!(rows[1]["doubled"], json!(40));
}
#[test]
fn columnar_batch_fn_handles_large_batch() {
let bf = batch_fn("SELECT * FROM batch");
let recs: Vec<Value> = (0..5_000).map(|i| json!({"id": i})).collect();
let input = faucet_core::columnar::values_to_record_batch_inferred(&recs).unwrap();
let out = bf(input).unwrap();
assert_eq!(out.num_rows(), 5_000);
}
}