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::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 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
208pub 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 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 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 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 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 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 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
783pub 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 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 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 let mut updates: Vec<(PathBuf, String, String)> = Vec::new(); 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 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 let mut ref_count = 0;
916 for (filepath, column, _old_val) in &updates {
917 let text = std::fs::read_to_string(filepath)?;
918 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 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 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}