Skip to main content

mdql_core/
api.rs

1//! Object-oriented API for MDQL databases and tables.
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use regex::Regex;
7use std::sync::LazyLock;
8
9use crate::database::{DatabaseConfig, load_database_config};
10use crate::errors::{MdqlError, ValidationError};
11use crate::migrate;
12use crate::model::{Row, Value};
13use crate::parser::parse_file;
14use crate::query_engine::{evaluate, sql_value_to_value};
15use crate::query_parser::*;
16use crate::schema::{FieldType, Schema, MDQL_FILENAME, load_schema};
17use crate::stamp::TIMESTAMP_FIELDS;
18use crate::txn::{TableLock, TableTransaction, atomic_write, recover_journal, with_multi_file_txn};
19use crate::validator::validate_file;
20
21static SLUGIFY_NON_WORD: LazyLock<Regex> =
22    LazyLock::new(|| Regex::new(r"[^\w\s-]").unwrap());
23static SLUGIFY_WHITESPACE: LazyLock<Regex> =
24    LazyLock::new(|| Regex::new(r"[\s_]+").unwrap());
25static SLUGIFY_MULTI_DASH: LazyLock<Regex> =
26    LazyLock::new(|| Regex::new(r"-+").unwrap());
27
28fn normalize_filename(name: &str) -> String {
29    if name.ends_with(".md") { name.to_string() } else { format!("{}.md", name) }
30}
31
32pub fn slugify(text: &str, max_length: usize) -> String {
33    let slug = text.to_lowercase();
34    let slug = slug.trim();
35    let slug = SLUGIFY_NON_WORD.replace_all(&slug, "");
36    let slug = SLUGIFY_WHITESPACE.replace_all(&slug, "-");
37    let slug = SLUGIFY_MULTI_DASH.replace_all(&slug, "-");
38    let slug = slug.trim_matches('-').to_string();
39    if slug.len() > max_length {
40        slug[..max_length].trim_end_matches('-').to_string()
41    } else {
42        slug
43    }
44}
45
46fn write_and_validate(
47    filepath: &Path,
48    content: &str,
49    old_content: Option<&str>,
50    schema: &Schema,
51    table_path: &Path,
52) -> crate::errors::Result<()> {
53    atomic_write(filepath, content)?;
54
55    let parsed = parse_file(filepath, Some(table_path), schema.rules.normalize_numbered_headings)?;
56    let errors = validate_file(&parsed, schema);
57
58    if !errors.is_empty() {
59        if let Some(old) = old_content {
60            atomic_write(filepath, old)?;
61        } else {
62            let _ = std::fs::remove_file(filepath);
63        }
64        let msgs: Vec<String> = errors.iter().map(|e| e.message.clone()).collect();
65        return Err(MdqlError::General(format!("Validation failed: {}", msgs.join("; "))));
66    }
67
68    Ok(())
69}
70
71fn format_yaml_value(value: &Value, field_type: &FieldType) -> String {
72    match (value, field_type) {
73        (Value::String(s), FieldType::String) => format!("\"{}\"", s),
74        (Value::String(s), FieldType::Date) => format!("\"{}\"", s),
75        (Value::String(s), FieldType::DateTime) => format!("\"{}\"", s),
76        (Value::Date(d), _) => format!("\"{}\"", d.format("%Y-%m-%d")),
77        (Value::DateTime(dt), _) => format!("\"{}\"", dt.format("%Y-%m-%dT%H:%M:%S")),
78        (Value::Int(n), _) => n.to_string(),
79        (Value::Float(f), _) => format!("{}", f),
80        (Value::Bool(b), _) => if *b { "true" } else { "false" }.to_string(),
81        (Value::List(items), _) => {
82            if items.is_empty() {
83                "[]".to_string()
84            } else {
85                let list: Vec<String> = items.iter().map(|i| format!("  - {}", i)).collect();
86                format!("\n{}", list.join("\n"))
87            }
88        }
89        (Value::Dict(map), _) => {
90            if map.is_empty() {
91                "{}".to_string()
92            } else {
93                let lines: Vec<String> = map.iter()
94                    .map(|(k, v)| {
95                        let val_str = match v {
96                            Value::String(s) => format!("\"{}\"", s),
97                            Value::Int(n) => n.to_string(),
98                            Value::Float(f) => format!("{}", f),
99                            Value::Bool(b) => b.to_string(),
100                            _ => v.to_display_string(),
101                        };
102                        format!("  {}: {}", k, val_str)
103                    })
104                    .collect();
105                format!("\n{}", lines.join("\n"))
106            }
107        }
108        (Value::Null, _) => "null".to_string(),
109        _ => value.to_display_string(),
110    }
111}
112
113fn serialize_frontmatter(
114    data: &HashMap<String, Value>,
115    schema: &Schema,
116    preserve_created: Option<&str>,
117) -> String {
118    let now = chrono::Local::now().naive_local().format("%Y-%m-%dT%H:%M:%S").to_string();
119
120    let mut fm_lines: Vec<String> = Vec::new();
121
122    for (name, field_def) in &schema.frontmatter {
123        if TIMESTAMP_FIELDS.contains(&name.as_str()) {
124            continue;
125        }
126        if let Some(val) = data.get(name) {
127            let formatted = format_yaml_value(val, &field_def.field_type);
128            let is_multiline_list = matches!(field_def.field_type, FieldType::StringArray) && !matches!(val, Value::List(items) if items.is_empty());
129            let is_multiline_dict = matches!(field_def.field_type, FieldType::Dict) && !matches!(val, Value::Dict(m) if m.is_empty());
130            if is_multiline_list || is_multiline_dict {
131                fm_lines.push(format!("{}:{}", name, formatted));
132            } else {
133                fm_lines.push(format!("{}: {}", name, formatted));
134            }
135        }
136    }
137
138    // Also write any unknown frontmatter keys that were in data
139    for (name, val) in data {
140        if !schema.frontmatter.contains_key(name)
141            && !TIMESTAMP_FIELDS.contains(&name.as_str())
142            && !schema.sections.contains_key(name)
143            && name != "path"
144            && name != "h1"
145        {
146            fm_lines.push(format!("{}: \"{}\"", name, val.to_display_string()));
147        }
148    }
149
150    let created = preserve_created
151        .map(|s| s.to_string())
152        .unwrap_or_else(|| {
153            data.get("created")
154                .map(|v| v.to_display_string())
155                .unwrap_or_else(|| now.clone())
156        });
157    fm_lines.push(format!("created: \"{}\"", created));
158    fm_lines.push(format!("modified: \"{}\"", now));
159
160    format!("---\n{}\n---\n", fm_lines.join("\n"))
161}
162
163fn serialize_body(data: &HashMap<String, Value>, schema: &Schema) -> String {
164    let mut body = String::new();
165
166    if schema.h1_required {
167        let h1_text = if let Some(ref field) = schema.h1_must_equal_frontmatter {
168            data.get(field)
169                .map(|v| v.to_display_string())
170                .unwrap_or_default()
171        } else {
172            data.get("h1")
173                .or_else(|| data.get("title"))
174                .map(|v| v.to_display_string())
175                .unwrap_or_default()
176        };
177        body.push_str(&format!("\n# {}\n", h1_text));
178    }
179
180    for (name, section_def) in &schema.sections {
181        let section_body = data
182            .get(name)
183            .map(|v| v.to_display_string())
184            .unwrap_or_default();
185        if section_def.required || !section_body.is_empty() {
186            body.push_str(&format!("\n## {}\n\n{}\n", name, section_body));
187        }
188    }
189
190    body
191}
192
193fn read_existing(filepath: &Path) -> crate::errors::Result<(HashMap<String, String>, String)> {
194    let text = std::fs::read_to_string(filepath)?;
195    let lines: Vec<&str> = text.split('\n').collect();
196
197    if lines.is_empty() || lines[0].trim() != "---" {
198        return Err(MdqlError::General(format!(
199            "No frontmatter in {}",
200            filepath.file_name().unwrap_or_default().to_string_lossy()
201        )));
202    }
203
204    let mut end_idx = None;
205    for i in 1..lines.len() {
206        if lines[i].trim() == "---" {
207            end_idx = Some(i);
208            break;
209        }
210    }
211
212    let end_idx = end_idx.ok_or_else(|| {
213        MdqlError::General(format!(
214            "Unclosed frontmatter in {}",
215            filepath.file_name().unwrap_or_default().to_string_lossy()
216        ))
217    })?;
218
219    let fm_text = lines[1..end_idx].join("\n");
220    let fm: serde_yaml::Value = serde_yaml::from_str(&fm_text).unwrap_or(serde_yaml::Value::Null);
221    let mut fm_map = HashMap::new();
222    if let Some(mapping) = fm.as_mapping() {
223        for (k, v) in mapping {
224            if let Some(key) = k.as_str() {
225                let val = match v {
226                    serde_yaml::Value::String(s) => s.clone(),
227                    serde_yaml::Value::Number(n) => n.to_string(),
228                    serde_yaml::Value::Bool(b) => b.to_string(),
229                    _ => format!("{:?}", v),
230                };
231                fm_map.insert(key.to_string(), val);
232            }
233        }
234    }
235
236    let raw_body = lines[end_idx + 1..].join("\n");
237    Ok((fm_map, raw_body))
238}
239
240pub fn coerce_cli_value(raw: &str, field_type: &FieldType) -> crate::errors::Result<Value> {
241    match field_type {
242        FieldType::Int => raw
243            .parse::<i64>()
244            .map(Value::Int)
245            .map_err(|e| MdqlError::General(e.to_string())),
246        FieldType::Float => raw
247            .parse::<f64>()
248            .map(Value::Float)
249            .map_err(|e| MdqlError::General(e.to_string())),
250        FieldType::Bool => Ok(Value::Bool(
251            matches!(raw.to_lowercase().as_str(), "true" | "1" | "yes"),
252        )),
253        FieldType::StringArray => Ok(Value::List(
254            raw.split(',').map(|s| s.trim().to_string()).collect(),
255        )),
256        FieldType::Date => {
257            if let Ok(d) = chrono::NaiveDate::parse_from_str(raw, "%Y-%m-%d") {
258                Ok(Value::Date(d))
259            } else {
260                Ok(Value::String(raw.to_string()))
261            }
262        }
263        FieldType::DateTime => {
264            if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S") {
265                Ok(Value::DateTime(dt))
266            } else if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S%.f") {
267                Ok(Value::DateTime(dt))
268            } else {
269                Ok(Value::String(raw.to_string()))
270            }
271        }
272        FieldType::String => Ok(Value::String(raw.to_string())),
273        FieldType::Dict => {
274            match serde_yaml::from_str::<serde_yaml::Value>(raw) {
275                Ok(serde_yaml::Value::Mapping(m)) => {
276                    let mut dict = indexmap::IndexMap::new();
277                    for (k, v) in m {
278                        if let Some(key) = k.as_str() {
279                            let val = crate::model::yaml_to_value_pub(&v, None);
280                            dict.insert(key.to_string(), val);
281                        }
282                    }
283                    Ok(Value::Dict(dict))
284                }
285                _ => Err(MdqlError::General(format!(
286                    "Expected YAML mapping for dict field, got: {}", raw
287                ))),
288            }
289        }
290    }
291}
292
293// ── Table ─────────────────────────────────────────────────────────────────
294
295pub struct Table {
296    pub path: PathBuf,
297    schema: Schema,
298    cache: std::sync::Mutex<crate::cache::TableCache>,
299}
300
301impl Table {
302    pub fn new(path: impl Into<PathBuf>) -> crate::errors::Result<Self> {
303        let path = path.into();
304        recover_journal(&path)?;
305        let schema = load_schema(&path)?;
306        Ok(Table {
307            path,
308            schema,
309            cache: std::sync::Mutex::new(crate::cache::TableCache::new()),
310        })
311    }
312
313    pub fn schema(&self) -> &Schema {
314        &self.schema
315    }
316
317    pub fn name(&self) -> &str {
318        &self.schema.table
319    }
320
321    pub fn insert(
322        &self,
323        data: &HashMap<String, Value>,
324        body: Option<&str>,
325        filename: Option<&str>,
326        replace: bool,
327    ) -> crate::errors::Result<PathBuf> {
328        let fname = match filename {
329            Some(f) => f.to_string(),
330            None => {
331                let title = data
332                    .get("title")
333                    .ok_or_else(|| {
334                        MdqlError::General(
335                            "Cannot derive filename: provide 'title' in data or pass filename"
336                                .into(),
337                        )
338                    })?
339                    .to_display_string();
340                slugify(&title, 80)
341            }
342        };
343
344        let fname = normalize_filename(&fname);
345
346        let filepath = self.path.join(&fname);
347
348        let _lock = TableLock::acquire(&self.path)?;
349
350        let mut preserve_created: Option<String> = None;
351        let mut old_content: Option<String> = None;
352
353        if filepath.exists() {
354            if !replace {
355                return Err(MdqlError::General(format!(
356                    "File already exists: {}",
357                    fname
358                )));
359            }
360            let (old_fm, _) = read_existing(&filepath)?;
361            if let Some(c) = old_fm.get("created") {
362                preserve_created = Some(c.clone());
363            }
364            old_content = Some(std::fs::read_to_string(&filepath)?);
365        }
366
367        let mut content = serialize_frontmatter(
368            data,
369            &self.schema,
370            preserve_created.as_deref(),
371        );
372
373        if let Some(b) = body {
374            if !b.starts_with('\n') {
375                content.push('\n');
376            }
377            content.push_str(b);
378            if !b.ends_with('\n') {
379                content.push('\n');
380            }
381        } else {
382            content.push_str(&serialize_body(data, &self.schema));
383        }
384
385        write_and_validate(&filepath, &content, old_content.as_deref(), &self.schema, &self.path)?;
386
387        self.cache.lock().unwrap().invalidate_all();
388        Ok(filepath)
389    }
390
391    pub fn update(
392        &self,
393        filename: &str,
394        data: &HashMap<String, Value>,
395        body: Option<&str>,
396    ) -> crate::errors::Result<PathBuf> {
397        let _lock = TableLock::acquire(&self.path)?;
398        self.update_no_lock(filename, data, body)
399    }
400
401    fn update_no_lock(
402        &self,
403        filename: &str,
404        data: &HashMap<String, Value>,
405        body: Option<&str>,
406    ) -> crate::errors::Result<PathBuf> {
407        let fname = normalize_filename(filename);
408
409        let filepath = self.path.join(&fname);
410        if !filepath.exists() {
411            return Err(MdqlError::General(format!("File not found: {}", fname)));
412        }
413
414        let old_content = std::fs::read_to_string(&filepath)?;
415        let (old_fm_raw, old_body) = read_existing(&filepath)?;
416
417        // Merge: read existing frontmatter as Value, overlay with data
418        let parsed = parse_file(
419            &filepath,
420            Some(&self.path),
421            self.schema.rules.normalize_numbered_headings,
422        )?;
423        let existing_row = crate::model::to_row(&parsed, &self.schema);
424
425        // Collect section headings so we can exclude them from frontmatter
426        let section_keys: std::collections::HashSet<&str> = parsed
427            .sections
428            .iter()
429            .map(|s| s.normalized_heading.as_str())
430            .collect();
431
432        let mut merged = existing_row;
433        for (k, v) in data {
434            merged.insert(k.clone(), v.clone());
435        }
436
437        // Remove section keys — they belong in the body, not frontmatter
438        merged.retain(|k, _| !section_keys.contains(k.as_str()));
439
440        let preserve_created = old_fm_raw.get("created").map(|s| s.as_str());
441
442        let mut content = serialize_frontmatter(&merged, &self.schema, preserve_created);
443
444        if let Some(b) = body {
445            if !b.starts_with('\n') {
446                content.push('\n');
447            }
448            content.push_str(b);
449            if !b.ends_with('\n') {
450                content.push('\n');
451            }
452        } else {
453            content.push_str(&old_body);
454        }
455
456        write_and_validate(&filepath, &content, Some(&old_content), &self.schema, &self.path)?;
457
458        self.cache.lock().unwrap().invalidate_all();
459        Ok(filepath)
460    }
461
462    pub fn delete(&self, filename: &str) -> crate::errors::Result<PathBuf> {
463        let _lock = TableLock::acquire(&self.path)?;
464        self.delete_no_lock(filename)
465    }
466
467    fn delete_no_lock(&self, filename: &str) -> crate::errors::Result<PathBuf> {
468        let fname = normalize_filename(filename);
469
470        let filepath = self.path.join(&fname);
471        if !filepath.exists() {
472            return Err(MdqlError::General(format!("File not found: {}", fname)));
473        }
474
475        std::fs::remove_file(&filepath)?;
476        self.cache.lock().unwrap().invalidate_all();
477        Ok(filepath)
478    }
479
480    pub fn execute_sql(&mut self, sql: &str) -> crate::errors::Result<String> {
481        let stmt = parse_query(sql)?;
482
483        match stmt {
484            Statement::Select(q) => self.exec_select(&q),
485            Statement::Insert(q) => self.exec_insert(&q),
486            Statement::Update(q) => self.exec_update(&q),
487            Statement::Delete(q) => self.exec_delete(&q),
488            Statement::AlterRename(q) => {
489                let count = self.rename_field(&q.old_name, &q.new_name)?;
490                Ok(format!(
491                    "ALTER TABLE — renamed '{}' to '{}' in {} files",
492                    q.old_name, q.new_name, count
493                ))
494            }
495            Statement::AlterDrop(q) => {
496                let count = self.drop_field(&q.field_name)?;
497                Ok(format!(
498                    "ALTER TABLE — dropped '{}' from {} files",
499                    q.field_name, count
500                ))
501            }
502            Statement::AlterMerge(q) => {
503                let count = self.merge_fields(&q.sources, &q.into)?;
504                let names: Vec<String> = q.sources.iter().map(|s| format!("'{}'", s)).collect();
505                Ok(format!(
506                    "ALTER TABLE — merged {} into '{}' in {} files",
507                    names.join(", "),
508                    q.into,
509                    count
510                ))
511            }
512            Statement::CreateView(_) | Statement::DropView(_) => {
513                Err(MdqlError::QueryExecution(
514                    "CREATE VIEW and DROP VIEW require a database directory, not a single table".into(),
515                ))
516            }
517        }
518    }
519
520    /// Execute a SELECT query and return structured results.
521    pub fn query_sql(&mut self, sql: &str) -> crate::errors::Result<(Vec<Row>, Vec<String>)> {
522        let stmt = parse_query(sql)?;
523        let select = match stmt {
524            Statement::Select(q) => q,
525            _ => return Err(MdqlError::QueryParse("Only SELECT queries supported".into())),
526        };
527        let (_, rows, _) = crate::loader::load_table_cached(&self.path, &mut self.cache.lock().unwrap())?;
528        crate::query_engine::execute_query(&select, &rows, &self.schema)
529    }
530
531    fn exec_select(&self, query: &SelectQuery) -> crate::errors::Result<String> {
532        let (_, rows, _) = crate::loader::load_table_cached(&self.path, &mut self.cache.lock().unwrap())?;
533        let (result_rows, result_columns) = crate::query_engine::execute_query(query, &rows, &self.schema)?;
534        Ok(crate::projector::format_results(
535            &result_rows,
536            Some(&result_columns),
537            "table",
538            0,
539        ))
540    }
541
542    fn exec_insert(&self, query: &InsertQuery) -> crate::errors::Result<String> {
543        let mut data: HashMap<String, Value> = HashMap::new();
544        for (col, val) in query.columns.iter().zip(query.values.iter()) {
545            let field_def = self.schema.frontmatter.get(col);
546            if let Some(fd) = field_def {
547                if matches!(fd.field_type, FieldType::StringArray) {
548                    if let SqlValue::String(s) = val {
549                        data.insert(
550                            col.clone(),
551                            Value::List(s.split(',').map(|v| v.trim().to_string()).collect()),
552                        );
553                        continue;
554                    }
555                }
556            }
557            data.insert(col.clone(), sql_value_to_value(val));
558        }
559        let filepath = self.insert(&data, None, None, false)?;
560        Ok(format!(
561            "INSERT 1 ({})",
562            filepath.file_name().unwrap_or_default().to_string_lossy()
563        ))
564    }
565
566    fn exec_update(&self, query: &UpdateQuery) -> crate::errors::Result<String> {
567        let (_, rows, _) = crate::loader::load_table_cached(&self.path, &mut self.cache.lock().unwrap())?;
568
569        let matching: Vec<&Row> = if let Some(ref wc) = query.where_clause {
570            rows.iter().filter(|r| evaluate(wc, r)).collect()
571        } else {
572            rows.iter().collect()
573        };
574
575        if matching.is_empty() {
576            return Ok("UPDATE 0".to_string());
577        }
578
579        let mut data: HashMap<String, Value> = HashMap::new();
580        for (col, val) in &query.assignments {
581            let field_def = self.schema.frontmatter.get(col);
582            if let Some(fd) = field_def {
583                if matches!(fd.field_type, FieldType::StringArray) {
584                    if let SqlValue::String(s) = val {
585                        data.insert(
586                            col.clone(),
587                            Value::List(s.split(',').map(|v| v.trim().to_string()).collect()),
588                        );
589                        continue;
590                    }
591                }
592            }
593            data.insert(col.clone(), sql_value_to_value(val));
594        }
595
596        let paths: Vec<String> = matching
597            .iter()
598            .filter_map(|r| r.get("path").and_then(|v| v.as_str()).map(|s| s.to_string()))
599            .collect();
600
601        let _lock = TableLock::acquire(&self.path)?;
602        let count;
603        {
604            let mut txn = TableTransaction::new(&self.path, "UPDATE")?;
605            let mut c = 0;
606            for path_str in &paths {
607                let filepath = self.path.join(path_str);
608                txn.backup(&filepath)?;
609                self.update_no_lock(path_str, &data, None)?;
610                c += 1;
611            }
612            count = c;
613            txn.commit()?;
614        }
615
616        Ok(format!("UPDATE {}", count))
617    }
618
619    fn exec_delete(&self, query: &DeleteQuery) -> crate::errors::Result<String> {
620        let (_, rows, _) = crate::loader::load_table_cached(&self.path, &mut self.cache.lock().unwrap())?;
621
622        let matching: Vec<&Row> = if let Some(ref wc) = query.where_clause {
623            rows.iter().filter(|r| evaluate(wc, r)).collect()
624        } else {
625            rows.iter().collect()
626        };
627
628        if matching.is_empty() {
629            return Ok("DELETE 0".to_string());
630        }
631
632        let paths: Vec<String> = matching
633            .iter()
634            .filter_map(|r| r.get("path").and_then(|v| v.as_str()).map(|s| s.to_string()))
635            .collect();
636
637        let _lock = TableLock::acquire(&self.path)?;
638        let count;
639        {
640            let mut txn = TableTransaction::new(&self.path, "DELETE")?;
641            let mut c = 0;
642            for path_str in &paths {
643                let filepath = self.path.join(path_str);
644                let content = std::fs::read_to_string(&filepath)?;
645                txn.record_delete(&filepath, &content)?;
646                self.delete_no_lock(path_str)?;
647                c += 1;
648            }
649            count = c;
650            txn.commit()?;
651        }
652
653        Ok(format!("DELETE {}", count))
654    }
655
656    fn data_files(&self) -> Vec<PathBuf> {
657        let mut files: Vec<PathBuf> = std::fs::read_dir(&self.path)
658            .into_iter()
659            .flatten()
660            .filter_map(|e| e.ok())
661            .map(|e| e.path())
662            .filter(|p| {
663                p.extension().map_or(false, |e| e == "md")
664                    && p.file_name()
665                        .map_or(false, |n| n.to_string_lossy() != MDQL_FILENAME)
666            })
667            .collect();
668        files.sort();
669        files
670    }
671
672    fn field_kind(&self, name: &str) -> crate::errors::Result<&str> {
673        if self.schema.frontmatter.contains_key(name) {
674            return Ok("frontmatter");
675        }
676        if self.schema.sections.contains_key(name) {
677            return Ok("section");
678        }
679        Err(MdqlError::General(format!(
680            "Field '{}' not found in schema (not a frontmatter field or section)",
681            name
682        )))
683    }
684
685    pub fn rename_field(&mut self, old_name: &str, new_name: &str) -> crate::errors::Result<usize> {
686        let kind = self.field_kind(old_name)?.to_string();
687        let normalize = self.schema.rules.normalize_numbered_headings;
688
689        let _lock = TableLock::acquire(&self.path)?;
690        let mut count = 0;
691
692        with_multi_file_txn(
693            &self.path,
694            &format!("RENAME FIELD {} -> {}", old_name, new_name),
695            |txn| {
696                for md_file in self.data_files() {
697                    txn.backup(&md_file)?;
698                    if kind == "frontmatter" {
699                        if migrate::rename_frontmatter_key_in_file(&md_file, old_name, new_name)? {
700                            count += 1;
701                        }
702                    } else {
703                        if migrate::rename_section_in_file(&md_file, old_name, new_name, normalize)? {
704                            count += 1;
705                        }
706                    }
707                }
708
709                let schema_path = self.path.join(MDQL_FILENAME);
710                txn.backup(&schema_path)?;
711                if kind == "frontmatter" {
712                    migrate::update_schema(&schema_path, Some((old_name, new_name)), None, None, None, None)?;
713                } else {
714                    migrate::update_schema(&schema_path, None, None, Some((old_name, new_name)), None, None)?;
715                }
716                Ok(())
717            },
718        )?;
719
720        self.schema = load_schema(&self.path)?;
721        Ok(count)
722    }
723
724    pub fn drop_field(&mut self, field_name: &str) -> crate::errors::Result<usize> {
725        let kind = self.field_kind(field_name)?.to_string();
726        let normalize = self.schema.rules.normalize_numbered_headings;
727
728        let _lock = TableLock::acquire(&self.path)?;
729        let mut count = 0;
730
731        with_multi_file_txn(
732            &self.path,
733            &format!("DROP FIELD {}", field_name),
734            |txn| {
735                for md_file in self.data_files() {
736                    txn.backup(&md_file)?;
737                    if kind == "frontmatter" {
738                        if migrate::drop_frontmatter_key_in_file(&md_file, field_name)? {
739                            count += 1;
740                        }
741                    } else {
742                        if migrate::drop_section_in_file(&md_file, field_name, normalize)? {
743                            count += 1;
744                        }
745                    }
746                }
747
748                let schema_path = self.path.join(MDQL_FILENAME);
749                txn.backup(&schema_path)?;
750                if kind == "frontmatter" {
751                    migrate::update_schema(&schema_path, None, Some(field_name), None, None, None)?;
752                } else {
753                    migrate::update_schema(&schema_path, None, None, None, Some(field_name), None)?;
754                }
755                Ok(())
756            },
757        )?;
758
759        self.schema = load_schema(&self.path)?;
760        Ok(count)
761    }
762
763    pub fn merge_fields(&mut self, sources: &[String], into: &str) -> crate::errors::Result<usize> {
764        for name in sources {
765            let kind = self.field_kind(name)?;
766            if kind != "section" {
767                return Err(MdqlError::General(format!(
768                    "Cannot merge frontmatter field '{}' — merge is only supported for section fields",
769                    name
770                )));
771            }
772        }
773
774        let normalize = self.schema.rules.normalize_numbered_headings;
775        let _lock = TableLock::acquire(&self.path)?;
776        let mut count = 0;
777
778        let sources_owned: Vec<String> = sources.to_vec();
779
780        with_multi_file_txn(
781            &self.path,
782            &format!("MERGE FIELDS -> {}", into),
783            |txn| {
784                for md_file in self.data_files() {
785                    txn.backup(&md_file)?;
786                    if migrate::merge_sections_in_file(&md_file, &sources_owned, into, normalize)? {
787                        count += 1;
788                    }
789                }
790
791                let schema_path = self.path.join(MDQL_FILENAME);
792                txn.backup(&schema_path)?;
793                migrate::update_schema(
794                    &schema_path,
795                    None, None, None, None,
796                    Some((&sources_owned, into)),
797                )?;
798                Ok(())
799            },
800        )?;
801
802        self.schema = load_schema(&self.path)?;
803        Ok(count)
804    }
805
806    pub fn load(&self) -> crate::errors::Result<(Vec<Row>, Vec<ValidationError>)> {
807        let (_, rows, errors) = crate::loader::load_table_cached(
808            &self.path,
809            &mut self.cache.lock().unwrap(),
810        )?;
811        Ok((rows, errors))
812    }
813
814    pub fn validate(&self) -> crate::errors::Result<Vec<ValidationError>> {
815        let (_, _, errors) = crate::loader::load_table_cached(
816            &self.path,
817            &mut self.cache.lock().unwrap(),
818        )?;
819        Ok(errors)
820    }
821}
822
823// ── Database ──────────────────────────────────────────────────────────────
824
825pub struct Database {
826    pub path: PathBuf,
827    config: DatabaseConfig,
828    tables: HashMap<String, Table>,
829}
830
831impl Database {
832    pub fn new(path: impl Into<PathBuf>) -> crate::errors::Result<Self> {
833        let path = path.into();
834        let config = load_database_config(&path)?;
835        let mut tables = HashMap::new();
836
837        let mut children: Vec<_> = std::fs::read_dir(&path)?
838            .filter_map(|e| e.ok())
839            .map(|e| e.path())
840            .filter(|p| p.is_dir() && p.join(MDQL_FILENAME).exists())
841            .collect();
842        children.sort();
843
844        for child in children {
845            let t = Table::new(&child)?;
846            tables.insert(t.name().to_string(), t);
847        }
848
849        Ok(Database {
850            path,
851            config,
852            tables,
853        })
854    }
855
856    pub fn name(&self) -> &str {
857        &self.config.name
858    }
859
860    pub fn config(&self) -> &DatabaseConfig {
861        &self.config
862    }
863
864    pub fn table_names(&self) -> Vec<String> {
865        let mut names: Vec<String> = self.tables.keys().cloned().collect();
866        names.sort();
867        names
868    }
869
870    /// Rename a file and update all foreign key references across the database.
871    pub fn rename(
872        &self,
873        table_name: &str,
874        old_filename: &str,
875        new_filename: &str,
876    ) -> crate::errors::Result<String> {
877        let old_name = normalize_filename(old_filename);
878        let new_name = normalize_filename(new_filename);
879
880        let table = self.tables.get(table_name).ok_or_else(|| {
881            MdqlError::General(format!("Table '{}' not found", table_name))
882        })?;
883
884        let old_path = table.path.join(&old_name);
885        if !old_path.exists() {
886            return Err(MdqlError::General(format!(
887                "File not found: {}/{}",
888                table_name, old_name
889            )));
890        }
891
892        let new_path = table.path.join(&new_name);
893        if new_path.exists() {
894            return Err(MdqlError::General(format!(
895                "Target already exists: {}/{}",
896                table_name, new_name
897            )));
898        }
899
900        // Find all foreign keys that reference this table
901        let referencing_fks: Vec<_> = self
902            .config
903            .foreign_keys
904            .iter()
905            .filter(|fk| fk.to_table == table_name && fk.to_column == "path")
906            .collect();
907
908        // Collect files that need updating
909        let mut updates: Vec<(PathBuf, String, String)> = Vec::new(); // (file_path, column, old_value)
910
911        for fk in &referencing_fks {
912            let ref_table = self.tables.get(&fk.from_table).ok_or_else(|| {
913                MdqlError::General(format!(
914                    "Referencing table '{}' not found",
915                    fk.from_table
916                ))
917            })?;
918
919            // Scan files in the referencing table
920            let entries: Vec<_> = std::fs::read_dir(&ref_table.path)?
921                .filter_map(|e| e.ok())
922                .map(|e| e.path())
923                .filter(|p| {
924                    p.extension().and_then(|e| e.to_str()) == Some("md")
925                        && p.file_name()
926                            .and_then(|n| n.to_str())
927                            .map_or(true, |n| n != MDQL_FILENAME)
928                })
929                .collect();
930
931            for entry in entries {
932                if let Ok((fm, _body)) = read_existing(&entry) {
933                    if let Some(val) = fm.get(&fk.from_column) {
934                        if val == &old_name {
935                            updates.push((
936                                entry,
937                                fk.from_column.clone(),
938                                val.clone(),
939                            ));
940                        }
941                    }
942                }
943            }
944        }
945
946        // Perform all changes: update references first, then rename
947        let mut ref_count = 0;
948        for (filepath, column, _old_val) in &updates {
949            let text = std::fs::read_to_string(filepath)?;
950            // Replace the frontmatter value: "column: old_name" → "column: new_name"
951            let old_pattern = format!("{}: {}", column, old_name);
952            let new_pattern = format!("{}: {}", column, new_name);
953            let updated = text.replacen(&old_pattern, &new_pattern, 1);
954            // Also handle quoted form
955            let old_quoted = format!("{}: \"{}\"", column, old_name);
956            let new_quoted = format!("{}: \"{}\"", column, new_name);
957            let updated = updated.replacen(&old_quoted, &new_quoted, 1);
958            atomic_write(filepath, &updated)?;
959            ref_count += 1;
960        }
961
962        // Rename the file itself
963        std::fs::rename(&old_path, &new_path)?;
964
965        let mut msg = format!("RENAME {}/{} → {}", table_name, old_name, new_name);
966        if ref_count > 0 {
967            msg.push_str(&format!(
968                " — updated {} reference{}",
969                ref_count,
970                if ref_count == 1 { "" } else { "s" }
971            ));
972        }
973        Ok(msg)
974    }
975
976    pub fn table(&mut self, name: &str) -> crate::errors::Result<&mut Table> {
977        if self.tables.contains_key(name) {
978            Ok(self.tables.get_mut(name).expect("key verified above"))
979        } else {
980            let available: Vec<String> = self.tables.keys().cloned().collect();
981            Err(MdqlError::General(format!(
982                "Table '{}' not found in database '{}'. Available: {}",
983                name,
984                self.config.name,
985                if available.is_empty() {
986                    "(none)".to_string()
987                } else {
988                    available.join(", ")
989                }
990            )))
991        }
992    }
993}
994
995#[cfg(test)]
996mod tests {
997    use super::*;
998
999    #[test]
1000    fn test_slugify_basic() {
1001        assert_eq!(slugify("Hello World", 80), "hello-world");
1002    }
1003
1004    #[test]
1005    fn test_slugify_special_chars() {
1006        assert_eq!(slugify("My Strategy: Alpha & Beta!", 80), "my-strategy-alpha-beta");
1007    }
1008
1009    #[test]
1010    fn test_slugify_max_length() {
1011        let result = slugify("a very long title that exceeds the limit", 10);
1012        assert!(result.len() <= 10);
1013        assert!(!result.ends_with('-'));
1014    }
1015
1016    #[test]
1017    fn test_slugify_whitespace() {
1018        assert_eq!(slugify("  hello   world  ", 80), "hello-world");
1019    }
1020
1021    #[test]
1022    fn test_coerce_int() {
1023        let v = coerce_cli_value("42", &FieldType::Int).unwrap();
1024        assert_eq!(v, Value::Int(42));
1025    }
1026
1027    #[test]
1028    fn test_coerce_int_error() {
1029        assert!(coerce_cli_value("abc", &FieldType::Int).is_err());
1030    }
1031
1032    #[test]
1033    fn test_coerce_float() {
1034        let v = coerce_cli_value("3.14", &FieldType::Float).unwrap();
1035        assert_eq!(v, Value::Float(3.14));
1036    }
1037
1038    #[test]
1039    fn test_coerce_bool_true() {
1040        assert_eq!(coerce_cli_value("true", &FieldType::Bool).unwrap(), Value::Bool(true));
1041        assert_eq!(coerce_cli_value("yes", &FieldType::Bool).unwrap(), Value::Bool(true));
1042        assert_eq!(coerce_cli_value("1", &FieldType::Bool).unwrap(), Value::Bool(true));
1043    }
1044
1045    #[test]
1046    fn test_coerce_bool_false() {
1047        assert_eq!(coerce_cli_value("false", &FieldType::Bool).unwrap(), Value::Bool(false));
1048        assert_eq!(coerce_cli_value("no", &FieldType::Bool).unwrap(), Value::Bool(false));
1049    }
1050
1051    #[test]
1052    fn test_coerce_string_array() {
1053        let v = coerce_cli_value("a, b, c", &FieldType::StringArray).unwrap();
1054        assert_eq!(v, Value::List(vec!["a".into(), "b".into(), "c".into()]));
1055    }
1056
1057    #[test]
1058    fn test_coerce_date() {
1059        let v = coerce_cli_value("2026-04-16", &FieldType::Date).unwrap();
1060        assert_eq!(v, Value::Date(chrono::NaiveDate::from_ymd_opt(2026, 4, 16).unwrap()));
1061    }
1062
1063    #[test]
1064    fn test_coerce_datetime() {
1065        let v = coerce_cli_value("2026-04-16T10:30:00", &FieldType::DateTime).unwrap();
1066        match v {
1067            Value::DateTime(dt) => {
1068                assert_eq!(dt.date(), chrono::NaiveDate::from_ymd_opt(2026, 4, 16).unwrap());
1069            }
1070            _ => panic!("expected DateTime"),
1071        }
1072    }
1073
1074    #[test]
1075    fn test_coerce_string() {
1076        let v = coerce_cli_value("hello", &FieldType::String).unwrap();
1077        assert_eq!(v, Value::String("hello".into()));
1078    }
1079
1080    #[test]
1081    fn test_table_new_missing_schema() {
1082        let dir = tempfile::tempdir().unwrap();
1083        let result = Table::new(dir.path());
1084        assert!(result.is_err());
1085    }
1086
1087    #[test]
1088    fn test_table_insert_and_load() {
1089        let dir = tempfile::tempdir().unwrap();
1090        let schema_content = "---\ntype: schema\ntable: test\nprimary_key: path\nfrontmatter:\n  title:\n    type: string\n    required: true\n---\n";
1091        std::fs::write(dir.path().join("_mdql.md"), schema_content).unwrap();
1092
1093        let table = Table::new(dir.path()).unwrap();
1094        let mut data = HashMap::new();
1095        data.insert("title".into(), Value::String("Hello".into()));
1096
1097        let path = table.insert(&data, None, Some("hello"), false).unwrap();
1098        assert!(path.exists());
1099
1100        let (rows, errors) = table.load().unwrap();
1101        assert_eq!(rows.len(), 1);
1102        assert_eq!(rows[0].get("title"), Some(&Value::String("Hello".into())));
1103        assert!(errors.is_empty());
1104    }
1105
1106    #[test]
1107    fn test_table_insert_duplicate_rejected() {
1108        let dir = tempfile::tempdir().unwrap();
1109        let schema_content = "---\ntype: schema\ntable: test\nprimary_key: path\nfrontmatter:\n  title:\n    type: string\n    required: true\n---\n";
1110        std::fs::write(dir.path().join("_mdql.md"), schema_content).unwrap();
1111
1112        let table = Table::new(dir.path()).unwrap();
1113        let mut data = HashMap::new();
1114        data.insert("title".into(), Value::String("Hello".into()));
1115
1116        table.insert(&data, None, Some("hello"), false).unwrap();
1117        let result = table.insert(&data, None, Some("hello"), false);
1118        assert!(result.is_err());
1119    }
1120
1121    #[test]
1122    fn test_table_update() {
1123        let dir = tempfile::tempdir().unwrap();
1124        let schema_content = "---\ntype: schema\ntable: test\nprimary_key: path\nfrontmatter:\n  title:\n    type: string\n    required: true\n  score:\n    type: int\n---\n";
1125        std::fs::write(dir.path().join("_mdql.md"), schema_content).unwrap();
1126
1127        let table = Table::new(dir.path()).unwrap();
1128        let mut data = HashMap::new();
1129        data.insert("title".into(), Value::String("Test".into()));
1130        data.insert("score".into(), Value::Int(10));
1131        table.insert(&data, None, Some("test"), false).unwrap();
1132
1133        let mut update = HashMap::new();
1134        update.insert("score".into(), Value::Int(20));
1135        table.update("test", &update, None).unwrap();
1136
1137        let (rows, _) = table.load().unwrap();
1138        assert_eq!(rows[0].get("score"), Some(&Value::Int(20)));
1139    }
1140
1141    #[test]
1142    fn test_table_delete() {
1143        let dir = tempfile::tempdir().unwrap();
1144        let schema_content = "---\ntype: schema\ntable: test\nprimary_key: path\nfrontmatter:\n  title:\n    type: string\n    required: true\n---\n";
1145        std::fs::write(dir.path().join("_mdql.md"), schema_content).unwrap();
1146
1147        let table = Table::new(dir.path()).unwrap();
1148        let mut data = HashMap::new();
1149        data.insert("title".into(), Value::String("Doomed".into()));
1150        table.insert(&data, None, Some("doomed"), false).unwrap();
1151
1152        table.delete("doomed").unwrap();
1153        let (rows, _) = table.load().unwrap();
1154        assert!(rows.is_empty());
1155    }
1156
1157    #[test]
1158    fn test_table_validate() {
1159        let dir = tempfile::tempdir().unwrap();
1160        let schema_content = "---\ntype: schema\ntable: test\nprimary_key: path\nfrontmatter:\n  title:\n    type: string\n    required: true\n---\n";
1161        std::fs::write(dir.path().join("_mdql.md"), schema_content).unwrap();
1162        std::fs::write(dir.path().join("bad.md"), "---\n---\nNo title field\n").unwrap();
1163
1164        let table = Table::new(dir.path()).unwrap();
1165        let errors = table.validate().unwrap();
1166        assert!(!errors.is_empty());
1167    }
1168}