1use crate::compile::{Reloadable, build_connection, sql_escape, validate_query};
4use crate::config::SqlTransformConfig;
5use crate::shovel::{infer_schema, json_to_record_batch, record_batches_to_json, schema_eq};
6use arrow::array::RecordBatch;
7use arrow::datatypes::SchemaRef;
8use duckdb::Connection;
9use duckdb::vtab::arrow::arrow_recordbatch_to_query_params;
10use faucet_core::FaucetError;
11use faucet_core::stage::TransformStage;
12use serde_json::Value;
13use std::sync::{Arc, Mutex};
14
15struct State {
16 conn: Connection,
17 query: String,
18 reloadables: Vec<Reloadable>,
19 cached_schema: Option<SchemaRef>,
20 pages_seen: u64,
21 aggregates: Option<bool>,
22 warned: bool,
23}
24
25pub struct SqlTransform {
27 state: Arc<Mutex<State>>,
28}
29
30impl std::fmt::Debug for SqlTransform {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 let mut d = f.debug_struct("SqlTransform");
33 match self.state.lock() {
34 Ok(st) => d.field("query", &st.query),
35 Err(e) => d.field("query", &e.into_inner().query),
36 };
37 d.finish_non_exhaustive()
38 }
39}
40
41impl SqlTransform {
42 pub fn compile(cfg: &SqlTransformConfig) -> Result<Self, FaucetError> {
44 let (conn, reloadables) = build_connection(cfg)?;
45 validate_query(&conn, &cfg.query)?;
46 Ok(Self {
47 state: Arc::new(Mutex::new(State {
48 conn,
49 query: cfg.query.clone(),
50 reloadables,
51 cached_schema: None,
52 pages_seen: 0,
53 aggregates: None,
54 warned: false,
55 })),
56 })
57 }
58
59 pub fn into_page_stage(self) -> TransformStage {
61 let state = self.state;
62 TransformStage::PageFn(Arc::new(move |records: Vec<Value>| {
63 let mut st = state.lock().unwrap_or_else(|e| e.into_inner());
64 execute_page(&mut st, records)
65 }))
66 }
67
68 pub fn into_columnar_stage(self) -> (TransformStage, faucet_core::stage::PageFnBatchBox) {
74 let rows_state = self.state.clone();
75 let batch_state = self.state;
76 let stage = TransformStage::PageFn(Arc::new(move |records: Vec<Value>| {
77 let mut st = rows_state.lock().unwrap_or_else(|e| e.into_inner());
78 execute_page(&mut st, records)
79 }));
80 let batch: faucet_core::stage::PageFnBatchBox = Arc::new(move |batch: RecordBatch| {
81 let mut st = batch_state.lock().unwrap_or_else(|e| e.into_inner());
82 execute_batch(&mut st, batch)
83 });
84 (stage, batch)
85 }
86}
87
88fn run_query_batches(st: &mut State, batch: RecordBatch) -> Result<Vec<RecordBatch>, FaucetError> {
93 reload_relations(st)?;
94 register_batch_chunked(st, batch)?;
100
101 if st.aggregates.is_none() {
103 st.aggregates = Some(plan_has_aggregate(&st.conn, &st.query));
104 }
105 st.pages_seen += 1;
106 if st.pages_seen >= 2 && st.aggregates == Some(true) && !st.warned {
107 st.warned = true;
108 tracing::warn!(
109 target: "faucet::transform::sql",
110 "sql transform with aggregation received multiple pages; aggregation is \
111 per-page — set batch_size: 0 for global aggregation"
112 );
113 }
114
115 let mut stmt = st
116 .conn
117 .prepare(&st.query)
118 .map_err(|e| FaucetError::Transform(format!("sql transform: prepare: {e}")))?;
119 let batches: Vec<RecordBatch> = stmt
120 .query_arrow([])
121 .map_err(|e| FaucetError::Transform(format!("sql transform: execute: {e}")))?
122 .collect();
123 Ok(batches)
124}
125
126fn execute_page(st: &mut State, records: Vec<Value>) -> Result<Vec<Value>, FaucetError> {
127 if records.is_empty() {
128 return Ok(Vec::new());
129 }
130 let fresh = infer_schema(&records)?;
133 let schema = match &st.cached_schema {
134 Some(s) if schema_eq(s, &fresh) => s.clone(),
135 _ => {
136 st.cached_schema = Some(fresh.clone());
137 fresh
138 }
139 };
140 let batch = json_to_record_batch(&records, schema)?;
141 let batches = run_query_batches(st, batch)?;
142 record_batches_to_json(&batches)
143}
144
145fn execute_batch(st: &mut State, batch: RecordBatch) -> Result<RecordBatch, FaucetError> {
149 if batch.num_rows() == 0 {
150 return Ok(batch);
152 }
153 let batches = run_query_batches(st, batch)?;
154 if batches.is_empty() {
155 return Ok(RecordBatch::new_empty(std::sync::Arc::new(
156 arrow::datatypes::Schema::empty(),
157 )));
158 }
159 let schema = batches[0].schema();
160 arrow::compute::concat_batches(&schema, &batches)
161 .map_err(|e| FaucetError::Transform(format!("sql transform: concat result batches: {e}")))
162}
163
164const DUCKDB_VECTOR_SIZE: usize = 2048;
167
168fn register_batch_chunked(st: &mut State, batch: RecordBatch) -> Result<(), FaucetError> {
173 let total = batch.num_rows();
174 let mut offset = 0;
175 let mut first = true;
176 loop {
180 let len = (total - offset).min(DUCKDB_VECTOR_SIZE);
181 let slice = batch.slice(offset, len);
182 let params = arrow_recordbatch_to_query_params(slice);
183 let sql = if first {
184 "CREATE OR REPLACE TEMP TABLE batch AS SELECT * FROM arrow(?, ?)"
185 } else {
186 "INSERT INTO batch SELECT * FROM arrow(?, ?)"
187 };
188 st.conn
189 .execute(sql, params)
190 .map_err(|e| FaucetError::Transform(format!("sql transform: register batch: {e}")))?;
191 first = false;
192 offset += len;
193 if offset >= total {
194 break;
195 }
196 }
197 Ok(())
198}
199
200fn reload_relations(st: &mut State) -> Result<(), FaucetError> {
201 for r in st.reloadables.iter_mut() {
202 let cur = std::fs::metadata(&r.path).and_then(|m| m.modified()).ok();
203 if cur != r.last_mtime {
204 let stmt = if r.is_csv {
205 format!(
206 "CREATE OR REPLACE TABLE \"{}\" AS SELECT * FROM read_csv_auto('{}', header={});",
207 r.name,
208 sql_escape(&r.path),
209 r.has_header
210 )
211 } else {
212 format!(
213 "CREATE OR REPLACE TABLE \"{}\" AS SELECT * FROM read_json_auto('{}', format='newline_delimited');",
214 r.name,
215 sql_escape(&r.path)
216 )
217 };
218 st.conn.execute_batch(&stmt).map_err(|e| {
219 FaucetError::Transform(format!("sql transform: reload '{}': {e}", r.name))
220 })?;
221 r.last_mtime = cur;
222 }
223 }
224 Ok(())
225}
226
227fn plan_has_aggregate(conn: &Connection, query: &str) -> bool {
228 let explain = format!("EXPLAIN {query}");
229 let mut found = false;
230 if let Ok(mut stmt) = conn.prepare(&explain)
231 && let Ok(rows) = stmt.query_map([], |row| row.get::<_, String>(1))
232 {
233 for r in rows.flatten() {
234 let u = r.to_uppercase();
235 if u.contains("AGGREGATE") || u.contains("WINDOW") {
236 found = true;
237 break;
238 }
239 }
240 }
241 found
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use crate::config::SqlTransformConfig;
248 use faucet_core::stage::{apply_stages_to_page, compile_stage};
249 use serde_json::json;
250
251 fn run(query: &str, rows: Vec<Value>) -> Vec<Value> {
252 let cfg = SqlTransformConfig {
253 query: query.into(),
254 relations: vec![],
255 memory_limit: None,
256 threads: Some(1),
257 };
258 let stage = compile_stage(&SqlTransform::compile(&cfg).unwrap().into_page_stage()).unwrap();
259 apply_stages_to_page(rows, std::slice::from_ref(&stage)).unwrap()
260 }
261
262 #[test]
266 fn large_page_passthrough_does_not_abort() {
267 let rows: Vec<Value> = (0..10_000).map(|i| json!({"id": i, "v": i * 2})).collect();
268 let out = run("SELECT * FROM batch", rows);
269 assert_eq!(out.len(), 10_000, "every row of a >2048-row page survives");
270 assert_eq!(out[5_000]["id"], json!(5_000));
272 }
273
274 #[test]
277 fn large_page_aggregate_is_global_over_the_whole_page() {
278 let rows: Vec<Value> = (0..5_000).map(|i| json!({"k": i % 4, "v": 1})).collect();
279 let out = run(
280 "SELECT k, COUNT(*) AS n FROM batch GROUP BY k ORDER BY k",
281 rows,
282 );
283 assert_eq!(out.len(), 4, "one group per key");
284 let total: i64 = out.iter().map(|r| r["n"].as_i64().unwrap()).sum();
285 assert_eq!(total, 5_000, "every row counted exactly once across chunks");
286 for r in &out {
287 assert_eq!(r["n"], json!(1_250), "5000 rows / 4 keys = 1250 each");
288 }
289 }
290
291 #[test]
293 fn small_page_still_works() {
294 let out = run(
295 "SELECT id FROM batch WHERE id >= 1 ORDER BY id",
296 vec![json!({"id": 0}), json!({"id": 1}), json!({"id": 2})],
297 );
298 assert_eq!(out.len(), 2);
299 assert_eq!(out[0]["id"], json!(1));
300 }
301
302 fn batch_fn(query: &str) -> faucet_core::stage::PageFnBatchBox {
305 let cfg = SqlTransformConfig {
306 query: query.into(),
307 relations: vec![],
308 memory_limit: None,
309 threads: Some(1),
310 };
311 let (stage, batch) = SqlTransform::compile(&cfg).unwrap().into_columnar_stage();
312 assert!(matches!(stage, TransformStage::PageFn(_)));
314 batch
315 }
316
317 #[test]
318 fn columnar_batch_fn_transforms_record_batch() {
319 let bf = batch_fn("SELECT id, v * 2 AS doubled FROM batch WHERE id >= 1 ORDER BY id");
320 let input = faucet_core::columnar::values_to_record_batch_inferred(&[
321 json!({"id": 0, "v": 5}),
322 json!({"id": 1, "v": 10}),
323 json!({"id": 2, "v": 20}),
324 ])
325 .unwrap();
326 let out = bf(input).unwrap();
327 let rows = faucet_core::columnar::record_batch_to_values(&out).unwrap();
328 assert_eq!(rows.len(), 2, "WHERE id >= 1 keeps two rows");
329 assert_eq!(rows[0]["id"], json!(1));
330 assert_eq!(rows[0]["doubled"], json!(20));
331 assert_eq!(rows[1]["doubled"], json!(40));
332 }
333
334 #[test]
336 fn columnar_batch_fn_handles_large_batch() {
337 let bf = batch_fn("SELECT * FROM batch");
338 let recs: Vec<Value> = (0..5_000).map(|i| json!({"id": i})).collect();
339 let input = faucet_core::columnar::values_to_record_batch_inferred(&recs).unwrap();
340 let out = bf(input).unwrap();
341 assert_eq!(out.num_rows(), 5_000);
342 }
343}