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