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