1use 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
14pub const DEFAULT_CSV_TABLE: &str = "data";
16
17pub const PARQUET_CONVERT_THRESHOLD_BYTES: u64 = 100 * 1024 * 1024;
19
20pub const SELECT_STAR_DEFAULT_LIMIT: usize = 1000;
22
23const MAX_STREAM_ROWS: usize = 10_000;
25
26pub 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
77pub 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 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 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 df.write_parquet(&tmp_str, DataFrameWriteOptions::new(), None)
147 .await
148 .map_err(ComputeError::datafusion)?;
149
150 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 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 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
319fn 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 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}