1use crate::query::{QueryRequest, QueryResponse, QueryResultSet};
4use datafusion::prelude::*;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use tecton_core::{ComputeConfig, Result, TectonError};
8use tracing::{debug, info, warn};
9
10pub struct ComputeEngine {
12 ctx: SessionContext,
13 data_dir: PathBuf,
14 max_rows: usize,
15}
16
17impl ComputeEngine {
18 pub async fn from_config(cfg: &ComputeConfig) -> Result<Self> {
20 std::fs::create_dir_all(&cfg.data_dir)?;
21
22 let ctx = SessionContext::new();
23 let mut engine = Self {
24 ctx,
25 data_dir: cfg.data_dir.clone(),
26 max_rows: cfg.max_rows,
27 };
28
29 engine.refresh_datasets().await?;
30 info!(dir = %engine.data_dir.display(), "compute engine ready");
31 Ok(engine)
32 }
33
34 pub fn data_dir(&self) -> &Path {
35 &self.data_dir
36 }
37
38 pub fn max_rows(&self) -> usize {
39 self.max_rows
40 }
41
42 pub async fn refresh_datasets(&mut self) -> Result<Vec<String>> {
46 let mut registered = Vec::new();
47 let entries = std::fs::read_dir(&self.data_dir).map_err(|e| {
48 TectonError::compute(format!(
49 "cannot read data dir {}: {e}",
50 self.data_dir.display()
51 ))
52 })?;
53
54 for entry in entries {
55 let entry = entry.map_err(|e| TectonError::compute(e.to_string()))?;
56 let path = entry.path();
57 if !path.is_file() {
58 continue;
59 }
60 let ext = path
61 .extension()
62 .and_then(|e| e.to_str())
63 .unwrap_or("")
64 .to_ascii_lowercase();
65
66 let stem = path
67 .file_stem()
68 .and_then(|s| s.to_str())
69 .unwrap_or("table");
70 let table = sanitize_table_name(stem);
71 let path_owned = path.to_string_lossy().into_owned();
74
75 let result = match ext.as_str() {
76 "parquet" => {
77 self.ctx
78 .register_parquet(&table, &path_owned, ParquetReadOptions::default())
79 .await
80 }
81 "csv" => {
82 let csv_options = CsvReadOptions::new();
83 self.ctx
84 .register_csv(&table, &path_owned, csv_options)
85 .await
86 }
87 _ => continue,
88 };
89
90 match result {
91 Ok(()) => {
92 debug!(%table, path = %path.display(), "registered dataset");
93 registered.push(table);
94 }
95 Err(e) => warn!(path = %path.display(), error = %e, "failed to register dataset"),
96 }
97 }
98
99 Ok(registered)
100 }
101
102 pub async fn list_tables(&self) -> Result<Vec<String>> {
104 let names = self.ctx.catalog_names();
105 let mut tables = Vec::new();
106 for catalog_name in names {
107 let catalog = self
108 .ctx
109 .catalog(&catalog_name)
110 .ok_or_else(|| TectonError::compute("default catalog missing"))?;
111 for schema_name in catalog.schema_names() {
112 if let Some(schema) = catalog.schema(&schema_name) {
113 tables.extend(schema.table_names());
114 }
115 }
116 }
117 tables.sort();
118 Ok(tables)
119 }
120
121 pub async fn sql(&self, request: QueryRequest) -> Result<QueryResponse> {
123 let sql = request.sql.trim();
124 validate_sql(sql)?;
125
126 let started = std::time::Instant::now();
127 let df = self
128 .ctx
129 .sql(sql)
130 .await
131 .map_err(|e| TectonError::compute(format!("SQL planning failed: {e}")))?;
132
133 let batches = df
134 .collect()
135 .await
136 .map_err(|e| TectonError::compute(format!("SQL execution failed: {e}")))?;
137
138 let result = QueryResultSet::from_batches(&batches, self.max_rows)?;
139 let elapsed_ms = started.elapsed().as_millis() as u64;
140
141 Ok(QueryResponse {
142 columns: result.columns,
143 rows: result.rows,
144 row_count: result.row_count,
145 truncated: result.truncated,
146 elapsed_ms,
147 })
148 }
149
150 pub fn context(&self) -> Arc<SessionContext> {
152 Arc::new(self.ctx.clone())
153 }
154}
155
156fn validate_sql(sql: &str) -> Result<()> {
158 if sql.is_empty() {
159 return Err(TectonError::invalid_input("SQL query must not be empty"));
160 }
161 if sql.len() > 32_768 {
162 return Err(TectonError::invalid_input("SQL query exceeds 32 KiB limit"));
163 }
164
165 let normalized = sql.trim_start().to_ascii_lowercase();
166 let forbidden = [
167 "insert ", "update ", "delete ", "drop ", "alter ", "create ", "truncate ",
168 "grant ", "revoke ", "copy ", "attach ", "detach ", "pragma ",
169 ];
170 for token in forbidden {
171 if normalized.starts_with(token.trim()) || normalized.contains(&format!(" {token}")) {
172 return Err(TectonError::invalid_input(format!(
174 "mutating statement not allowed in MVP (found '{token}')"
175 )));
176 }
177 }
178
179 if !(normalized.starts_with("select")
180 || normalized.starts_with("with")
181 || normalized.starts_with("show")
182 || normalized.starts_with("describe")
183 || normalized.starts_with("explain"))
184 {
185 return Err(TectonError::invalid_input(
186 "only SELECT/WITH/SHOW/DESCRIBE/EXPLAIN statements are allowed",
187 ));
188 }
189
190 Ok(())
191}
192
193fn sanitize_table_name(raw: &str) -> String {
194 let mut name: String = raw
195 .chars()
196 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
197 .collect();
198 if name.is_empty() {
199 name = "table".into();
200 }
201 if name.chars().next().is_some_and(|c| c.is_ascii_digit()) {
202 name = format!("t_{name}");
203 }
204 name.to_ascii_lowercase()
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn sql_validation_blocks_mutations() {
213 assert!(validate_sql("SELECT 1").is_ok());
214 assert!(validate_sql("DROP TABLE x").is_err());
215 assert!(validate_sql("INSERT INTO t VALUES (1)").is_err());
216 }
217
218 #[test]
219 fn sanitize_table_names() {
220 assert_eq!(sanitize_table_name("My Data"), "my_data");
221 assert_eq!(sanitize_table_name("sales-2024"), "sales_2024");
222 assert_eq!(sanitize_table_name("9lives"), "t_9lives");
223 }
224}