1use 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 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 fs::write(
197 dir.path().join("_mdql.md"),
198 "---\ntype: database\nname: testdb\n---\n",
199 )
200 .unwrap();
201
202 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 #[test]
363 fn test_create_view_with_having() {
364 let dir = make_test_db();
365 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 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 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}