1use 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 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
226pub 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 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 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 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 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 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 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
801pub 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 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 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 let mut updates: Vec<(PathBuf, String, String)> = Vec::new(); 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 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 let mut ref_count = 0;
934 for (filepath, column, _old_val) in &updates {
935 let text = std::fs::read_to_string(filepath)?;
936 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 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 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}