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