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