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.joins.is_empty() || is_db {
25                let (_config, tables, errors) = crate::loader::load_database(path)?;
26                let (rows, cols) = if !q.joins.is_empty() {
27                    execute_join_query(q, &tables)?
28                } else {
29                    let (schema, rows) = tables.get(&q.table).ok_or_else(|| {
30                        MdqlError::QueryExecution(format!(
31                            "table '{}' not found in database",
32                            q.table
33                        ))
34                    })?;
35                    execute_query(q, rows, schema)?
36                };
37                Ok((QueryResult::Rows { rows, columns: cols }, errors))
38            } else {
39                let (schema, rows, errors) = crate::loader::load_table(path)?;
40                let (rows, cols) = execute_query(q, &rows, &schema)?;
41                Ok((QueryResult::Rows { rows, columns: cols }, errors))
42            }
43        }
44        Statement::CreateView(ref cv) => {
45            if !is_db {
46                return Err(MdqlError::QueryExecution(
47                    "CREATE VIEW requires a database directory".into(),
48                ));
49            }
50            let mut config = load_database_config(path)?;
51
52            let (_config_check, tables, _errors) = crate::loader::load_database(path)?;
53            if tables.contains_key(&cv.view_name) {
54                return Err(MdqlError::QueryExecution(format!(
55                    "Name '{}' already exists as a table or view",
56                    cv.view_name
57                )));
58            }
59
60            if config.views.iter().any(|v| v.name == cv.view_name) {
61                return Err(MdqlError::QueryExecution(format!(
62                    "View '{}' already exists",
63                    cv.view_name
64                )));
65            }
66
67            let query_str = sql
68                .to_uppercase()
69                .find(" AS ")
70                .map(|pos| sql[pos + 4..].trim().to_string())
71                .ok_or_else(|| {
72                    MdqlError::QueryExecution("CREATE VIEW must contain AS clause".into())
73                })?;
74
75            let view_def = ViewDef {
76                name: cv.view_name.clone(),
77                query: query_str,
78            };
79
80            let test_result = crate::loader::load_database(path);
81            if let Ok((_cfg, test_tables, _errs)) = test_result {
82                let test_view = ViewDef {
83                    name: view_def.name.clone(),
84                    query: view_def.query.clone(),
85                };
86                if let Err(e) = super::loader::materialize_view(&test_view, &test_tables) {
87                    return Err(MdqlError::QueryExecution(format!(
88                        "View query failed validation: {}",
89                        e
90                    )));
91                }
92            }
93
94            config.views.push(view_def);
95            save_database_config(path, &config)?;
96            Ok((
97                QueryResult::Message(format!("View '{}' created", cv.view_name)),
98                vec![],
99            ))
100        }
101        Statement::DropView(ref dv) => {
102            if !is_db {
103                return Err(MdqlError::QueryExecution(
104                    "DROP VIEW requires a database directory".into(),
105                ));
106            }
107            let mut config = load_database_config(path)?;
108            let len_before = config.views.len();
109            config.views.retain(|v| v.name != dv.view_name);
110            if config.views.len() == len_before {
111                return Err(MdqlError::QueryExecution(format!(
112                    "View '{}' does not exist",
113                    dv.view_name
114                )));
115            }
116            save_database_config(path, &config)?;
117            Ok((
118                QueryResult::Message(format!("View '{}' dropped", dv.view_name)),
119                vec![],
120            ))
121        }
122        ref stmt @ (Statement::Insert(_)
123        | Statement::Update(_)
124        | Statement::Delete(_)
125        | Statement::AlterRename(_)
126        | Statement::AlterDrop(_)
127        | Statement::AlterMerge(_)) => {
128            if is_db {
129                let config = load_database_config(path)?;
130                let target = stmt.table_name();
131                if config.views.iter().any(|v| v.name == target) {
132                    return Err(MdqlError::QueryExecution(format!(
133                        "Cannot write to view '{}' — views are read-only",
134                        target
135                    )));
136                }
137            }
138            let table_path = if is_db {
139                path.join(stmt.table_name())
140            } else {
141                path.to_path_buf()
142            };
143            let mut table = Table::new(&table_path)?;
144            let msg = table.execute_sql(sql)?;
145            Ok((QueryResult::Message(msg), vec![]))
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use std::fs;
154
155    fn make_test_db() -> tempfile::TempDir {
156        let dir = tempfile::tempdir().unwrap();
157
158        // Database-level _mdql.md
159        fs::write(
160            dir.path().join("_mdql.md"),
161            "---\ntype: database\nname: testdb\n---\n",
162        )
163        .unwrap();
164
165        // Table: strategies
166        let strats = dir.path().join("strategies");
167        fs::create_dir(&strats).unwrap();
168        fs::write(
169            strats.join("_mdql.md"),
170            "---\ntype: schema\ntable: strategies\nprimary_key: path\nfrontmatter:\n  title:\n    type: string\n  status:\n    type: string\n---\n",
171        )
172        .unwrap();
173        fs::write(
174            strats.join("alpha.md"),
175            "---\ntitle: Alpha\nstatus: LIVE\n---\n# Alpha\n",
176        )
177        .unwrap();
178        fs::write(
179            strats.join("beta.md"),
180            "---\ntitle: Beta\nstatus: DRAFT\n---\n# Beta\n",
181        )
182        .unwrap();
183
184        dir
185    }
186
187    #[test]
188    fn test_create_and_query_view() {
189        let dir = make_test_db();
190        let (result, _) = execute(
191            dir.path(),
192            "CREATE VIEW live AS SELECT * FROM strategies WHERE status = 'LIVE'",
193        )
194        .unwrap();
195        assert!(matches!(result, QueryResult::Message(ref m) if m.contains("created")));
196
197        let (result, _) = execute(dir.path(), "SELECT * FROM live").unwrap();
198        if let QueryResult::Rows { rows, columns } = result {
199            assert_eq!(rows.len(), 1);
200            assert!(columns.contains(&"title".to_string()));
201        } else {
202            panic!("Expected Rows");
203        }
204    }
205
206    #[test]
207    fn test_drop_view() {
208        let dir = make_test_db();
209        execute(
210            dir.path(),
211            "CREATE VIEW live AS SELECT * FROM strategies WHERE status = 'LIVE'",
212        )
213        .unwrap();
214
215        let (result, _) = execute(dir.path(), "DROP VIEW live").unwrap();
216        assert!(matches!(result, QueryResult::Message(ref m) if m.contains("dropped")));
217
218        let err = execute(dir.path(), "SELECT * FROM live");
219        assert!(err.is_err());
220    }
221
222    #[test]
223    fn test_drop_nonexistent_view() {
224        let dir = make_test_db();
225        let err = execute(dir.path(), "DROP VIEW nonexistent");
226        assert!(err.is_err());
227        assert!(err.unwrap_err().to_string().contains("does not exist"));
228    }
229
230    #[test]
231    fn test_create_view_duplicate_name() {
232        let dir = make_test_db();
233        execute(
234            dir.path(),
235            "CREATE VIEW live AS SELECT * FROM strategies WHERE status = 'LIVE'",
236        )
237        .unwrap();
238
239        let err = execute(
240            dir.path(),
241            "CREATE VIEW live AS SELECT * FROM strategies",
242        );
243        assert!(err.is_err());
244        assert!(err.unwrap_err().to_string().contains("already exists"));
245    }
246
247    #[test]
248    fn test_create_view_conflicts_with_table() {
249        let dir = make_test_db();
250        let err = execute(
251            dir.path(),
252            "CREATE VIEW strategies AS SELECT * FROM strategies",
253        );
254        assert!(err.is_err());
255        assert!(err.unwrap_err().to_string().contains("already exists"));
256    }
257
258    #[test]
259    fn test_write_to_view_rejected() {
260        let dir = make_test_db();
261        execute(
262            dir.path(),
263            "CREATE VIEW live AS SELECT * FROM strategies WHERE status = 'LIVE'",
264        )
265        .unwrap();
266
267        let err = execute(
268            dir.path(),
269            "INSERT INTO live (title, status) VALUES ('Gamma', 'LIVE')",
270        );
271        assert!(err.is_err());
272        assert!(err.unwrap_err().to_string().contains("read-only"));
273    }
274
275    #[test]
276    fn test_create_view_not_database() {
277        let dir = tempfile::tempdir().unwrap();
278        fs::write(
279            dir.path().join("_mdql.md"),
280            "---\ntype: schema\ntable: t\nprimary_key: path\nfrontmatter:\n  x:\n    type: string\n---\n",
281        )
282        .unwrap();
283
284        let err = execute(
285            dir.path(),
286            "CREATE VIEW v AS SELECT * FROM t",
287        );
288        assert!(err.is_err());
289        assert!(err.unwrap_err().to_string().contains("database directory"));
290    }
291}