Skip to main content

tecton_compute/
execute.rs

1//! One-shot dataset SQL execution with large-file optimizations.
2
3use crate::error::ComputeError;
4use crate::query::{batch_to_json_values, QueryResult};
5use datafusion::dataframe::DataFrameWriteOptions;
6use datafusion::prelude::*;
7use futures::StreamExt;
8use parquet::file::reader::{FileReader, SerializedFileReader};
9use std::fs::File;
10use std::path::{Path, PathBuf};
11use std::time::Instant;
12use tracing::{info, warn};
13
14/// Default table name registered for the dataset (used in SQL as `FROM data`).
15pub const DEFAULT_CSV_TABLE: &str = "data";
16
17/// CSV files larger than this are auto-converted to Parquet when no cache exists.
18pub const PARQUET_CONVERT_THRESHOLD_BYTES: u64 = 100 * 1024 * 1024;
19
20/// Safety LIMIT appended to bare `SELECT *` queries.
21pub const SELECT_STAR_DEFAULT_LIMIT: usize = 1000;
22
23/// Hard cap when streaming result batches into memory for the UI/CLI.
24const MAX_STREAM_ROWS: usize = 10_000;
25
26/// Execute SQL against a CSV path (optionally via a Parquet cache) and return a rich result.
27///
28/// Optimizations:
29/// - CSV > 100 MiB → convert/reuse sibling `.parquet` when present
30/// - `SELECT *` without `LIMIT` → append `LIMIT 1000`
31/// - Results are streamed via DataFusion `execute_stream` to limit RAM spikes
32pub async fn execute_query(csv_path: &str, sql: &str) -> Result<QueryResult, ComputeError> {
33    let started = Instant::now();
34    let mut optimizations: Vec<String> = Vec::new();
35
36    let (safe_sql, limit_note) = apply_select_star_safety(sql.trim())?;
37    if let Some(note) = limit_note {
38        info!(%note, "query safety applied");
39        optimizations.push(note);
40    }
41    validate_sql(&safe_sql)?;
42
43    let dataset_path = owned_absolute_path(csv_path)?;
44    validate_dataset_path(Path::new(&dataset_path))?;
45
46    let (exec_path, format_note) = ensure_query_source(Path::new(&dataset_path)).await?;
47    if let Some(note) = format_note {
48        info!(path = %exec_path.display(), %note, "format optimization applied");
49        optimizations.push(note);
50    }
51
52    let table_name = DEFAULT_CSV_TABLE.to_owned();
53    let ctx = SessionContext::new();
54    register_table(&ctx, &table_name, &exec_path).await?;
55
56    let df = ctx
57        .sql(&safe_sql)
58        .await
59        .map_err(ComputeError::datafusion)?;
60
61    let data = stream_json_rows(df, MAX_STREAM_ROWS).await?;
62
63    let total_rows_affected = estimate_total_rows(
64        &exec_path,
65        &data,
66        optimizations.iter().any(|o| o.contains("LIMIT")),
67    )?;
68
69    Ok(QueryResult {
70        execution_time_ms: started.elapsed().as_millis() as u64,
71        total_rows_affected,
72        data,
73        optimization_applied: merge_notes(optimizations),
74    })
75}
76
77/// Convenience wrapper that returns pretty JSON (CLI / Tauri string bridge).
78pub async fn execute_query_json(csv_path: &str, sql: &str) -> Result<String, ComputeError> {
79    let result = execute_query(csv_path, sql).await?;
80    Ok(result.to_pretty_json()?)
81}
82
83async fn ensure_query_source(path: &Path) -> Result<(PathBuf, Option<String>), ComputeError> {
84    // Parquet file or directory of shards — use as-is.
85    if path.is_dir() {
86        return Ok((
87            path.to_path_buf(),
88            Some("Using Parquet dataset directory".to_string()),
89        ));
90    }
91
92    let ext = path
93        .extension()
94        .and_then(|e| e.to_str())
95        .unwrap_or("")
96        .to_ascii_lowercase();
97
98    if ext == "parquet" {
99        return Ok((
100            path.to_path_buf(),
101            Some("Using Parquet dataset".to_string()),
102        ));
103    }
104
105    // CSV path: prefer sibling .parquet cache, else convert when large.
106    let parquet_path = path.with_extension("parquet");
107    if parquet_path.exists() {
108        return Ok((
109            parquet_path,
110            Some("Using existing Parquet cache".to_string()),
111        ));
112    }
113
114    let meta = std::fs::metadata(path)?;
115    if meta.len() <= PARQUET_CONVERT_THRESHOLD_BYTES {
116        return Ok((path.to_path_buf(), None));
117    }
118
119    info!(
120        csv = %path.display(),
121        bytes = meta.len(),
122        threshold = PARQUET_CONVERT_THRESHOLD_BYTES,
123        "CSV exceeds threshold; converting to Parquet"
124    );
125
126    convert_csv_to_parquet(path, &parquet_path).await?;
127
128    Ok((
129        parquet_path,
130        Some("Converted CSV to Parquet".to_string()),
131    ))
132}
133
134async fn convert_csv_to_parquet(csv_path: &Path, parquet_path: &Path) -> Result<(), ComputeError> {
135    let csv_str = path_to_utf8(csv_path)?;
136    let tmp = parquet_path.with_extension("parquet.tmp");
137    let tmp_str = path_to_utf8(&tmp)?;
138
139    let ctx = SessionContext::new();
140    let df = ctx
141        .read_csv(&csv_str, CsvReadOptions::new())
142        .await
143        .map_err(ComputeError::datafusion)?;
144
145    // Streamed write through DataFusion — avoids loading the full CSV as JSON.
146    df.write_parquet(&tmp_str, DataFrameWriteOptions::new(), None)
147        .await
148        .map_err(ComputeError::datafusion)?;
149
150    // DataFusion may write a directory for multi-file output; normalize to a single file path.
151    finalize_parquet_output(&tmp, parquet_path)?;
152
153    info!(
154        from = %csv_path.display(),
155        to = %parquet_path.display(),
156        "Parquet conversion complete"
157    );
158    Ok(())
159}
160
161fn finalize_parquet_output(tmp: &Path, final_path: &Path) -> Result<(), ComputeError> {
162    if tmp.is_file() {
163        if final_path.exists() {
164            std::fs::remove_file(final_path)?;
165        }
166        std::fs::rename(tmp, final_path)?;
167        return Ok(());
168    }
169
170    if tmp.is_dir() {
171        // Pick the first part file produced by the writer.
172        let mut parts: Vec<PathBuf> = std::fs::read_dir(tmp)?
173            .filter_map(|e| e.ok().map(|e| e.path()))
174            .filter(|p| {
175                p.extension()
176                    .and_then(|e| e.to_str())
177                    .is_some_and(|e| e.eq_ignore_ascii_case("parquet"))
178            })
179            .collect();
180        parts.sort();
181        let part = parts.into_iter().next().ok_or_else(|| {
182            ComputeError::datafusion(format!(
183                "parquet write produced no part files under {}",
184                tmp.display()
185            ))
186        })?;
187        if final_path.exists() {
188            std::fs::remove_file(final_path)?;
189        }
190        std::fs::rename(&part, final_path)?;
191        let _ = std::fs::remove_dir_all(tmp);
192        return Ok(());
193    }
194
195    Err(ComputeError::datafusion(format!(
196        "unexpected parquet write output: {}",
197        tmp.display()
198    )))
199}
200
201async fn register_table(
202    ctx: &SessionContext,
203    table_name: &str,
204    path: &Path,
205) -> Result<(), ComputeError> {
206    let path_str = path_to_utf8(path)?;
207
208    if path.is_dir() {
209        // Multi-file Parquet dataset (e.g. bank_billion/part-*.parquet).
210        ctx.register_parquet(table_name, &path_str, ParquetReadOptions::default())
211            .await
212            .map_err(ComputeError::datafusion)?;
213        return Ok(());
214    }
215
216    let ext = path
217        .extension()
218        .and_then(|e| e.to_str())
219        .unwrap_or("")
220        .to_ascii_lowercase();
221
222    match ext.as_str() {
223        "parquet" => {
224            ctx.register_parquet(table_name, &path_str, ParquetReadOptions::default())
225                .await
226                .map_err(ComputeError::datafusion)?;
227        }
228        "csv" => {
229            let options = CsvReadOptions::new();
230            ctx.register_csv(table_name, &path_str, options)
231                .await
232                .map_err(ComputeError::datafusion)?;
233        }
234        other => {
235            return Err(ComputeError::invalid_input(format!(
236                "unsupported dataset format '{other}'"
237            )));
238        }
239    }
240    Ok(())
241}
242
243async fn stream_json_rows(
244    df: DataFrame,
245    max_rows: usize,
246) -> Result<Vec<serde_json::Value>, ComputeError> {
247    let mut stream = df
248        .execute_stream()
249        .await
250        .map_err(ComputeError::datafusion)?;
251
252    let mut data = Vec::new();
253    while let Some(batch) = stream.next().await {
254        let batch = batch.map_err(ComputeError::datafusion)?;
255        let mut rows = batch_to_json_values(&batch).map_err(|e| ComputeError::datafusion(e))?;
256        for row in rows.drain(..) {
257            if data.len() >= max_rows {
258                warn!(max_rows, "result stream truncated at hard cap");
259                return Ok(data);
260            }
261            data.push(row);
262        }
263    }
264    Ok(data)
265}
266
267fn estimate_total_rows(
268    exec_path: &Path,
269    data: &[serde_json::Value],
270    limit_applied: bool,
271) -> Result<u64, ComputeError> {
272    let returned = data.len() as u64;
273
274    if !limit_applied {
275        return Ok(returned);
276    }
277
278    if let Some(n) = parquet_dataset_num_rows(exec_path) {
279        return Ok(n);
280    }
281
282    Ok(returned)
283}
284
285fn parquet_dataset_num_rows(path: &Path) -> Option<u64> {
286    if path.is_file() {
287        return parquet_file_num_rows(path);
288    }
289    if path.is_dir() {
290        let mut total = 0u64;
291        let mut any = false;
292        for entry in std::fs::read_dir(path).ok()? {
293            let entry = entry.ok()?;
294            let p = entry.path();
295            if p.extension()
296                .and_then(|e| e.to_str())
297                .is_some_and(|e| e.eq_ignore_ascii_case("parquet"))
298            {
299                total = total.saturating_add(parquet_file_num_rows(&p)?);
300                any = true;
301            }
302        }
303        return any.then_some(total);
304    }
305    None
306}
307
308fn parquet_file_num_rows(path: &Path) -> Option<u64> {
309    let file = File::open(path).ok()?;
310    let reader = SerializedFileReader::new(file).ok()?;
311    let n = reader.metadata().file_metadata().num_rows();
312    if n < 0 {
313        None
314    } else {
315        Some(n as u64)
316    }
317}
318
319/// If SQL is a `SELECT *` without LIMIT, append `LIMIT 1000`.
320fn apply_select_star_safety(sql: &str) -> Result<(String, Option<String>), ComputeError> {
321    if sql.is_empty() {
322        return Err(ComputeError::invalid_input("SQL query must not be empty"));
323    }
324
325    let compact = collapse_ws_lower(sql);
326    let is_select_star = compact.starts_with("select*")
327        || compact.starts_with("select *")
328        || compact.contains(" select *")
329        || compact.contains("select*from")
330        || compact.contains("select *from");
331
332    if !is_select_star {
333        return Ok((sql.to_owned(), None));
334    }
335
336    if has_limit_clause(&compact) {
337        return Ok((sql.to_owned(), None));
338    }
339
340    let trimmed = sql.trim().trim_end_matches(';').trim_end();
341    let rewritten = format!("{trimmed} LIMIT {SELECT_STAR_DEFAULT_LIMIT}");
342    Ok((
343        rewritten,
344        Some(format!(
345            "Appended LIMIT {SELECT_STAR_DEFAULT_LIMIT} to SELECT *"
346        )),
347    ))
348}
349
350fn has_limit_clause(sql_lower: &str) -> bool {
351    // Token-based so identifiers like `limited_table` are not false positives.
352    for token in sql_lower.split_whitespace().filter(|t| !t.is_empty()) {
353        if token == "limit" {
354            return true;
355        }
356        if let Some(rest) = token.strip_prefix("limit") {
357            if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()) {
358                return true;
359            }
360        }
361    }
362    false
363}
364
365fn collapse_ws_lower(sql: &str) -> String {
366    let mut out = String::with_capacity(sql.len());
367    let mut prev_space = false;
368    for c in sql.chars() {
369        if c.is_whitespace() {
370            if !prev_space {
371                out.push(' ');
372                prev_space = true;
373            }
374        } else {
375            out.push(c.to_ascii_lowercase());
376            prev_space = false;
377        }
378    }
379    out.trim().to_owned()
380}
381
382fn merge_notes(notes: Vec<String>) -> Option<String> {
383    if notes.is_empty() {
384        None
385    } else {
386        Some(notes.join("; "))
387    }
388}
389
390fn owned_absolute_path(path: &str) -> Result<String, ComputeError> {
391    if path.trim().is_empty() {
392        return Err(ComputeError::invalid_input("csv path must not be empty"));
393    }
394    let path = Path::new(path);
395    let absolute: PathBuf = if path.is_absolute() {
396        path.to_path_buf()
397    } else {
398        std::env::current_dir()
399            .map_err(ComputeError::Io)?
400            .join(path)
401    };
402    Ok(absolute.to_string_lossy().into_owned())
403}
404
405fn path_to_utf8(path: &Path) -> Result<String, ComputeError> {
406    path.to_str()
407        .map(str::to_owned)
408        .ok_or_else(|| ComputeError::invalid_input("path is not valid UTF-8"))
409}
410
411fn validate_dataset_path(path: &Path) -> Result<(), ComputeError> {
412    if path.as_os_str().is_empty() {
413        return Err(ComputeError::invalid_input("dataset path must not be empty"));
414    }
415    if !path.exists() {
416        return Err(ComputeError::FileNotFound(path.display().to_string()));
417    }
418
419    if path.is_dir() {
420        let has_parquet = std::fs::read_dir(path)
421            .map_err(ComputeError::Io)?
422            .filter_map(|e| e.ok())
423            .any(|e| {
424                e.path()
425                    .extension()
426                    .and_then(|ext| ext.to_str())
427                    .is_some_and(|ext| ext.eq_ignore_ascii_case("parquet"))
428            });
429        if !has_parquet {
430            return Err(ComputeError::invalid_input(format!(
431                "directory has no .parquet shards: {}",
432                path.display()
433            )));
434        }
435        return Ok(());
436    }
437
438    if !path.is_file() {
439        return Err(ComputeError::invalid_input(format!(
440            "dataset path is not a file or directory: {}",
441            path.display()
442        )));
443    }
444
445    let ext = path
446        .extension()
447        .and_then(|e| e.to_str())
448        .unwrap_or("")
449        .to_ascii_lowercase();
450    if ext != "csv" && ext != "parquet" {
451        return Err(ComputeError::invalid_input(format!(
452            "expected a .csv/.parquet file or parquet directory, got '{}'",
453            path.display()
454        )));
455    }
456    Ok(())
457}
458
459#[cfg(test)]
460fn validate_csv_path(path: &Path) -> Result<(), ComputeError> {
461    validate_dataset_path(path)
462}
463
464fn validate_sql(sql: &str) -> Result<(), ComputeError> {
465    let trimmed = sql.trim();
466    if trimmed.is_empty() {
467        return Err(ComputeError::invalid_input("SQL query must not be empty"));
468    }
469    if trimmed.len() > 32_768 {
470        return Err(ComputeError::invalid_input(
471            "SQL query exceeds 32 KiB limit",
472        ));
473    }
474
475    let normalized = trimmed.to_ascii_lowercase();
476    let forbidden = [
477        "insert ", "update ", "delete ", "drop ", "alter ", "create ", "truncate ", "grant ",
478        "revoke ", "copy ", "attach ", "detach ", "pragma ",
479    ];
480    for token in forbidden {
481        if normalized.starts_with(token.trim()) || normalized.contains(&format!(" {token}")) {
482            return Err(ComputeError::invalid_input(format!(
483                "mutating statement not allowed (found '{token}')"
484            )));
485        }
486    }
487
488    if !(normalized.starts_with("select")
489        || normalized.starts_with("with")
490        || normalized.starts_with("show")
491        || normalized.starts_with("describe")
492        || normalized.starts_with("explain"))
493    {
494        return Err(ComputeError::invalid_input(
495            "only SELECT/WITH/SHOW/DESCRIBE/EXPLAIN statements are allowed",
496        ));
497    }
498
499    Ok(())
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505    use std::io::Write;
506
507    #[test]
508    fn select_star_gets_limit() {
509        let (sql, note) = apply_select_star_safety("SELECT * FROM data").unwrap();
510        assert!(sql.to_ascii_uppercase().contains("LIMIT 1000"));
511        assert!(note.unwrap().contains("LIMIT"));
512    }
513
514    #[test]
515    fn select_star_with_limit_untouched() {
516        let (sql, note) = apply_select_star_safety("SELECT * FROM data LIMIT 5").unwrap();
517        assert_eq!(sql, "SELECT * FROM data LIMIT 5");
518        assert!(note.is_none());
519    }
520
521    #[test]
522    fn projected_select_untouched() {
523        let (sql, note) = apply_select_star_safety("SELECT id FROM data").unwrap();
524        assert_eq!(sql, "SELECT id FROM data");
525        assert!(note.is_none());
526    }
527
528    #[tokio::test]
529    async fn execute_query_returns_structured_result() {
530        let dir = tempfile::tempdir().unwrap();
531        let csv = dir.path().join("sample.csv");
532        {
533            let mut f = std::fs::File::create(&csv).unwrap();
534            writeln!(f, "id,name").unwrap();
535            writeln!(f, "1,alpha").unwrap();
536            writeln!(f, "2,beta").unwrap();
537        }
538
539        let result = execute_query(
540            csv.to_str().unwrap(),
541            "SELECT id, name FROM data ORDER BY id LIMIT 1",
542        )
543        .await
544        .unwrap();
545
546        assert_eq!(result.data.len(), 1);
547        assert!(result.execution_time_ms < 60_000);
548        assert_eq!(result.total_rows_affected, 1);
549        let row = result.data[0].as_object().unwrap();
550        assert_eq!(row.get("name").and_then(|v| v.as_str()), Some("alpha"));
551    }
552
553    #[tokio::test]
554    async fn select_star_safety_limits_rows() {
555        let dir = tempfile::tempdir().unwrap();
556        let csv = dir.path().join("many.csv");
557        {
558            let mut f = std::fs::File::create(&csv).unwrap();
559            writeln!(f, "id").unwrap();
560            for i in 0..1500 {
561                writeln!(f, "{i}").unwrap();
562            }
563        }
564
565        let result = execute_query(csv.to_str().unwrap(), "SELECT * FROM data")
566            .await
567            .unwrap();
568
569        assert!(result.data.len() <= SELECT_STAR_DEFAULT_LIMIT);
570        assert!(result
571            .optimization_applied
572            .as_deref()
573            .unwrap_or("")
574            .contains("LIMIT"));
575    }
576
577    #[test]
578    fn rejects_non_csv() {
579        assert!(validate_csv_path(Path::new("note.txt")).is_err());
580    }
581}