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
28pub fn slugify(text: &str, max_length: usize) -> String {
29    let slug = text.to_lowercase();
30    let slug = slug.trim();
31    let slug = SLUGIFY_NON_WORD.replace_all(&slug, "");
32    let slug = SLUGIFY_WHITESPACE.replace_all(&slug, "-");
33    let slug = SLUGIFY_MULTI_DASH.replace_all(&slug, "-");
34    let slug = slug.trim_matches('-').to_string();
35    if slug.len() > max_length {
36        slug[..max_length].trim_end_matches('-').to_string()
37    } else {
38        slug
39    }
40}
41
42fn format_yaml_value(value: &Value, field_type: &FieldType) -> String {
43    match (value, field_type) {
44        (Value::String(s), FieldType::String) => format!("\"{}\"", s),
45        (Value::String(s), FieldType::Date) => format!("\"{}\"", s),
46        (Value::String(s), FieldType::DateTime) => format!("\"{}\"", s),
47        (Value::Date(d), _) => format!("\"{}\"", d.format("%Y-%m-%d")),
48        (Value::DateTime(dt), _) => format!("\"{}\"", dt.format("%Y-%m-%dT%H:%M:%S")),
49        (Value::Int(n), _) => n.to_string(),
50        (Value::Float(f), _) => format!("{}", f),
51        (Value::Bool(b), _) => if *b { "true" } else { "false" }.to_string(),
52        (Value::List(items), _) => {
53            if items.is_empty() {
54                "[]".to_string()
55            } else {
56                let list: Vec<String> = items.iter().map(|i| format!("  - {}", i)).collect();
57                format!("\n{}", list.join("\n"))
58            }
59        }
60        (Value::Dict(map), _) => {
61            if map.is_empty() {
62                "{}".to_string()
63            } else {
64                let lines: Vec<String> = map.iter()
65                    .map(|(k, v)| {
66                        let val_str = match v {
67                            Value::String(s) => format!("\"{}\"", s),
68                            Value::Int(n) => n.to_string(),
69                            Value::Float(f) => format!("{}", f),
70                            Value::Bool(b) => b.to_string(),
71                            _ => v.to_display_string(),
72                        };
73                        format!("  {}: {}", k, val_str)
74                    })
75                    .collect();
76                format!("\n{}", lines.join("\n"))
77            }
78        }
79        (Value::Null, _) => "null".to_string(),
80        _ => value.to_display_string(),
81    }
82}
83
84fn serialize_frontmatter(
85    data: &HashMap<String, Value>,
86    schema: &Schema,
87    preserve_created: Option<&str>,
88) -> String {
89    let now = chrono::Local::now().naive_local().format("%Y-%m-%dT%H:%M:%S").to_string();
90
91    let mut fm_lines: Vec<String> = Vec::new();
92
93    for (name, field_def) in &schema.frontmatter {
94        if TIMESTAMP_FIELDS.contains(&name.as_str()) {
95            continue;
96        }
97        if let Some(val) = data.get(name) {
98            let formatted = format_yaml_value(val, &field_def.field_type);
99            let is_multiline_list = matches!(field_def.field_type, FieldType::StringArray) && !matches!(val, Value::List(items) if items.is_empty());
100            let is_multiline_dict = matches!(field_def.field_type, FieldType::Dict) && !matches!(val, Value::Dict(m) if m.is_empty());
101            if is_multiline_list || is_multiline_dict {
102                fm_lines.push(format!("{}:{}", name, formatted));
103            } else {
104                fm_lines.push(format!("{}: {}", name, formatted));
105            }
106        }
107    }
108
109    // Also write any unknown frontmatter keys that were in data
110    for (name, val) in data {
111        if !schema.frontmatter.contains_key(name)
112            && !TIMESTAMP_FIELDS.contains(&name.as_str())
113            && !schema.sections.contains_key(name)
114            && name != "path"
115            && name != "h1"
116        {
117            fm_lines.push(format!("{}: \"{}\"", name, val.to_display_string()));
118        }
119    }
120
121    let created = preserve_created
122        .map(|s| s.to_string())
123        .unwrap_or_else(|| {
124            data.get("created")
125                .map(|v| v.to_display_string())
126                .unwrap_or_else(|| now.clone())
127        });
128    fm_lines.push(format!("created: \"{}\"", created));
129    fm_lines.push(format!("modified: \"{}\"", now));
130
131    format!("---\n{}\n---\n", fm_lines.join("\n"))
132}
133
134fn serialize_body(data: &HashMap<String, Value>, schema: &Schema) -> String {
135    let mut body = String::new();
136
137    if schema.h1_required {
138        let h1_text = if let Some(ref field) = schema.h1_must_equal_frontmatter {
139            data.get(field)
140                .map(|v| v.to_display_string())
141                .unwrap_or_default()
142        } else {
143            data.get("h1")
144                .or_else(|| data.get("title"))
145                .map(|v| v.to_display_string())
146                .unwrap_or_default()
147        };
148        body.push_str(&format!("\n# {}\n", h1_text));
149    }
150
151    for (name, section_def) in &schema.sections {
152        let section_body = data
153            .get(name)
154            .map(|v| v.to_display_string())
155            .unwrap_or_default();
156        if section_def.required || !section_body.is_empty() {
157            body.push_str(&format!("\n## {}\n\n{}\n", name, section_body));
158        }
159    }
160
161    body
162}
163
164fn read_existing(filepath: &Path) -> crate::errors::Result<(HashMap<String, String>, String)> {
165    let text = std::fs::read_to_string(filepath)?;
166    let lines: Vec<&str> = text.split('\n').collect();
167
168    if lines.is_empty() || lines[0].trim() != "---" {
169        return Err(MdqlError::General(format!(
170            "No frontmatter in {}",
171            filepath.file_name().unwrap_or_default().to_string_lossy()
172        )));
173    }
174
175    let mut end_idx = None;
176    for i in 1..lines.len() {
177        if lines[i].trim() == "---" {
178            end_idx = Some(i);
179            break;
180        }
181    }
182
183    let end_idx = end_idx.ok_or_else(|| {
184        MdqlError::General(format!(
185            "Unclosed frontmatter in {}",
186            filepath.file_name().unwrap_or_default().to_string_lossy()
187        ))
188    })?;
189
190    let fm_text = lines[1..end_idx].join("\n");
191    let fm: serde_yaml::Value = serde_yaml::from_str(&fm_text).unwrap_or(serde_yaml::Value::Null);
192    let mut fm_map = HashMap::new();
193    if let Some(mapping) = fm.as_mapping() {
194        for (k, v) in mapping {
195            if let Some(key) = k.as_str() {
196                let val = match v {
197                    serde_yaml::Value::String(s) => s.clone(),
198                    serde_yaml::Value::Number(n) => n.to_string(),
199                    serde_yaml::Value::Bool(b) => b.to_string(),
200                    _ => format!("{:?}", v),
201                };
202                fm_map.insert(key.to_string(), val);
203            }
204        }
205    }
206
207    let raw_body = lines[end_idx + 1..].join("\n");
208    Ok((fm_map, raw_body))
209}
210
211pub fn coerce_cli_value(raw: &str, field_type: &FieldType) -> crate::errors::Result<Value> {
212    match field_type {
213        FieldType::Int => raw
214            .parse::<i64>()
215            .map(Value::Int)
216            .map_err(|e| MdqlError::General(e.to_string())),
217        FieldType::Float => raw
218            .parse::<f64>()
219            .map(Value::Float)
220            .map_err(|e| MdqlError::General(e.to_string())),
221        FieldType::Bool => Ok(Value::Bool(
222            matches!(raw.to_lowercase().as_str(), "true" | "1" | "yes"),
223        )),
224        FieldType::StringArray => Ok(Value::List(
225            raw.split(',').map(|s| s.trim().to_string()).collect(),
226        )),
227        FieldType::Date => {
228            if let Ok(d) = chrono::NaiveDate::parse_from_str(raw, "%Y-%m-%d") {
229                Ok(Value::Date(d))
230            } else {
231                Ok(Value::String(raw.to_string()))
232            }
233        }
234        FieldType::DateTime => {
235            if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S") {
236                Ok(Value::DateTime(dt))
237            } else if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S%.f") {
238                Ok(Value::DateTime(dt))
239            } else {
240                Ok(Value::String(raw.to_string()))
241            }
242        }
243        FieldType::String => Ok(Value::String(raw.to_string())),
244        FieldType::Dict => {
245            match serde_yaml::from_str::<serde_yaml::Value>(raw) {
246                Ok(serde_yaml::Value::Mapping(m)) => {
247                    let mut dict = indexmap::IndexMap::new();
248                    for (k, v) in m {
249                        if let Some(key) = k.as_str() {
250                            let val = crate::model::yaml_to_value_pub(&v, None);
251                            dict.insert(key.to_string(), val);
252                        }
253                    }
254                    Ok(Value::Dict(dict))
255                }
256                _ => Err(MdqlError::General(format!(
257                    "Expected YAML mapping for dict field, got: {}", raw
258                ))),
259            }
260        }
261    }
262}
263
264// ── Table ─────────────────────────────────────────────────────────────────
265
266pub struct Table {
267    pub path: PathBuf,
268    schema: Schema,
269    cache: std::sync::Mutex<crate::cache::TableCache>,
270}
271
272impl Table {
273    pub fn new(path: impl Into<PathBuf>) -> crate::errors::Result<Self> {
274        let path = path.into();
275        recover_journal(&path)?;
276        let schema = load_schema(&path)?;
277        Ok(Table {
278            path,
279            schema,
280            cache: std::sync::Mutex::new(crate::cache::TableCache::new()),
281        })
282    }
283
284    pub fn schema(&self) -> &Schema {
285        &self.schema
286    }
287
288    pub fn name(&self) -> &str {
289        &self.schema.table
290    }
291
292    pub fn insert(
293        &self,
294        data: &HashMap<String, Value>,
295        body: Option<&str>,
296        filename: Option<&str>,
297        replace: bool,
298    ) -> crate::errors::Result<PathBuf> {
299        let fname = match filename {
300            Some(f) => f.to_string(),
301            None => {
302                let title = data
303                    .get("title")
304                    .ok_or_else(|| {
305                        MdqlError::General(
306                            "Cannot derive filename: provide 'title' in data or pass filename"
307                                .into(),
308                        )
309                    })?
310                    .to_display_string();
311                slugify(&title, 80)
312            }
313        };
314
315        let fname = if fname.ends_with(".md") {
316            fname
317        } else {
318            format!("{}.md", fname)
319        };
320
321        let filepath = self.path.join(&fname);
322
323        let _lock = TableLock::acquire(&self.path)?;
324
325        let mut preserve_created: Option<String> = None;
326        let mut old_content: Option<String> = None;
327
328        if filepath.exists() {
329            if !replace {
330                return Err(MdqlError::General(format!(
331                    "File already exists: {}",
332                    fname
333                )));
334            }
335            let (old_fm, _) = read_existing(&filepath)?;
336            if let Some(c) = old_fm.get("created") {
337                preserve_created = Some(c.clone());
338            }
339            old_content = Some(std::fs::read_to_string(&filepath)?);
340        }
341
342        let mut content = serialize_frontmatter(
343            data,
344            &self.schema,
345            preserve_created.as_deref(),
346        );
347
348        if let Some(b) = body {
349            if !b.starts_with('\n') {
350                content.push('\n');
351            }
352            content.push_str(b);
353            if !b.ends_with('\n') {
354                content.push('\n');
355            }
356        } else {
357            content.push_str(&serialize_body(data, &self.schema));
358        }
359
360        atomic_write(&filepath, &content)?;
361
362        // Validate — roll back on failure
363        let parsed = parse_file(
364            &filepath,
365            Some(&self.path),
366            self.schema.rules.normalize_numbered_headings,
367        )?;
368        let errors = validate_file(&parsed, &self.schema);
369
370        if !errors.is_empty() {
371            if let Some(ref old) = old_content {
372                atomic_write(&filepath, old)?;
373            } else {
374                let _ = std::fs::remove_file(&filepath);
375            }
376            let msgs: Vec<String> = errors.iter().map(|e| e.message.clone()).collect();
377            return Err(MdqlError::General(format!(
378                "Validation failed: {}",
379                msgs.join("; ")
380            )));
381        }
382
383        self.cache.lock().unwrap().invalidate_all();
384        Ok(filepath)
385    }
386
387    pub fn update(
388        &self,
389        filename: &str,
390        data: &HashMap<String, Value>,
391        body: Option<&str>,
392    ) -> crate::errors::Result<PathBuf> {
393        let _lock = TableLock::acquire(&self.path)?;
394        self.update_no_lock(filename, data, body)
395    }
396
397    fn update_no_lock(
398        &self,
399        filename: &str,
400        data: &HashMap<String, Value>,
401        body: Option<&str>,
402    ) -> crate::errors::Result<PathBuf> {
403        let fname = if filename.ends_with(".md") {
404            filename.to_string()
405        } else {
406            format!("{}.md", filename)
407        };
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        atomic_write(&filepath, &content)?;
457
458        // Validate — roll back
459        let parsed = parse_file(
460            &filepath,
461            Some(&self.path),
462            self.schema.rules.normalize_numbered_headings,
463        )?;
464        let errors = validate_file(&parsed, &self.schema);
465
466        if !errors.is_empty() {
467            atomic_write(&filepath, &old_content)?;
468            let msgs: Vec<String> = errors.iter().map(|e| e.message.clone()).collect();
469            return Err(MdqlError::General(format!(
470                "Validation failed: {}",
471                msgs.join("; ")
472            )));
473        }
474
475        self.cache.lock().unwrap().invalidate_all();
476        Ok(filepath)
477    }
478
479    pub fn delete(&self, filename: &str) -> crate::errors::Result<PathBuf> {
480        let _lock = TableLock::acquire(&self.path)?;
481        self.delete_no_lock(filename)
482    }
483
484    fn delete_no_lock(&self, filename: &str) -> crate::errors::Result<PathBuf> {
485        let fname = if filename.ends_with(".md") {
486            filename.to_string()
487        } else {
488            format!("{}.md", filename)
489        };
490
491        let filepath = self.path.join(&fname);
492        if !filepath.exists() {
493            return Err(MdqlError::General(format!("File not found: {}", fname)));
494        }
495
496        std::fs::remove_file(&filepath)?;
497        self.cache.lock().unwrap().invalidate_all();
498        Ok(filepath)
499    }
500
501    pub fn execute_sql(&mut self, sql: &str) -> crate::errors::Result<String> {
502        let stmt = parse_query(sql)?;
503
504        match stmt {
505            Statement::Select(q) => self.exec_select(&q),
506            Statement::Insert(q) => self.exec_insert(&q),
507            Statement::Update(q) => self.exec_update(&q),
508            Statement::Delete(q) => self.exec_delete(&q),
509            Statement::AlterRename(q) => {
510                let count = self.rename_field(&q.old_name, &q.new_name)?;
511                Ok(format!(
512                    "ALTER TABLE — renamed '{}' to '{}' in {} files",
513                    q.old_name, q.new_name, count
514                ))
515            }
516            Statement::AlterDrop(q) => {
517                let count = self.drop_field(&q.field_name)?;
518                Ok(format!(
519                    "ALTER TABLE — dropped '{}' from {} files",
520                    q.field_name, count
521                ))
522            }
523            Statement::AlterMerge(q) => {
524                let count = self.merge_fields(&q.sources, &q.into)?;
525                let names: Vec<String> = q.sources.iter().map(|s| format!("'{}'", s)).collect();
526                Ok(format!(
527                    "ALTER TABLE — merged {} into '{}' in {} files",
528                    names.join(", "),
529                    q.into,
530                    count
531                ))
532            }
533        }
534    }
535
536    /// Execute a SELECT query and return structured results.
537    pub fn query_sql(&mut self, sql: &str) -> crate::errors::Result<(Vec<Row>, Vec<String>)> {
538        let stmt = parse_query(sql)?;
539        let select = match stmt {
540            Statement::Select(q) => q,
541            _ => return Err(MdqlError::QueryParse("Only SELECT queries supported".into())),
542        };
543        let (_, rows, _) = crate::loader::load_table_cached(&self.path, &mut self.cache.lock().unwrap())?;
544        crate::query_engine::execute_query(&select, &rows, &self.schema)
545    }
546
547    fn exec_select(&self, query: &SelectQuery) -> crate::errors::Result<String> {
548        let (_, rows, _) = crate::loader::load_table_cached(&self.path, &mut self.cache.lock().unwrap())?;
549        let (result_rows, result_columns) = crate::query_engine::execute_query(query, &rows, &self.schema)?;
550        Ok(crate::projector::format_results(
551            &result_rows,
552            Some(&result_columns),
553            "table",
554            0,
555        ))
556    }
557
558    fn exec_insert(&self, query: &InsertQuery) -> crate::errors::Result<String> {
559        let mut data: HashMap<String, Value> = HashMap::new();
560        for (col, val) in query.columns.iter().zip(query.values.iter()) {
561            let field_def = self.schema.frontmatter.get(col);
562            if let Some(fd) = field_def {
563                if matches!(fd.field_type, FieldType::StringArray) {
564                    if let SqlValue::String(s) = val {
565                        data.insert(
566                            col.clone(),
567                            Value::List(s.split(',').map(|v| v.trim().to_string()).collect()),
568                        );
569                        continue;
570                    }
571                }
572            }
573            data.insert(col.clone(), sql_value_to_value(val));
574        }
575        let filepath = self.insert(&data, None, None, false)?;
576        Ok(format!(
577            "INSERT 1 ({})",
578            filepath.file_name().unwrap_or_default().to_string_lossy()
579        ))
580    }
581
582    fn exec_update(&self, query: &UpdateQuery) -> crate::errors::Result<String> {
583        let (_, rows, _) = crate::loader::load_table_cached(&self.path, &mut self.cache.lock().unwrap())?;
584
585        let matching: Vec<&Row> = if let Some(ref wc) = query.where_clause {
586            rows.iter().filter(|r| evaluate(wc, r)).collect()
587        } else {
588            rows.iter().collect()
589        };
590
591        if matching.is_empty() {
592            return Ok("UPDATE 0".to_string());
593        }
594
595        let mut data: HashMap<String, Value> = HashMap::new();
596        for (col, val) in &query.assignments {
597            let field_def = self.schema.frontmatter.get(col);
598            if let Some(fd) = field_def {
599                if matches!(fd.field_type, FieldType::StringArray) {
600                    if let SqlValue::String(s) = val {
601                        data.insert(
602                            col.clone(),
603                            Value::List(s.split(',').map(|v| v.trim().to_string()).collect()),
604                        );
605                        continue;
606                    }
607                }
608            }
609            data.insert(col.clone(), sql_value_to_value(val));
610        }
611
612        let paths: Vec<String> = matching
613            .iter()
614            .filter_map(|r| r.get("path").and_then(|v| v.as_str()).map(|s| s.to_string()))
615            .collect();
616
617        let _lock = TableLock::acquire(&self.path)?;
618        let count;
619        {
620            let mut txn = TableTransaction::new(&self.path, "UPDATE")?;
621            let mut c = 0;
622            for path_str in &paths {
623                let filepath = self.path.join(path_str);
624                txn.backup(&filepath)?;
625                self.update_no_lock(path_str, &data, None)?;
626                c += 1;
627            }
628            count = c;
629            txn.commit()?;
630        }
631
632        Ok(format!("UPDATE {}", count))
633    }
634
635    fn exec_delete(&self, query: &DeleteQuery) -> crate::errors::Result<String> {
636        let (_, rows, _) = crate::loader::load_table_cached(&self.path, &mut self.cache.lock().unwrap())?;
637
638        let matching: Vec<&Row> = if let Some(ref wc) = query.where_clause {
639            rows.iter().filter(|r| evaluate(wc, r)).collect()
640        } else {
641            rows.iter().collect()
642        };
643
644        if matching.is_empty() {
645            return Ok("DELETE 0".to_string());
646        }
647
648        let paths: Vec<String> = matching
649            .iter()
650            .filter_map(|r| r.get("path").and_then(|v| v.as_str()).map(|s| s.to_string()))
651            .collect();
652
653        let _lock = TableLock::acquire(&self.path)?;
654        let count;
655        {
656            let mut txn = TableTransaction::new(&self.path, "DELETE")?;
657            let mut c = 0;
658            for path_str in &paths {
659                let filepath = self.path.join(path_str);
660                let content = std::fs::read_to_string(&filepath)?;
661                txn.record_delete(&filepath, &content)?;
662                self.delete_no_lock(path_str)?;
663                c += 1;
664            }
665            count = c;
666            txn.commit()?;
667        }
668
669        Ok(format!("DELETE {}", count))
670    }
671
672    fn data_files(&self) -> Vec<PathBuf> {
673        let mut files: Vec<PathBuf> = std::fs::read_dir(&self.path)
674            .into_iter()
675            .flatten()
676            .filter_map(|e| e.ok())
677            .map(|e| e.path())
678            .filter(|p| {
679                p.extension().map_or(false, |e| e == "md")
680                    && p.file_name()
681                        .map_or(false, |n| n.to_string_lossy() != MDQL_FILENAME)
682            })
683            .collect();
684        files.sort();
685        files
686    }
687
688    fn field_kind(&self, name: &str) -> crate::errors::Result<&str> {
689        if self.schema.frontmatter.contains_key(name) {
690            return Ok("frontmatter");
691        }
692        if self.schema.sections.contains_key(name) {
693            return Ok("section");
694        }
695        Err(MdqlError::General(format!(
696            "Field '{}' not found in schema (not a frontmatter field or section)",
697            name
698        )))
699    }
700
701    pub fn rename_field(&mut self, old_name: &str, new_name: &str) -> crate::errors::Result<usize> {
702        let kind = self.field_kind(old_name)?.to_string();
703        let normalize = self.schema.rules.normalize_numbered_headings;
704
705        let _lock = TableLock::acquire(&self.path)?;
706        let mut count = 0;
707
708        with_multi_file_txn(
709            &self.path,
710            &format!("RENAME FIELD {} -> {}", old_name, new_name),
711            |txn| {
712                for md_file in self.data_files() {
713                    txn.backup(&md_file)?;
714                    if kind == "frontmatter" {
715                        if migrate::rename_frontmatter_key_in_file(&md_file, old_name, new_name)? {
716                            count += 1;
717                        }
718                    } else {
719                        if migrate::rename_section_in_file(&md_file, old_name, new_name, normalize)? {
720                            count += 1;
721                        }
722                    }
723                }
724
725                let schema_path = self.path.join(MDQL_FILENAME);
726                txn.backup(&schema_path)?;
727                if kind == "frontmatter" {
728                    migrate::update_schema(&schema_path, Some((old_name, new_name)), None, None, None, None)?;
729                } else {
730                    migrate::update_schema(&schema_path, None, None, Some((old_name, new_name)), None, None)?;
731                }
732                Ok(())
733            },
734        )?;
735
736        self.schema = load_schema(&self.path)?;
737        Ok(count)
738    }
739
740    pub fn drop_field(&mut self, field_name: &str) -> crate::errors::Result<usize> {
741        let kind = self.field_kind(field_name)?.to_string();
742        let normalize = self.schema.rules.normalize_numbered_headings;
743
744        let _lock = TableLock::acquire(&self.path)?;
745        let mut count = 0;
746
747        with_multi_file_txn(
748            &self.path,
749            &format!("DROP FIELD {}", field_name),
750            |txn| {
751                for md_file in self.data_files() {
752                    txn.backup(&md_file)?;
753                    if kind == "frontmatter" {
754                        if migrate::drop_frontmatter_key_in_file(&md_file, field_name)? {
755                            count += 1;
756                        }
757                    } else {
758                        if migrate::drop_section_in_file(&md_file, field_name, normalize)? {
759                            count += 1;
760                        }
761                    }
762                }
763
764                let schema_path = self.path.join(MDQL_FILENAME);
765                txn.backup(&schema_path)?;
766                if kind == "frontmatter" {
767                    migrate::update_schema(&schema_path, None, Some(field_name), None, None, None)?;
768                } else {
769                    migrate::update_schema(&schema_path, None, None, None, Some(field_name), None)?;
770                }
771                Ok(())
772            },
773        )?;
774
775        self.schema = load_schema(&self.path)?;
776        Ok(count)
777    }
778
779    pub fn merge_fields(&mut self, sources: &[String], into: &str) -> crate::errors::Result<usize> {
780        for name in sources {
781            let kind = self.field_kind(name)?;
782            if kind != "section" {
783                return Err(MdqlError::General(format!(
784                    "Cannot merge frontmatter field '{}' — merge is only supported for section fields",
785                    name
786                )));
787            }
788        }
789
790        let normalize = self.schema.rules.normalize_numbered_headings;
791        let _lock = TableLock::acquire(&self.path)?;
792        let mut count = 0;
793
794        let sources_owned: Vec<String> = sources.to_vec();
795
796        with_multi_file_txn(
797            &self.path,
798            &format!("MERGE FIELDS -> {}", into),
799            |txn| {
800                for md_file in self.data_files() {
801                    txn.backup(&md_file)?;
802                    if migrate::merge_sections_in_file(&md_file, &sources_owned, into, normalize)? {
803                        count += 1;
804                    }
805                }
806
807                let schema_path = self.path.join(MDQL_FILENAME);
808                txn.backup(&schema_path)?;
809                migrate::update_schema(
810                    &schema_path,
811                    None, None, None, None,
812                    Some((&sources_owned, into)),
813                )?;
814                Ok(())
815            },
816        )?;
817
818        self.schema = load_schema(&self.path)?;
819        Ok(count)
820    }
821
822    pub fn load(&self) -> crate::errors::Result<(Vec<Row>, Vec<ValidationError>)> {
823        let (_, rows, errors) = crate::loader::load_table_cached(
824            &self.path,
825            &mut self.cache.lock().unwrap(),
826        )?;
827        Ok((rows, errors))
828    }
829
830    pub fn validate(&self) -> crate::errors::Result<Vec<ValidationError>> {
831        let (_, _, errors) = crate::loader::load_table_cached(
832            &self.path,
833            &mut self.cache.lock().unwrap(),
834        )?;
835        Ok(errors)
836    }
837}
838
839// ── Database ──────────────────────────────────────────────────────────────
840
841pub struct Database {
842    pub path: PathBuf,
843    config: DatabaseConfig,
844    tables: HashMap<String, Table>,
845}
846
847impl Database {
848    pub fn new(path: impl Into<PathBuf>) -> crate::errors::Result<Self> {
849        let path = path.into();
850        let config = load_database_config(&path)?;
851        let mut tables = HashMap::new();
852
853        let mut children: Vec<_> = std::fs::read_dir(&path)?
854            .filter_map(|e| e.ok())
855            .map(|e| e.path())
856            .filter(|p| p.is_dir() && p.join(MDQL_FILENAME).exists())
857            .collect();
858        children.sort();
859
860        for child in children {
861            let t = Table::new(&child)?;
862            tables.insert(t.name().to_string(), t);
863        }
864
865        Ok(Database {
866            path,
867            config,
868            tables,
869        })
870    }
871
872    pub fn name(&self) -> &str {
873        &self.config.name
874    }
875
876    pub fn config(&self) -> &DatabaseConfig {
877        &self.config
878    }
879
880    pub fn table_names(&self) -> Vec<String> {
881        let mut names: Vec<String> = self.tables.keys().cloned().collect();
882        names.sort();
883        names
884    }
885
886    /// Rename a file and update all foreign key references across the database.
887    pub fn rename(
888        &self,
889        table_name: &str,
890        old_filename: &str,
891        new_filename: &str,
892    ) -> crate::errors::Result<String> {
893        let old_name = if old_filename.ends_with(".md") {
894            old_filename.to_string()
895        } else {
896            format!("{}.md", old_filename)
897        };
898        let new_name = if new_filename.ends_with(".md") {
899            new_filename.to_string()
900        } else {
901            format!("{}.md", new_filename)
902        };
903
904        let table = self.tables.get(table_name).ok_or_else(|| {
905            MdqlError::General(format!("Table '{}' not found", table_name))
906        })?;
907
908        let old_path = table.path.join(&old_name);
909        if !old_path.exists() {
910            return Err(MdqlError::General(format!(
911                "File not found: {}/{}",
912                table_name, old_name
913            )));
914        }
915
916        let new_path = table.path.join(&new_name);
917        if new_path.exists() {
918            return Err(MdqlError::General(format!(
919                "Target already exists: {}/{}",
920                table_name, new_name
921            )));
922        }
923
924        // Find all foreign keys that reference this table
925        let referencing_fks: Vec<_> = self
926            .config
927            .foreign_keys
928            .iter()
929            .filter(|fk| fk.to_table == table_name && fk.to_column == "path")
930            .collect();
931
932        // Collect files that need updating
933        let mut updates: Vec<(PathBuf, String, String)> = Vec::new(); // (file_path, column, old_value)
934
935        for fk in &referencing_fks {
936            let ref_table = self.tables.get(&fk.from_table).ok_or_else(|| {
937                MdqlError::General(format!(
938                    "Referencing table '{}' not found",
939                    fk.from_table
940                ))
941            })?;
942
943            // Scan files in the referencing table
944            let entries: Vec<_> = std::fs::read_dir(&ref_table.path)?
945                .filter_map(|e| e.ok())
946                .map(|e| e.path())
947                .filter(|p| {
948                    p.extension().and_then(|e| e.to_str()) == Some("md")
949                        && p.file_name()
950                            .and_then(|n| n.to_str())
951                            .map_or(true, |n| n != MDQL_FILENAME)
952                })
953                .collect();
954
955            for entry in entries {
956                if let Ok((fm, _body)) = read_existing(&entry) {
957                    if let Some(val) = fm.get(&fk.from_column) {
958                        if val == &old_name {
959                            updates.push((
960                                entry,
961                                fk.from_column.clone(),
962                                val.clone(),
963                            ));
964                        }
965                    }
966                }
967            }
968        }
969
970        // Perform all changes: update references first, then rename
971        let mut ref_count = 0;
972        for (filepath, column, _old_val) in &updates {
973            let text = std::fs::read_to_string(filepath)?;
974            // Replace the frontmatter value: "column: old_name" → "column: new_name"
975            let old_pattern = format!("{}: {}", column, old_name);
976            let new_pattern = format!("{}: {}", column, new_name);
977            let updated = text.replacen(&old_pattern, &new_pattern, 1);
978            // Also handle quoted form
979            let old_quoted = format!("{}: \"{}\"", column, old_name);
980            let new_quoted = format!("{}: \"{}\"", column, new_name);
981            let updated = updated.replacen(&old_quoted, &new_quoted, 1);
982            atomic_write(filepath, &updated)?;
983            ref_count += 1;
984        }
985
986        // Rename the file itself
987        std::fs::rename(&old_path, &new_path)?;
988
989        let mut msg = format!("RENAME {}/{} → {}", table_name, old_name, new_name);
990        if ref_count > 0 {
991            msg.push_str(&format!(
992                " — updated {} reference{}",
993                ref_count,
994                if ref_count == 1 { "" } else { "s" }
995            ));
996        }
997        Ok(msg)
998    }
999
1000    pub fn table(&mut self, name: &str) -> crate::errors::Result<&mut Table> {
1001        if !self.tables.contains_key(name) {
1002            let available: Vec<String> = self.tables.keys().cloned().collect();
1003            return Err(MdqlError::General(format!(
1004                "Table '{}' not found in database '{}'. Available: {}",
1005                name,
1006                self.config.name,
1007                if available.is_empty() {
1008                    "(none)".to_string()
1009                } else {
1010                    available.join(", ")
1011                }
1012            )));
1013        }
1014        Ok(self.tables.get_mut(name).unwrap())
1015    }
1016}