Skip to main content

mdql_web/
server.rs

1//! MDQL browser UI — axum server with embedded SPA.
2
3use std::collections::HashMap;
4use std::path::PathBuf;
5use std::sync::{Arc, Mutex};
6
7use axum::extract::{Path as AxumPath, State};
8use axum::http::{header, StatusCode, Uri};
9use axum::response::{IntoResponse, Json};
10use axum::routing::{get, post};
11use axum::Router;
12use rust_embed::Embed;
13use serde::{Deserialize, Serialize};
14use tower_http::cors::CorsLayer;
15
16use mdql_core::api::Table;
17use mdql_core::loader;
18use mdql_core::model::{Row, Value};
19use mdql_core::projector::format_results;
20use mdql_core::query_parser::{parse_query, Statement};
21use mdql_core::schema::Schema;
22
23#[derive(Embed)]
24#[folder = "static/"]
25struct StaticFiles;
26
27#[derive(Clone)]
28struct AppState {
29    db_path: PathBuf,
30    tables: Arc<Mutex<HashMap<String, (Schema, Vec<Row>)>>>,
31    fk_errors: Arc<Mutex<Vec<mdql_core::errors::ValidationError>>>,
32}
33
34#[derive(Serialize)]
35struct TableInfo {
36    name: String,
37    row_count: usize,
38}
39
40#[derive(Serialize)]
41struct TablesResponse {
42    tables: Vec<TableInfo>,
43}
44
45#[derive(Serialize)]
46struct TableDetailResponse {
47    table: String,
48    primary_key: String,
49    row_count: usize,
50    frontmatter: HashMap<String, FieldInfo>,
51    sections: HashMap<String, SectionInfo>,
52}
53
54#[derive(Serialize)]
55struct FieldInfo {
56    #[serde(rename = "type")]
57    field_type: String,
58    required: bool,
59    enum_values: Option<Vec<String>>,
60}
61
62#[derive(Serialize)]
63struct SectionInfo {
64    content_type: String,
65    required: bool,
66}
67
68#[derive(Deserialize)]
69struct QueryRequest {
70    sql: String,
71    #[serde(default = "default_format")]
72    format: String,
73}
74
75fn default_format() -> String {
76    "table".into()
77}
78
79#[derive(Serialize)]
80struct QueryResponse {
81    columns: Option<Vec<String>>,
82    rows: Option<Vec<HashMap<String, serde_json::Value>>>,
83    output: Option<String>,
84    error: Option<String>,
85    row_count: Option<usize>,
86}
87
88pub async fn run_server(db_path: PathBuf, port: u16) {
89    // Load the database
90    let tables = match load_all_tables(&db_path) {
91        Ok(t) => t,
92        Err(e) => {
93            eprintln!("Failed to load database: {}", e);
94            std::process::exit(1);
95        }
96    };
97
98    let fk_errors: Arc<Mutex<Vec<mdql_core::errors::ValidationError>>> =
99        Arc::new(Mutex::new(Vec::new()));
100
101    let state = AppState {
102        db_path: db_path.clone(),
103        tables: Arc::new(Mutex::new(tables)),
104        fk_errors: fk_errors.clone(),
105    };
106
107    // Start filesystem watcher for FK validation
108    {
109        let tables_clone = state.tables.clone();
110        let fk_errors_clone = fk_errors.clone();
111        let db_path_clone = db_path.clone();
112        tokio::task::spawn_blocking(move || {
113            let watcher = match mdql_core::watcher::FkWatcher::start(db_path_clone.clone()) {
114                Ok(w) => w,
115                Err(e) => {
116                    eprintln!("Warning: could not start FK watcher: {}", e);
117                    return;
118                }
119            };
120            loop {
121                if let Some(errors) = watcher.poll() {
122                    *fk_errors_clone.lock().unwrap() = errors;
123                    // Also reload tables on file change
124                    if let Ok(new_tables) = load_all_tables(&db_path_clone) {
125                        *tables_clone.lock().unwrap() = new_tables;
126                    }
127                }
128                std::thread::sleep(std::time::Duration::from_millis(200));
129            }
130        });
131    }
132
133    let app = Router::new()
134        .route("/api/tables", get(list_tables))
135        .route("/api/tables/{name}", get(table_detail))
136        .route("/api/query", post(execute_query))
137        .route("/api/reload", post(reload_tables))
138        .route("/api/fk-errors", get(get_fk_errors))
139        .fallback(static_handler)
140        .layer(CorsLayer::permissive())
141        .with_state(state);
142
143    let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port))
144        .await
145        .expect("Failed to bind");
146
147    println!("MDQL client running at http://localhost:{}", port);
148
149    axum::serve(listener, app).await.expect("Server failed");
150}
151
152fn load_all_tables(db_path: &std::path::Path) -> Result<HashMap<String, (Schema, Vec<Row>)>, String> {
153    // Try as database first
154    if let Ok((_config, tables, _errors)) = loader::load_database(db_path) {
155        return Ok(tables);
156    }
157
158    // Try as single table
159    match loader::load_table(db_path) {
160        Ok((schema, rows, _errors)) => {
161            let mut map = HashMap::new();
162            map.insert(schema.table.clone(), (schema, rows));
163            Ok(map)
164        }
165        Err(e) => Err(format!("Failed to load: {}", e)),
166    }
167}
168
169async fn list_tables(State(state): State<AppState>) -> Json<TablesResponse> {
170    let tables = state.tables.lock().unwrap();
171    let mut infos: Vec<TableInfo> = tables
172        .iter()
173        .map(|(name, (_schema, rows))| TableInfo {
174            name: name.clone(),
175            row_count: rows.len(),
176        })
177        .collect();
178    infos.sort_by(|a, b| a.name.cmp(&b.name));
179    Json(TablesResponse { tables: infos })
180}
181
182async fn table_detail(
183    State(state): State<AppState>,
184    AxumPath(name): AxumPath<String>,
185) -> Result<Json<TableDetailResponse>, StatusCode> {
186    let tables = state.tables.lock().unwrap();
187    let (schema, rows) = tables.get(&name).ok_or(StatusCode::NOT_FOUND)?;
188
189    let frontmatter: HashMap<String, FieldInfo> = schema
190        .frontmatter
191        .iter()
192        .map(|(k, v)| {
193            (
194                k.clone(),
195                FieldInfo {
196                    field_type: format!("{:?}", v.field_type),
197                    required: v.required,
198                    enum_values: v.enum_values.clone(),
199                },
200            )
201        })
202        .collect();
203
204    let sections: HashMap<String, SectionInfo> = schema
205        .sections
206        .iter()
207        .map(|(k, v)| {
208            (
209                k.clone(),
210                SectionInfo {
211                    content_type: v.content_type.clone(),
212                    required: v.required,
213                },
214            )
215        })
216        .collect();
217
218    Ok(Json(TableDetailResponse {
219        table: schema.table.clone(),
220        primary_key: schema.primary_key.clone(),
221        row_count: rows.len(),
222        frontmatter,
223        sections,
224    }))
225}
226
227async fn execute_query(
228    State(state): State<AppState>,
229    Json(req): Json<QueryRequest>,
230) -> Json<QueryResponse> {
231    let tables = state.tables.lock().unwrap();
232
233    // Parse the SQL
234    let stmt = match parse_query(&req.sql) {
235        Ok(s) => s,
236        Err(e) => {
237            return Json(QueryResponse {
238                columns: None,
239                rows: None,
240                output: None,
241                error: Some(format!("Parse error: {}", e)),
242                row_count: None,
243            });
244        }
245    };
246
247    match stmt {
248        Statement::Select(query) => {
249            // Determine table
250            let table_name = &query.table;
251            let (schema, rows) = match tables.get(table_name.as_str()) {
252                Some(t) => t,
253                None => {
254                    return Json(QueryResponse {
255                        columns: None,
256                        rows: None,
257                        output: None,
258                        error: Some(format!("Unknown table '{}'", table_name)),
259                        row_count: None,
260                    });
261                }
262            };
263
264            // Handle JOINs
265            let result = if !query.joins.is_empty() {
266                mdql_core::query_engine::execute_join_query(&query, &tables)
267            } else {
268                mdql_core::query_engine::execute_query(&query, rows, schema)
269            };
270
271            match result {
272                Ok((result_rows, columns)) => {
273                    if req.format == "json" || req.format == "csv" {
274                        let output = format_results(
275                            &result_rows,
276                            Some(&columns),
277                            &req.format,
278                            80,
279                        );
280                        Json(QueryResponse {
281                            columns: None,
282                            rows: None,
283                            output: Some(output),
284                            error: None,
285                            row_count: Some(result_rows.len()),
286                        })
287                    } else {
288                        let json_rows: Vec<HashMap<String, serde_json::Value>> = result_rows
289                            .iter()
290                            .map(|row| {
291                                columns
292                                    .iter()
293                                    .map(|col| {
294                                        let val = row.get(col).unwrap_or(&Value::Null);
295                                        (col.clone(), value_to_json(val))
296                                    })
297                                    .collect()
298                            })
299                            .collect();
300
301                        Json(QueryResponse {
302                            columns: Some(columns),
303                            rows: Some(json_rows.clone()),
304                            output: None,
305                            error: None,
306                            row_count: Some(json_rows.len()),
307                        })
308                    }
309                }
310                Err(e) => Json(QueryResponse {
311                    columns: None,
312                    rows: None,
313                    output: None,
314                    error: Some(e.to_string()),
315                    row_count: None,
316                }),
317            }
318        }
319        _ => {
320            // For write operations, use the Table API
321            drop(tables); // Release lock before write operations
322            let result = execute_write(&state, &req.sql);
323            Json(QueryResponse {
324                columns: None,
325                rows: None,
326                output: Some(result.clone()),
327                error: if result.starts_with("Error") {
328                    Some(result)
329                } else {
330                    None
331                },
332                row_count: None,
333            })
334        }
335    }
336}
337
338fn execute_write(state: &AppState, sql: &str) -> String {
339    // Find the table in the db_path
340    let stmt = match parse_query(sql) {
341        Ok(s) => s,
342        Err(e) => return format!("Error: {}", e),
343    };
344
345    let table_name = match &stmt {
346        Statement::Insert(q) => q.table.clone(),
347        Statement::Update(q) => q.table.clone(),
348        Statement::Delete(q) => q.table.clone(),
349        Statement::AlterRename(q) => q.table.clone(),
350        Statement::AlterDrop(q) => q.table.clone(),
351        Statement::AlterMerge(q) => q.table.clone(),
352        _ => return "Error: unsupported statement type".into(),
353    };
354
355    let table_path = state.db_path.join(&table_name);
356    let mut table = match Table::new(&table_path) {
357        Ok(t) => t,
358        Err(e) => return format!("Error: {}", e),
359    };
360
361    let result = match table.execute_sql(sql) {
362        Ok(s) => s,
363        Err(e) => format!("Error: {}", e),
364    };
365
366    // Reload tables after write
367    if let Ok(new_tables) = load_all_tables(&state.db_path) {
368        let mut tables = state.tables.lock().unwrap();
369        *tables = new_tables;
370    }
371
372    result
373}
374
375async fn get_fk_errors(State(state): State<AppState>) -> Json<serde_json::Value> {
376    let errors = state.fk_errors.lock().unwrap();
377    let error_list: Vec<serde_json::Value> = errors
378        .iter()
379        .map(|e| {
380            serde_json::json!({
381                "file": e.file_path,
382                "field": e.field,
383                "message": e.message,
384            })
385        })
386        .collect();
387    Json(serde_json::json!({ "errors": error_list }))
388}
389
390async fn reload_tables(State(state): State<AppState>) -> Json<serde_json::Value> {
391    match load_all_tables(&state.db_path) {
392        Ok(new_tables) => {
393            let mut tables = state.tables.lock().unwrap();
394            *tables = new_tables;
395            Json(serde_json::json!({ "status": "ok" }))
396        }
397        Err(e) => Json(serde_json::json!({ "status": "error", "message": e })),
398    }
399}
400
401fn value_to_json(val: &Value) -> serde_json::Value {
402    match val {
403        Value::Null => serde_json::Value::Null,
404        Value::String(s) => serde_json::Value::String(s.clone()),
405        Value::Int(n) => serde_json::json!(n),
406        Value::Float(f) => serde_json::json!(f),
407        Value::Bool(b) => serde_json::json!(b),
408        Value::Date(d) => serde_json::Value::String(d.format("%Y-%m-%d").to_string()),
409        Value::List(items) => serde_json::json!(items),
410    }
411}
412
413async fn static_handler(uri: Uri) -> impl IntoResponse {
414    let path = uri.path().trim_start_matches('/');
415    let path = if path.is_empty() { "index.html" } else { path };
416
417    match StaticFiles::get(path) {
418        Some(content) => {
419            let mime = mime_guess::from_path(path).first_or_octet_stream();
420            (
421                StatusCode::OK,
422                [(header::CONTENT_TYPE, mime.as_ref().to_string())],
423                content.data.into_owned(),
424            )
425                .into_response()
426        }
427        None => {
428            // SPA fallback: serve index.html for unknown routes
429            match StaticFiles::get("index.html") {
430                Some(content) => (
431                    StatusCode::OK,
432                    [(header::CONTENT_TYPE, "text/html".to_string())],
433                    content.data.into_owned(),
434                )
435                    .into_response(),
436                None => (StatusCode::NOT_FOUND, "Not found").into_response(),
437            }
438        }
439    }
440}
441