Skip to main content

mdql_core/
executor.rs

1//! Unified SQL execution — single entry point for CLI, REPL, and web server.
2
3use std::path::Path;
4
5use crate::api::Table;
6use crate::database::{ViewDef, is_database_dir, load_database_config, save_database_config};
7use crate::errors::{MdqlError, ValidationError};
8use crate::model::Row;
9use crate::query_engine::{execute_join_query, execute_query};
10use crate::query_parser::{Statement, parse_query};
11
12#[derive(Debug)]
13pub enum QueryResult {
14    Rows { rows: Vec<Row>, columns: Vec<String> },
15    Message(String),
16}
17
18pub fn execute(path: &Path, sql: &str) -> crate::errors::Result<(QueryResult, Vec<ValidationError>)> {
19    let stmt = parse_query(sql)?;
20    let is_db = is_database_dir(path);
21
22    match stmt {
23        Statement::Select(ref q) => {
24            if q.subquery.is_some() || !q.joins.is_empty() || is_db {
25                let (_config, tables, errors) = crate::loader::load_database(path)?;
26                let (rows, cols) = if let Some(ref sub) = q.subquery {
27                    let source_table = &sub.table;
28                    let (schema, table_rows) = tables.get(source_table).ok_or_else(|| {
29                        MdqlError::QueryExecution(format!(
30                            "table '{}' not found in database",
31                            source_table
32                        ))
33                    })?;
34                    let (sub_rows, _) = execute_query(sub, table_rows, schema)?;
35                    execute_query(q, &sub_rows, schema)?
36                } else if !q.joins.is_empty() {
37                    execute_join_query(q, &tables)?
38                } else {
39                    let (schema, rows) = tables.get(&q.table).ok_or_else(|| {
40                        MdqlError::QueryExecution(format!(
41                            "table '{}' not found in database",
42                            q.table
43                        ))
44                    })?;
45                    execute_query(q, rows, schema)?
46                };
47                Ok((QueryResult::Rows { rows, columns: cols }, errors))
48            } else {
49                let (schema, rows, errors) = crate::loader::load_table(path)?;
50                let (rows, cols) = execute_query(q, &rows, &schema)?;
51                Ok((QueryResult::Rows { rows, columns: cols }, errors))
52            }
53        }
54        Statement::CreateView(ref cv) => {
55            if !is_db {
56                return Err(MdqlError::QueryExecution(
57                    "CREATE VIEW requires a database directory".into(),
58                ));
59            }
60            let mut config = load_database_config(path)?;
61
62            let (_config_check, tables, _errors) = crate::loader::load_database(path)?;
63            if tables.contains_key(&cv.view_name) {
64                return Err(MdqlError::QueryExecution(format!(
65                    "Name '{}' already exists as a table or view",
66                    cv.view_name
67                )));
68            }
69
70            if config.views.iter().any(|v| v.name == cv.view_name) {
71                return Err(MdqlError::QueryExecution(format!(
72                    "View '{}' already exists",
73                    cv.view_name
74                )));
75            }
76
77            let query_str = extract_view_query(sql)?;
78
79            let view_def = ViewDef {
80                name: cv.view_name.clone(),
81                query: query_str,
82            };
83
84            let test_result = crate::loader::load_database(path);
85            if let Ok((_cfg, test_tables, _errs)) = test_result {
86                let test_view = ViewDef {
87                    name: view_def.name.clone(),
88                    query: view_def.query.clone(),
89                };
90                if let Err(e) = super::loader::materialize_view(&test_view, &test_tables) {
91                    return Err(MdqlError::QueryExecution(format!(
92                        "View query failed validation: {}",
93                        e
94                    )));
95                }
96            }
97
98            config.views.push(view_def);
99            save_database_config(path, &config)?;
100            Ok((
101                QueryResult::Message(format!("View '{}' created", cv.view_name)),
102                vec![],
103            ))
104        }
105        Statement::DropView(ref dv) => {
106            if !is_db {
107                return Err(MdqlError::QueryExecution(
108                    "DROP VIEW requires a database directory".into(),
109                ));
110            }
111            let mut config = load_database_config(path)?;
112            let len_before = config.views.len();
113            config.views.retain(|v| v.name != dv.view_name);
114            if config.views.len() == len_before {
115                return Err(MdqlError::QueryExecution(format!(
116                    "View '{}' does not exist",
117                    dv.view_name
118                )));
119            }
120            save_database_config(path, &config)?;
121            Ok((
122                QueryResult::Message(format!("View '{}' dropped", dv.view_name)),
123                vec![],
124            ))
125        }
126        ref stmt @ (Statement::Insert(_)
127        | Statement::Update(_)
128        | Statement::Delete(_)
129        | Statement::AlterRename(_)
130        | Statement::AlterDrop(_)
131        | Statement::AlterMerge(_)) => {
132            if is_db {
133                let config = load_database_config(path)?;
134                let target = stmt.table_name();
135                if config.views.iter().any(|v| v.name == target) {
136                    return Err(MdqlError::QueryExecution(format!(
137                        "Cannot write to view '{}' — views are read-only",
138                        target
139                    )));
140                }
141            }
142            let table_path = if is_db {
143                path.join(stmt.table_name())
144            } else {
145                path.to_path_buf()
146            };
147            let mut table = Table::new(&table_path)?;
148            let msg = table.execute_sql(sql)?;
149            Ok((QueryResult::Message(msg), vec![]))
150        }
151    }
152}
153
154fn extract_view_query(sql: &str) -> crate::errors::Result<String> {
155    let upper = sql.to_uppercase();
156    let as_keyword = upper.find(" AS ");
157    if let Some(pos) = as_keyword {
158        let after = &sql[pos + 4..];
159        let trimmed = after.trim_start();
160        let trimmed_upper = trimmed.to_uppercase();
161        if trimmed_upper.starts_with("SELECT") {
162            return Ok(trimmed.to_string());
163        }
164    }
165    // Fallback: scan for any whitespace-surrounded AS that precedes SELECT
166    let bytes = upper.as_bytes();
167    let mut i = 0;
168    while i + 4 < bytes.len() {
169        if bytes[i].is_ascii_whitespace()
170            && bytes[i + 1] == b'A'
171            && bytes[i + 2] == b'S'
172            && bytes[i + 3].is_ascii_whitespace()
173        {
174            let after = &sql[i + 3..];
175            let trimmed = after.trim_start();
176            let trimmed_upper = trimmed.to_uppercase();
177            if trimmed_upper.starts_with("SELECT") {
178                return Ok(trimmed.to_string());
179            }
180        }
181        i += 1;
182    }
183    Err(crate::errors::MdqlError::QueryExecution(
184        "CREATE VIEW must contain AS clause followed by SELECT".into(),
185    ))
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use std::fs;
192
193    fn make_test_db() -> tempfile::TempDir {
194        let dir = tempfile::tempdir().unwrap();
195
196        // Database-level _mdql.md
197        fs::write(
198            dir.path().join("_mdql.md"),
199            "---\ntype: database\nname: testdb\n---\n",
200        )
201        .unwrap();
202
203        // Table: strategies
204        let strats = dir.path().join("strategies");
205        fs::create_dir(&strats).unwrap();
206        fs::write(
207            strats.join("_mdql.md"),
208            "---\ntype: schema\ntable: strategies\nprimary_key: path\nfrontmatter:\n  title:\n    type: string\n  status:\n    type: string\n---\n",
209        )
210        .unwrap();
211        fs::write(
212            strats.join("alpha.md"),
213            "---\ntitle: Alpha\nstatus: LIVE\n---\n# Alpha\n",
214        )
215        .unwrap();
216        fs::write(
217            strats.join("beta.md"),
218            "---\ntitle: Beta\nstatus: DRAFT\n---\n# Beta\n",
219        )
220        .unwrap();
221
222        dir
223    }
224
225    #[test]
226    fn test_create_and_query_view() {
227        let dir = make_test_db();
228        let (result, _) = execute(
229            dir.path(),
230            "CREATE VIEW live AS SELECT * FROM strategies WHERE status = 'LIVE'",
231        )
232        .unwrap();
233        assert!(matches!(result, QueryResult::Message(ref m) if m.contains("created")));
234
235        let (result, _) = execute(dir.path(), "SELECT * FROM live").unwrap();
236        if let QueryResult::Rows { rows, columns } = result {
237            assert_eq!(rows.len(), 1);
238            assert!(columns.contains(&"title".to_string()));
239        } else {
240            panic!("Expected Rows");
241        }
242    }
243
244    #[test]
245    fn test_drop_view() {
246        let dir = make_test_db();
247        execute(
248            dir.path(),
249            "CREATE VIEW live AS SELECT * FROM strategies WHERE status = 'LIVE'",
250        )
251        .unwrap();
252
253        let (result, _) = execute(dir.path(), "DROP VIEW live").unwrap();
254        assert!(matches!(result, QueryResult::Message(ref m) if m.contains("dropped")));
255
256        let err = execute(dir.path(), "SELECT * FROM live");
257        assert!(err.is_err());
258    }
259
260    #[test]
261    fn test_drop_nonexistent_view() {
262        let dir = make_test_db();
263        let err = execute(dir.path(), "DROP VIEW nonexistent");
264        assert!(err.is_err());
265        assert!(err.unwrap_err().to_string().contains("does not exist"));
266    }
267
268    #[test]
269    fn test_create_view_duplicate_name() {
270        let dir = make_test_db();
271        execute(
272            dir.path(),
273            "CREATE VIEW live AS SELECT * FROM strategies WHERE status = 'LIVE'",
274        )
275        .unwrap();
276
277        let err = execute(
278            dir.path(),
279            "CREATE VIEW live AS SELECT * FROM strategies",
280        );
281        assert!(err.is_err());
282        assert!(err.unwrap_err().to_string().contains("already exists"));
283    }
284
285    #[test]
286    fn test_create_view_conflicts_with_table() {
287        let dir = make_test_db();
288        let err = execute(
289            dir.path(),
290            "CREATE VIEW strategies AS SELECT * FROM strategies",
291        );
292        assert!(err.is_err());
293        assert!(err.unwrap_err().to_string().contains("already exists"));
294    }
295
296    #[test]
297    fn test_write_to_view_rejected() {
298        let dir = make_test_db();
299        execute(
300            dir.path(),
301            "CREATE VIEW live AS SELECT * FROM strategies WHERE status = 'LIVE'",
302        )
303        .unwrap();
304
305        let err = execute(
306            dir.path(),
307            "INSERT INTO live (title, status) VALUES ('Gamma', 'LIVE')",
308        );
309        assert!(err.is_err());
310        assert!(err.unwrap_err().to_string().contains("read-only"));
311    }
312
313    #[test]
314    fn test_create_view_not_database() {
315        let dir = tempfile::tempdir().unwrap();
316        fs::write(
317            dir.path().join("_mdql.md"),
318            "---\ntype: schema\ntable: t\nprimary_key: path\nfrontmatter:\n  x:\n    type: string\n---\n",
319        )
320        .unwrap();
321
322        let err = execute(
323            dir.path(),
324            "CREATE VIEW v AS SELECT * FROM t",
325        );
326        assert!(err.is_err());
327        assert!(err.unwrap_err().to_string().contains("database directory"));
328    }
329
330    #[test]
331    fn test_extract_view_query_basic() {
332        let q = extract_view_query("CREATE VIEW v AS SELECT * FROM t").unwrap();
333        assert!(q.starts_with("SELECT"));
334    }
335
336    #[test]
337    fn test_extract_view_query_with_column_alias() {
338        let q = extract_view_query(
339            "CREATE VIEW v AS SELECT token, SUM(size) as sell_size FROM orders GROUP BY token HAVING sell_size > 0"
340        ).unwrap();
341        assert!(q.starts_with("SELECT"));
342        assert!(q.contains("HAVING"));
343    }
344
345    #[test]
346    fn test_extract_view_query_newline_after_as() {
347        let q = extract_view_query("CREATE VIEW v AS\nSELECT * FROM t").unwrap();
348        assert!(q.starts_with("SELECT"));
349    }
350
351    #[test]
352    fn test_create_view_with_aggregate_arithmetic() {
353        let dir = make_test_db();
354        let result = execute(
355            dir.path(),
356            "CREATE VIEW v AS SELECT status, COUNT(*) - COUNT(*) as zero FROM strategies GROUP BY status",
357        );
358        assert!(result.is_ok());
359    }
360
361    // ── Issue #44: HAVING in CREATE VIEW ──
362
363    #[test]
364    fn test_create_view_with_having() {
365        let dir = make_test_db();
366        // Create a view with HAVING — both statuses have cnt=1, so HAVING cnt > 0 keeps both
367        let (result, _) = execute(
368            dir.path(),
369            "CREATE VIEW popular AS SELECT status, COUNT(*) as cnt FROM strategies GROUP BY status HAVING cnt > 0",
370        )
371        .unwrap();
372        assert!(matches!(result, QueryResult::Message(ref m) if m.contains("created")));
373
374        // Query the view to confirm it works
375        let (result, _) = execute(dir.path(), "SELECT * FROM popular").unwrap();
376        if let QueryResult::Rows { rows, columns } = result {
377            assert!(columns.contains(&"status".to_string()));
378            assert!(columns.contains(&"cnt".to_string()));
379            // Both LIVE and DRAFT have count 1, both > 0
380            assert_eq!(rows.len(), 2);
381        } else {
382            panic!("Expected Rows, got {:?}", result);
383        }
384    }
385
386    #[test]
387    fn test_extract_view_query_tab_after_as() {
388        let q = extract_view_query("CREATE VIEW v AS\tSELECT * FROM t").unwrap();
389        assert!(q.starts_with("SELECT"));
390        assert!(q.contains("FROM t"));
391    }
392}