1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use regex::Regex;
7use std::sync::LazyLock;
8
9use crate::checksums;
10use crate::database::{DatabaseConfig, load_database_config};
11use crate::errors::{MdqlError, ValidationError};
12use crate::migrate;
13use crate::model::{Row, Value};
14
15pub use crate::migrate::{
16 rename_frontmatter_key_in_file, drop_frontmatter_key_in_file,
17 rename_section_in_file, drop_section_in_file,
18 merge_sections_in_file, update_schema,
19};
20use crate::parser::parse_file;
21use crate::query_engine::{evaluate, sql_value_to_value};
22use crate::query_parser::*;
23use crate::schema::{FieldType, Schema, MDQL_FILENAME, load_schema};
24use crate::stamp::TIMESTAMP_FIELDS;
25use crate::txn::{TableLock, TableTransaction, atomic_write, recover_journal, with_multi_file_txn};
26use crate::validator::validate_file;
27
28static SLUGIFY_NON_WORD: LazyLock<Regex> =
29 LazyLock::new(|| Regex::new(r"[^\w\s-]").unwrap());
30static SLUGIFY_WHITESPACE: LazyLock<Regex> =
31 LazyLock::new(|| Regex::new(r"[\s_]+").unwrap());
32static SLUGIFY_MULTI_DASH: LazyLock<Regex> =
33 LazyLock::new(|| Regex::new(r"-+").unwrap());
34
35fn normalize_filename(name: &str) -> String {
36 if name.ends_with(".md") { name.to_string() } else { format!("{}.md", name) }
37}
38
39pub fn slugify(text: &str, max_length: usize) -> String {
40 let slug = text.to_lowercase();
41 let slug = slug.trim();
42 let slug = SLUGIFY_NON_WORD.replace_all(&slug, "");
43 let slug = SLUGIFY_WHITESPACE.replace_all(&slug, "-");
44 let slug = SLUGIFY_MULTI_DASH.replace_all(&slug, "-");
45 let slug = slug.trim_matches('-').to_string();
46 if slug.len() > max_length {
47 slug[..max_length].trim_end_matches('-').to_string()
48 } else {
49 slug
50 }
51}
52
53fn write_and_validate(
54 filepath: &Path,
55 content: &str,
56 old_content: Option<&str>,
57 schema: &Schema,
58 table_path: &Path,
59) -> crate::errors::Result<()> {
60 atomic_write(filepath, content)?;
61
62 let parsed = parse_file(filepath, Some(table_path), schema.rules.normalize_numbered_headings)?;
63 let errors = validate_file(&parsed, schema);
64
65 if !errors.is_empty() {
66 if let Some(old) = old_content {
67 atomic_write(filepath, old)?;
68 } else {
69 let _ = std::fs::remove_file(filepath);
70 }
71 let msgs: Vec<String> = errors.iter().map(|e| e.message.clone()).collect();
72 return Err(MdqlError::General(format!("Validation failed: {}", msgs.join("; "))));
73 }
74
75 if checksums::load_checksums(table_path).is_some() {
76 if let Some(fname) = filepath.file_name().and_then(|f| f.to_str()) {
77 checksums::update_checksum(table_path, fname, content.as_bytes())?;
78 }
79 }
80
81 Ok(())
82}
83
84fn format_yaml_value(value: &Value, field_type: &FieldType) -> String {
85 match (value, field_type) {
86 (Value::String(s), FieldType::String) => format!("\"{}\"", s),
87 (Value::String(s), FieldType::Date) => format!("\"{}\"", s),
88 (Value::String(s), FieldType::DateTime) => format!("\"{}\"", s),
89 (Value::Date(d), _) => format!("\"{}\"", d.format("%Y-%m-%d")),
90 (Value::DateTime(dt), _) => format!("\"{}\"", dt.format("%Y-%m-%dT%H:%M:%S")),
91 (Value::Int(n), _) => n.to_string(),
92 (Value::Float(f), _) => format!("{}", f),
93 (Value::Bool(b), _) => if *b { "true" } else { "false" }.to_string(),
94 (Value::List(items), _) => {
95 if items.is_empty() {
96 "[]".to_string()
97 } else {
98 let list: Vec<String> = items.iter().map(|i| format!(" - {}", i)).collect();
99 format!("\n{}", list.join("\n"))
100 }
101 }
102 (Value::Dict(map), _) => {
103 if map.is_empty() {
104 "{}".to_string()
105 } else {
106 let lines: Vec<String> = map.iter()
107 .map(|(k, v)| {
108 let val_str = match v {
109 Value::String(s) => format!("\"{}\"", s),
110 Value::Int(n) => n.to_string(),
111 Value::Float(f) => format!("{}", f),
112 Value::Bool(b) => b.to_string(),
113 _ => v.to_display_string(),
114 };
115 format!(" {}: {}", k, val_str)
116 })
117 .collect();
118 format!("\n{}", lines.join("\n"))
119 }
120 }
121 (Value::Null, _) => "null".to_string(),
122 _ => value.to_display_string(),
123 }
124}
125
126fn serialize_frontmatter(
127 data: &HashMap<String, Value>,
128 schema: &Schema,
129 preserve_created: Option<&str>,
130) -> String {
131 let now = chrono::Local::now().naive_local().format("%Y-%m-%dT%H:%M:%S").to_string();
132
133 let mut fm_lines: Vec<String> = Vec::new();
134
135 for (name, field_def) in &schema.frontmatter {
136 if TIMESTAMP_FIELDS.contains(&name.as_str()) {
137 continue;
138 }
139 if let Some(val) = data.get(name) {
140 let formatted = format_yaml_value(val, &field_def.field_type);
141 let is_multiline_list = matches!(field_def.field_type, FieldType::StringArray) && !matches!(val, Value::List(items) if items.is_empty());
142 let is_multiline_dict = matches!(field_def.field_type, FieldType::Dict) && !matches!(val, Value::Dict(m) if m.is_empty());
143 if is_multiline_list || is_multiline_dict {
144 fm_lines.push(format!("{}:{}", name, formatted));
145 } else {
146 fm_lines.push(format!("{}: {}", name, formatted));
147 }
148 }
149 }
150
151 for (name, val) in data {
153 if !schema.frontmatter.contains_key(name)
154 && !TIMESTAMP_FIELDS.contains(&name.as_str())
155 && !schema.sections.contains_key(name)
156 && name != "path"
157 && name != "h1"
158 {
159 fm_lines.push(format!("{}: \"{}\"", name, val.to_display_string()));
160 }
161 }
162
163 let created = preserve_created
164 .map(|s| s.to_string())
165 .unwrap_or_else(|| {
166 data.get("created")
167 .map(|v| v.to_display_string())
168 .unwrap_or_else(|| now.clone())
169 });
170 fm_lines.push(format!("created: \"{}\"", created));
171 fm_lines.push(format!("modified: \"{}\"", now));
172
173 format!("---\n{}\n---\n", fm_lines.join("\n"))
174}
175
176fn serialize_body(data: &HashMap<String, Value>, schema: &Schema) -> String {
177 let mut body = String::new();
178
179 if schema.h1_required {
180 let h1_text = if let Some(ref field) = schema.h1_must_equal_frontmatter {
181 data.get(field)
182 .map(|v| v.to_display_string())
183 .unwrap_or_default()
184 } else {
185 data.get("h1")
186 .or_else(|| data.get("title"))
187 .map(|v| v.to_display_string())
188 .unwrap_or_default()
189 };
190 body.push_str(&format!("\n# {}\n", h1_text));
191 }
192
193 for (name, section_def) in &schema.sections {
194 let section_body = data
195 .get(name)
196 .map(|v| v.to_display_string())
197 .unwrap_or_default();
198 if section_def.required || !section_body.is_empty() {
199 body.push_str(&format!("\n## {}\n\n{}\n", name, section_body));
200 }
201 }
202
203 body
204}
205
206fn read_existing(filepath: &Path) -> crate::errors::Result<(HashMap<String, String>, String)> {
207 let text = std::fs::read_to_string(filepath)?;
208 let lines: Vec<&str> = text.split('\n').collect();
209
210 if lines.is_empty() || lines[0].trim() != "---" {
211 return Err(MdqlError::General(format!(
212 "No frontmatter in {}",
213 filepath.file_name().unwrap_or_default().to_string_lossy()
214 )));
215 }
216
217 let mut end_idx = None;
218 for i in 1..lines.len() {
219 if lines[i].trim() == "---" {
220 end_idx = Some(i);
221 break;
222 }
223 }
224
225 let end_idx = end_idx.ok_or_else(|| {
226 MdqlError::General(format!(
227 "Unclosed frontmatter in {}",
228 filepath.file_name().unwrap_or_default().to_string_lossy()
229 ))
230 })?;
231
232 let fm_text = lines[1..end_idx].join("\n");
233 let fm: serde_yaml::Value = serde_yaml::from_str(&fm_text).unwrap_or(serde_yaml::Value::Null);
234 let mut fm_map = HashMap::new();
235 if let Some(mapping) = fm.as_mapping() {
236 for (k, v) in mapping {
237 if let Some(key) = k.as_str() {
238 let val = match v {
239 serde_yaml::Value::String(s) => s.clone(),
240 serde_yaml::Value::Number(n) => n.to_string(),
241 serde_yaml::Value::Bool(b) => b.to_string(),
242 _ => format!("{:?}", v),
243 };
244 fm_map.insert(key.to_string(), val);
245 }
246 }
247 }
248
249 let raw_body = lines[end_idx + 1..].join("\n");
250 Ok((fm_map, raw_body))
251}
252
253pub fn coerce_cli_value(raw: &str, field_type: &FieldType) -> crate::errors::Result<Value> {
254 match field_type {
255 FieldType::Int => raw
256 .parse::<i64>()
257 .map(Value::Int)
258 .map_err(|e| MdqlError::General(e.to_string())),
259 FieldType::Float => raw
260 .parse::<f64>()
261 .map(Value::Float)
262 .map_err(|e| MdqlError::General(e.to_string())),
263 FieldType::Bool => Ok(Value::Bool(
264 matches!(raw.to_lowercase().as_str(), "true" | "1" | "yes"),
265 )),
266 FieldType::StringArray => Ok(Value::List(
267 raw.split(',').map(|s| s.trim().to_string()).collect(),
268 )),
269 FieldType::Date => {
270 if let Ok(d) = chrono::NaiveDate::parse_from_str(raw, "%Y-%m-%d") {
271 Ok(Value::Date(d))
272 } else {
273 Ok(Value::String(raw.to_string()))
274 }
275 }
276 FieldType::DateTime => {
277 if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S") {
278 Ok(Value::DateTime(dt))
279 } else if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S%.f") {
280 Ok(Value::DateTime(dt))
281 } else {
282 Ok(Value::String(raw.to_string()))
283 }
284 }
285 FieldType::String => Ok(Value::String(raw.to_string())),
286 FieldType::Dict => {
287 match serde_yaml::from_str::<serde_yaml::Value>(raw) {
288 Ok(serde_yaml::Value::Mapping(m)) => {
289 let mut dict = indexmap::IndexMap::new();
290 for (k, v) in m {
291 if let Some(key) = k.as_str() {
292 let val = crate::model::yaml_to_value_pub(&v, None);
293 dict.insert(key.to_string(), val);
294 }
295 }
296 Ok(Value::Dict(dict))
297 }
298 _ => Err(MdqlError::General(format!(
299 "Expected YAML mapping for dict field, got: {}", raw
300 ))),
301 }
302 }
303 }
304}
305
306pub struct Table {
309 pub path: PathBuf,
310 schema: Schema,
311 cache: std::sync::Mutex<crate::cache::TableCache>,
312}
313
314impl Table {
315 pub fn new(path: impl Into<PathBuf>) -> crate::errors::Result<Self> {
316 let path = path.into();
317 recover_journal(&path)?;
318 let schema = load_schema(&path)?;
319 Ok(Table {
320 path,
321 schema,
322 cache: std::sync::Mutex::new(crate::cache::TableCache::new()),
323 })
324 }
325
326 pub fn schema(&self) -> &Schema {
327 &self.schema
328 }
329
330 pub fn name(&self) -> &str {
331 &self.schema.table
332 }
333
334 pub fn insert(
335 &self,
336 data: &HashMap<String, Value>,
337 body: Option<&str>,
338 filename: Option<&str>,
339 replace: bool,
340 ) -> crate::errors::Result<PathBuf> {
341 let fname = match filename {
342 Some(f) => f.to_string(),
343 None => {
344 let title = data
345 .get("title")
346 .ok_or_else(|| {
347 MdqlError::General(
348 "Cannot derive filename: provide 'title' in data or pass filename"
349 .into(),
350 )
351 })?
352 .to_display_string();
353 slugify(&title, 80)
354 }
355 };
356
357 let fname = normalize_filename(&fname);
358
359 let filepath = self.path.join(&fname);
360
361 let _lock = TableLock::acquire(&self.path)?;
362
363 let mut preserve_created: Option<String> = None;
364 let mut old_content: Option<String> = None;
365
366 if filepath.exists() {
367 if !replace {
368 return Err(MdqlError::General(format!(
369 "File already exists: {}",
370 fname
371 )));
372 }
373 let (old_fm, _) = read_existing(&filepath)?;
374 if let Some(c) = old_fm.get("created") {
375 preserve_created = Some(c.clone());
376 }
377 old_content = Some(std::fs::read_to_string(&filepath)?);
378 }
379
380 let mut content = serialize_frontmatter(
381 data,
382 &self.schema,
383 preserve_created.as_deref(),
384 );
385
386 if let Some(b) = body {
387 if !b.starts_with('\n') {
388 content.push('\n');
389 }
390 content.push_str(b);
391 if !b.ends_with('\n') {
392 content.push('\n');
393 }
394 } else {
395 content.push_str(&serialize_body(data, &self.schema));
396 }
397
398 write_and_validate(&filepath, &content, old_content.as_deref(), &self.schema, &self.path)?;
399
400 self.cache.lock().unwrap().invalidate_all();
401 Ok(filepath)
402 }
403
404 pub fn update(
405 &self,
406 filename: &str,
407 data: &HashMap<String, Value>,
408 body: Option<&str>,
409 ) -> crate::errors::Result<PathBuf> {
410 let _lock = TableLock::acquire(&self.path)?;
411 self.update_no_lock(filename, data, body)
412 }
413
414 fn update_no_lock(
415 &self,
416 filename: &str,
417 data: &HashMap<String, Value>,
418 body: Option<&str>,
419 ) -> crate::errors::Result<PathBuf> {
420 let fname = normalize_filename(filename);
421
422 let filepath = self.path.join(&fname);
423 if !filepath.exists() {
424 return Err(MdqlError::General(format!("File not found: {}", fname)));
425 }
426
427 let old_content = std::fs::read_to_string(&filepath)?;
428 let (old_fm_raw, old_body) = read_existing(&filepath)?;
429
430 let parsed = parse_file(
432 &filepath,
433 Some(&self.path),
434 self.schema.rules.normalize_numbered_headings,
435 )?;
436 let existing_row = crate::model::to_row(&parsed, &self.schema);
437
438 let section_keys: std::collections::HashSet<&str> = parsed
440 .sections
441 .iter()
442 .map(|s| s.normalized_heading.as_str())
443 .collect();
444
445 let mut merged = existing_row;
446 for (k, v) in data {
447 merged.insert(k.clone(), v.clone());
448 }
449
450 merged.retain(|k, _| !section_keys.contains(k.as_str()));
452
453 let preserve_created = old_fm_raw.get("created").map(|s| s.as_str());
454
455 let mut content = serialize_frontmatter(&merged, &self.schema, preserve_created);
456
457 if let Some(b) = body {
458 if !b.starts_with('\n') {
459 content.push('\n');
460 }
461 content.push_str(b);
462 if !b.ends_with('\n') {
463 content.push('\n');
464 }
465 } else {
466 content.push_str(&old_body);
467 }
468
469 write_and_validate(&filepath, &content, Some(&old_content), &self.schema, &self.path)?;
470
471 self.cache.lock().unwrap().invalidate_all();
472 Ok(filepath)
473 }
474
475 pub fn delete(&self, filename: &str) -> crate::errors::Result<PathBuf> {
476 let _lock = TableLock::acquire(&self.path)?;
477 self.delete_no_lock(filename)
478 }
479
480 fn delete_no_lock(&self, filename: &str) -> crate::errors::Result<PathBuf> {
481 let fname = normalize_filename(filename);
482
483 let filepath = self.path.join(&fname);
484 if !filepath.exists() {
485 return Err(MdqlError::General(format!("File not found: {}", fname)));
486 }
487
488 std::fs::remove_file(&filepath)?;
489 checksums::remove_checksum(&self.path, &fname)?;
490 self.cache.lock().unwrap().invalidate_all();
491 Ok(filepath)
492 }
493
494 pub fn execute_sql(&mut self, sql: &str) -> crate::errors::Result<String> {
495 let stmt = parse_query(sql)?;
496
497 match stmt {
498 Statement::Select(q) => self.exec_select(&q),
499 Statement::Insert(q) => self.exec_insert(&q),
500 Statement::Update(q) => self.exec_update(&q),
501 Statement::Delete(q) => self.exec_delete(&q),
502 Statement::AlterRename(q) => {
503 let count = self.rename_field(&q.old_name, &q.new_name)?;
504 Ok(format!(
505 "ALTER TABLE — renamed '{}' to '{}' in {} files",
506 q.old_name, q.new_name, count
507 ))
508 }
509 Statement::AlterDrop(q) => {
510 let count = self.drop_field(&q.field_name)?;
511 Ok(format!(
512 "ALTER TABLE — dropped '{}' from {} files",
513 q.field_name, count
514 ))
515 }
516 Statement::AlterMerge(q) => {
517 let count = self.merge_fields(&q.sources, &q.into)?;
518 let names: Vec<String> = q.sources.iter().map(|s| format!("'{}'", s)).collect();
519 Ok(format!(
520 "ALTER TABLE — merged {} into '{}' in {} files",
521 names.join(", "),
522 q.into,
523 count
524 ))
525 }
526 Statement::CreateView(_) | Statement::DropView(_) => {
527 Err(MdqlError::QueryExecution(
528 "CREATE VIEW and DROP VIEW require a database directory, not a single table".into(),
529 ))
530 }
531 }
532 }
533
534 pub fn query_sql(&mut self, sql: &str) -> crate::errors::Result<(Vec<Row>, Vec<String>)> {
536 let stmt = parse_query(sql)?;
537 let select = match stmt {
538 Statement::Select(q) => q,
539 _ => return Err(MdqlError::QueryParse("Only SELECT queries supported".into())),
540 };
541 let (_, rows, _) = crate::loader::load_table_cached(&self.path, &mut self.cache.lock().unwrap())?;
542 crate::query_engine::execute_query(&select, &rows, &self.schema)
543 }
544
545 fn exec_select(&self, query: &SelectQuery) -> crate::errors::Result<String> {
546 let (_, rows, _) = crate::loader::load_table_cached(&self.path, &mut self.cache.lock().unwrap())?;
547 let (result_rows, result_columns) = crate::query_engine::execute_query(query, &rows, &self.schema)?;
548 Ok(crate::projector::format_results(
549 &result_rows,
550 Some(&result_columns),
551 "table",
552 0,
553 ))
554 }
555
556 fn exec_insert(&self, query: &InsertQuery) -> crate::errors::Result<String> {
557 let mut data: HashMap<String, Value> = HashMap::new();
558 for (col, val) in query.columns.iter().zip(query.values.iter()) {
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 let filepath = self.insert(&data, None, None, false)?;
574 Ok(format!(
575 "INSERT 1 ({})",
576 filepath.file_name().unwrap_or_default().to_string_lossy()
577 ))
578 }
579
580 fn exec_update(&self, query: &UpdateQuery) -> crate::errors::Result<String> {
581 let (_, rows, _) = crate::loader::load_table_cached(&self.path, &mut self.cache.lock().unwrap())?;
582
583 let matching: Vec<&Row> = if let Some(ref wc) = query.where_clause {
584 rows.iter().filter(|r| evaluate(wc, r)).collect()
585 } else {
586 rows.iter().collect()
587 };
588
589 if matching.is_empty() {
590 return Ok("UPDATE 0".to_string());
591 }
592
593 let mut data: HashMap<String, Value> = HashMap::new();
594 for (col, val) in &query.assignments {
595 let field_def = self.schema.frontmatter.get(col);
596 if let Some(fd) = field_def {
597 if matches!(fd.field_type, FieldType::StringArray) {
598 if let SqlValue::String(s) = val {
599 data.insert(
600 col.clone(),
601 Value::List(s.split(',').map(|v| v.trim().to_string()).collect()),
602 );
603 continue;
604 }
605 }
606 }
607 data.insert(col.clone(), sql_value_to_value(val));
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, "UPDATE")?;
619 let mut c = 0;
620 for path_str in &paths {
621 let filepath = self.path.join(path_str);
622 txn.backup(&filepath)?;
623 self.update_no_lock(path_str, &data, None)?;
624 c += 1;
625 }
626 count = c;
627 txn.commit()?;
628 }
629
630 Ok(format!("UPDATE {}", count))
631 }
632
633 fn exec_delete(&self, query: &DeleteQuery) -> crate::errors::Result<String> {
634 let (_, rows, _) = crate::loader::load_table_cached(&self.path, &mut self.cache.lock().unwrap())?;
635
636 let matching: Vec<&Row> = if let Some(ref wc) = query.where_clause {
637 rows.iter().filter(|r| evaluate(wc, r)).collect()
638 } else {
639 rows.iter().collect()
640 };
641
642 if matching.is_empty() {
643 return Ok("DELETE 0".to_string());
644 }
645
646 let paths: Vec<String> = matching
647 .iter()
648 .filter_map(|r| r.get("path").and_then(|v| v.as_str()).map(|s| s.to_string()))
649 .collect();
650
651 let _lock = TableLock::acquire(&self.path)?;
652 let count;
653 {
654 let mut txn = TableTransaction::new(&self.path, "DELETE")?;
655 let mut c = 0;
656 for path_str in &paths {
657 let filepath = self.path.join(path_str);
658 let content = std::fs::read_to_string(&filepath)?;
659 txn.record_delete(&filepath, &content)?;
660 self.delete_no_lock(path_str)?;
661 c += 1;
662 }
663 count = c;
664 txn.commit()?;
665 }
666
667 Ok(format!("DELETE {}", count))
668 }
669
670 fn data_files(&self) -> Vec<PathBuf> {
671 let mut files: Vec<PathBuf> = std::fs::read_dir(&self.path)
672 .into_iter()
673 .flatten()
674 .filter_map(|e| e.ok())
675 .map(|e| e.path())
676 .filter(|p| {
677 p.extension().map_or(false, |e| e == "md")
678 && p.file_name()
679 .map_or(false, |n| n.to_string_lossy() != MDQL_FILENAME)
680 })
681 .collect();
682 files.sort();
683 files
684 }
685
686 fn field_kind(&self, name: &str) -> crate::errors::Result<&str> {
687 if self.schema.frontmatter.contains_key(name) {
688 return Ok("frontmatter");
689 }
690 if self.schema.sections.contains_key(name) {
691 return Ok("section");
692 }
693 Err(MdqlError::General(format!(
694 "Field '{}' not found in schema (not a frontmatter field or section)",
695 name
696 )))
697 }
698
699 pub fn rename_field(&mut self, old_name: &str, new_name: &str) -> crate::errors::Result<usize> {
700 let kind = self.field_kind(old_name)?.to_string();
701 let normalize = self.schema.rules.normalize_numbered_headings;
702
703 let _lock = TableLock::acquire(&self.path)?;
704 let mut count = 0;
705
706 with_multi_file_txn(
707 &self.path,
708 &format!("RENAME FIELD {} -> {}", old_name, new_name),
709 |txn| {
710 for md_file in self.data_files() {
711 txn.backup(&md_file)?;
712 if kind == "frontmatter" {
713 if migrate::rename_frontmatter_key_in_file(&md_file, old_name, new_name)? {
714 count += 1;
715 }
716 } else {
717 if migrate::rename_section_in_file(&md_file, old_name, new_name, normalize)? {
718 count += 1;
719 }
720 }
721 }
722
723 let schema_path = self.path.join(MDQL_FILENAME);
724 txn.backup(&schema_path)?;
725 if kind == "frontmatter" {
726 migrate::update_schema(&schema_path, Some((old_name, new_name)), None, None, None, None)?;
727 } else {
728 migrate::update_schema(&schema_path, None, None, Some((old_name, new_name)), None, None)?;
729 }
730 Ok(())
731 },
732 )?;
733
734 self.schema = load_schema(&self.path)?;
735 Ok(count)
736 }
737
738 pub fn drop_field(&mut self, field_name: &str) -> crate::errors::Result<usize> {
739 let kind = self.field_kind(field_name)?.to_string();
740 let normalize = self.schema.rules.normalize_numbered_headings;
741
742 let _lock = TableLock::acquire(&self.path)?;
743 let mut count = 0;
744
745 with_multi_file_txn(
746 &self.path,
747 &format!("DROP FIELD {}", field_name),
748 |txn| {
749 for md_file in self.data_files() {
750 txn.backup(&md_file)?;
751 if kind == "frontmatter" {
752 if migrate::drop_frontmatter_key_in_file(&md_file, field_name)? {
753 count += 1;
754 }
755 } else {
756 if migrate::drop_section_in_file(&md_file, field_name, normalize)? {
757 count += 1;
758 }
759 }
760 }
761
762 let schema_path = self.path.join(MDQL_FILENAME);
763 txn.backup(&schema_path)?;
764 if kind == "frontmatter" {
765 migrate::update_schema(&schema_path, None, Some(field_name), None, None, None)?;
766 } else {
767 migrate::update_schema(&schema_path, None, None, None, Some(field_name), None)?;
768 }
769 Ok(())
770 },
771 )?;
772
773 self.schema = load_schema(&self.path)?;
774 Ok(count)
775 }
776
777 pub fn merge_fields(&mut self, sources: &[String], into: &str) -> crate::errors::Result<usize> {
778 for name in sources {
779 let kind = self.field_kind(name)?;
780 if kind != "section" {
781 return Err(MdqlError::General(format!(
782 "Cannot merge frontmatter field '{}' — merge is only supported for section fields",
783 name
784 )));
785 }
786 }
787
788 let normalize = self.schema.rules.normalize_numbered_headings;
789 let _lock = TableLock::acquire(&self.path)?;
790 let mut count = 0;
791
792 let sources_owned: Vec<String> = sources.to_vec();
793
794 with_multi_file_txn(
795 &self.path,
796 &format!("MERGE FIELDS -> {}", into),
797 |txn| {
798 for md_file in self.data_files() {
799 txn.backup(&md_file)?;
800 if migrate::merge_sections_in_file(&md_file, &sources_owned, into, normalize)? {
801 count += 1;
802 }
803 }
804
805 let schema_path = self.path.join(MDQL_FILENAME);
806 txn.backup(&schema_path)?;
807 migrate::update_schema(
808 &schema_path,
809 None, None, None, None,
810 Some((&sources_owned, into)),
811 )?;
812 Ok(())
813 },
814 )?;
815
816 self.schema = load_schema(&self.path)?;
817 Ok(count)
818 }
819
820 pub fn load(&self) -> crate::errors::Result<(Vec<Row>, Vec<ValidationError>)> {
821 let (_, rows, errors) = crate::loader::load_table_cached(
822 &self.path,
823 &mut self.cache.lock().unwrap(),
824 )?;
825 Ok((rows, errors))
826 }
827
828 pub fn validate(&self) -> crate::errors::Result<Vec<ValidationError>> {
829 let (_, _, errors) = crate::loader::load_table_cached(
830 &self.path,
831 &mut self.cache.lock().unwrap(),
832 )?;
833 Ok(errors)
834 }
835}
836
837pub struct Database {
840 pub path: PathBuf,
841 config: DatabaseConfig,
842 tables: HashMap<String, Table>,
843}
844
845impl Database {
846 pub fn new(path: impl Into<PathBuf>) -> crate::errors::Result<Self> {
847 let path = path.into();
848 let config = load_database_config(&path)?;
849 let mut tables = HashMap::new();
850
851 let mut children: Vec<_> = std::fs::read_dir(&path)?
852 .filter_map(|e| e.ok())
853 .map(|e| e.path())
854 .filter(|p| p.is_dir() && p.join(MDQL_FILENAME).exists())
855 .collect();
856 children.sort();
857
858 for child in children {
859 let t = Table::new(&child)?;
860 tables.insert(t.name().to_string(), t);
861 }
862
863 Ok(Database {
864 path,
865 config,
866 tables,
867 })
868 }
869
870 pub fn name(&self) -> &str {
871 &self.config.name
872 }
873
874 pub fn config(&self) -> &DatabaseConfig {
875 &self.config
876 }
877
878 pub fn table_names(&self) -> Vec<String> {
879 let mut names: Vec<String> = self.tables.keys().cloned().collect();
880 names.sort();
881 names
882 }
883
884 pub fn rename(
886 &self,
887 table_name: &str,
888 old_filename: &str,
889 new_filename: &str,
890 ) -> crate::errors::Result<String> {
891 let old_name = normalize_filename(old_filename);
892 let new_name = normalize_filename(new_filename);
893
894 let table = self.tables.get(table_name).ok_or_else(|| {
895 MdqlError::General(format!("Table '{}' not found", table_name))
896 })?;
897
898 let old_path = table.path.join(&old_name);
899 if !old_path.exists() {
900 return Err(MdqlError::General(format!(
901 "File not found: {}/{}",
902 table_name, old_name
903 )));
904 }
905
906 let new_path = table.path.join(&new_name);
907 if new_path.exists() {
908 return Err(MdqlError::General(format!(
909 "Target already exists: {}/{}",
910 table_name, new_name
911 )));
912 }
913
914 let referencing_fks: Vec<_> = self
916 .config
917 .foreign_keys
918 .iter()
919 .filter(|fk| fk.to_table == table_name && fk.to_column == "path")
920 .collect();
921
922 let mut updates: Vec<(PathBuf, String, String)> = Vec::new(); for fk in &referencing_fks {
926 let ref_table = self.tables.get(&fk.from_table).ok_or_else(|| {
927 MdqlError::General(format!(
928 "Referencing table '{}' not found",
929 fk.from_table
930 ))
931 })?;
932
933 let entries: Vec<_> = std::fs::read_dir(&ref_table.path)?
935 .filter_map(|e| e.ok())
936 .map(|e| e.path())
937 .filter(|p| {
938 p.extension().and_then(|e| e.to_str()) == Some("md")
939 && p.file_name()
940 .and_then(|n| n.to_str())
941 .map_or(true, |n| n != MDQL_FILENAME)
942 })
943 .collect();
944
945 for entry in entries {
946 if let Ok((fm, _body)) = read_existing(&entry) {
947 if let Some(val) = fm.get(&fk.from_column) {
948 if val == &old_name {
949 updates.push((
950 entry,
951 fk.from_column.clone(),
952 val.clone(),
953 ));
954 }
955 }
956 }
957 }
958 }
959
960 let mut ref_count = 0;
962 for (filepath, column, _old_val) in &updates {
963 let text = std::fs::read_to_string(filepath)?;
964 let old_pattern = format!("{}: {}", column, old_name);
966 let new_pattern = format!("{}: {}", column, new_name);
967 let updated = text.replacen(&old_pattern, &new_pattern, 1);
968 let old_quoted = format!("{}: \"{}\"", column, old_name);
970 let new_quoted = format!("{}: \"{}\"", column, new_name);
971 let updated = updated.replacen(&old_quoted, &new_quoted, 1);
972 atomic_write(filepath, &updated)?;
973 ref_count += 1;
974 }
975
976 std::fs::rename(&old_path, &new_path)?;
978
979 let mut msg = format!("RENAME {}/{} → {}", table_name, old_name, new_name);
980 if ref_count > 0 {
981 msg.push_str(&format!(
982 " — updated {} reference{}",
983 ref_count,
984 if ref_count == 1 { "" } else { "s" }
985 ));
986 }
987 Ok(msg)
988 }
989
990 pub fn table(&mut self, name: &str) -> crate::errors::Result<&mut Table> {
991 if self.tables.contains_key(name) {
992 Ok(self.tables.get_mut(name).expect("key verified above"))
993 } else {
994 let available: Vec<String> = self.tables.keys().cloned().collect();
995 Err(MdqlError::General(format!(
996 "Table '{}' not found in database '{}'. Available: {}",
997 name,
998 self.config.name,
999 if available.is_empty() {
1000 "(none)".to_string()
1001 } else {
1002 available.join(", ")
1003 }
1004 )))
1005 }
1006 }
1007
1008 pub fn rename_table(&mut self, old_name: &str, new_name: &str) -> crate::errors::Result<String> {
1009 if !self.tables.contains_key(old_name) {
1010 return Err(MdqlError::General(format!("Table '{}' not found", old_name)));
1011 }
1012 if self.tables.contains_key(new_name) {
1013 return Err(MdqlError::General(format!("Table '{}' already exists", new_name)));
1014 }
1015
1016 let old_dir = self.path.join(old_name);
1017 let new_dir = self.path.join(new_name);
1018
1019 let schema_path = old_dir.join(MDQL_FILENAME);
1021 let schema_text = std::fs::read_to_string(&schema_path)?;
1022 let updated_schema = schema_text.replacen(
1023 &format!("table: {}", old_name),
1024 &format!("table: {}", new_name),
1025 1,
1026 );
1027 atomic_write(&schema_path, &updated_schema)?;
1028
1029 std::fs::rename(&old_dir, &new_dir)?;
1031
1032 let db_config_path = self.path.join(MDQL_FILENAME);
1034 if db_config_path.exists() {
1035 let config_text = std::fs::read_to_string(&db_config_path)?;
1036 if config_text.contains(old_name) {
1037 let mut updated_config = config_text.clone();
1038 for fk in &self.config.foreign_keys {
1039 if fk.from_table == old_name {
1040 updated_config = updated_config.replace(
1041 &format!("from: {}.{}", old_name, fk.from_column),
1042 &format!("from: {}.{}", new_name, fk.from_column),
1043 );
1044 }
1045 if fk.to_table == old_name {
1046 updated_config = updated_config.replace(
1047 &format!("to: {}.{}", old_name, fk.to_column),
1048 &format!("to: {}.{}", new_name, fk.to_column),
1049 );
1050 }
1051 }
1052 atomic_write(&db_config_path, &updated_config)?;
1053 }
1054 }
1055
1056 if let Some(_) = self.tables.remove(old_name) {
1058 let new_table = Table::new(&new_dir)?;
1059 self.tables.insert(new_name.to_string(), new_table);
1060 }
1061
1062 self.config = load_database_config(&self.path)?;
1064
1065 Ok(format!("RENAME TABLE {} → {}", old_name, new_name))
1066 }
1067}
1068
1069#[cfg(test)]
1070mod tests {
1071 use super::*;
1072
1073 #[test]
1074 fn test_slugify_basic() {
1075 assert_eq!(slugify("Hello World", 80), "hello-world");
1076 }
1077
1078 #[test]
1079 fn test_slugify_special_chars() {
1080 assert_eq!(slugify("My Strategy: Alpha & Beta!", 80), "my-strategy-alpha-beta");
1081 }
1082
1083 #[test]
1084 fn test_slugify_max_length() {
1085 let result = slugify("a very long title that exceeds the limit", 10);
1086 assert!(result.len() <= 10);
1087 assert!(!result.ends_with('-'));
1088 }
1089
1090 #[test]
1091 fn test_slugify_whitespace() {
1092 assert_eq!(slugify(" hello world ", 80), "hello-world");
1093 }
1094
1095 #[test]
1096 fn test_coerce_int() {
1097 let v = coerce_cli_value("42", &FieldType::Int).unwrap();
1098 assert_eq!(v, Value::Int(42));
1099 }
1100
1101 #[test]
1102 fn test_coerce_int_error() {
1103 assert!(coerce_cli_value("abc", &FieldType::Int).is_err());
1104 }
1105
1106 #[test]
1107 fn test_coerce_float() {
1108 let v = coerce_cli_value("3.14", &FieldType::Float).unwrap();
1109 assert_eq!(v, Value::Float(3.14));
1110 }
1111
1112 #[test]
1113 fn test_coerce_bool_true() {
1114 assert_eq!(coerce_cli_value("true", &FieldType::Bool).unwrap(), Value::Bool(true));
1115 assert_eq!(coerce_cli_value("yes", &FieldType::Bool).unwrap(), Value::Bool(true));
1116 assert_eq!(coerce_cli_value("1", &FieldType::Bool).unwrap(), Value::Bool(true));
1117 }
1118
1119 #[test]
1120 fn test_coerce_bool_false() {
1121 assert_eq!(coerce_cli_value("false", &FieldType::Bool).unwrap(), Value::Bool(false));
1122 assert_eq!(coerce_cli_value("no", &FieldType::Bool).unwrap(), Value::Bool(false));
1123 }
1124
1125 #[test]
1126 fn test_coerce_string_array() {
1127 let v = coerce_cli_value("a, b, c", &FieldType::StringArray).unwrap();
1128 assert_eq!(v, Value::List(vec!["a".into(), "b".into(), "c".into()]));
1129 }
1130
1131 #[test]
1132 fn test_coerce_date() {
1133 let v = coerce_cli_value("2026-04-16", &FieldType::Date).unwrap();
1134 assert_eq!(v, Value::Date(chrono::NaiveDate::from_ymd_opt(2026, 4, 16).unwrap()));
1135 }
1136
1137 #[test]
1138 fn test_coerce_datetime() {
1139 let v = coerce_cli_value("2026-04-16T10:30:00", &FieldType::DateTime).unwrap();
1140 match v {
1141 Value::DateTime(dt) => {
1142 assert_eq!(dt.date(), chrono::NaiveDate::from_ymd_opt(2026, 4, 16).unwrap());
1143 }
1144 _ => panic!("expected DateTime"),
1145 }
1146 }
1147
1148 #[test]
1149 fn test_coerce_string() {
1150 let v = coerce_cli_value("hello", &FieldType::String).unwrap();
1151 assert_eq!(v, Value::String("hello".into()));
1152 }
1153
1154 #[test]
1155 fn test_table_new_missing_schema() {
1156 let dir = tempfile::tempdir().unwrap();
1157 let result = Table::new(dir.path());
1158 assert!(result.is_err());
1159 }
1160
1161 #[test]
1162 fn test_table_insert_and_load() {
1163 let dir = tempfile::tempdir().unwrap();
1164 let schema_content = "---\ntype: schema\ntable: test\nprimary_key: path\nfrontmatter:\n title:\n type: string\n required: true\n---\n";
1165 std::fs::write(dir.path().join("_mdql.md"), schema_content).unwrap();
1166
1167 let table = Table::new(dir.path()).unwrap();
1168 let mut data = HashMap::new();
1169 data.insert("title".into(), Value::String("Hello".into()));
1170
1171 let path = table.insert(&data, None, Some("hello"), false).unwrap();
1172 assert!(path.exists());
1173
1174 let (rows, errors) = table.load().unwrap();
1175 assert_eq!(rows.len(), 1);
1176 assert_eq!(rows[0].get("title"), Some(&Value::String("Hello".into())));
1177 assert!(errors.is_empty());
1178 }
1179
1180 #[test]
1181 fn test_table_insert_duplicate_rejected() {
1182 let dir = tempfile::tempdir().unwrap();
1183 let schema_content = "---\ntype: schema\ntable: test\nprimary_key: path\nfrontmatter:\n title:\n type: string\n required: true\n---\n";
1184 std::fs::write(dir.path().join("_mdql.md"), schema_content).unwrap();
1185
1186 let table = Table::new(dir.path()).unwrap();
1187 let mut data = HashMap::new();
1188 data.insert("title".into(), Value::String("Hello".into()));
1189
1190 table.insert(&data, None, Some("hello"), false).unwrap();
1191 let result = table.insert(&data, None, Some("hello"), false);
1192 assert!(result.is_err());
1193 }
1194
1195 #[test]
1196 fn test_table_update() {
1197 let dir = tempfile::tempdir().unwrap();
1198 let schema_content = "---\ntype: schema\ntable: test\nprimary_key: path\nfrontmatter:\n title:\n type: string\n required: true\n score:\n type: int\n---\n";
1199 std::fs::write(dir.path().join("_mdql.md"), schema_content).unwrap();
1200
1201 let table = Table::new(dir.path()).unwrap();
1202 let mut data = HashMap::new();
1203 data.insert("title".into(), Value::String("Test".into()));
1204 data.insert("score".into(), Value::Int(10));
1205 table.insert(&data, None, Some("test"), false).unwrap();
1206
1207 let mut update = HashMap::new();
1208 update.insert("score".into(), Value::Int(20));
1209 table.update("test", &update, None).unwrap();
1210
1211 let (rows, _) = table.load().unwrap();
1212 assert_eq!(rows[0].get("score"), Some(&Value::Int(20)));
1213 }
1214
1215 #[test]
1216 fn test_table_delete() {
1217 let dir = tempfile::tempdir().unwrap();
1218 let schema_content = "---\ntype: schema\ntable: test\nprimary_key: path\nfrontmatter:\n title:\n type: string\n required: true\n---\n";
1219 std::fs::write(dir.path().join("_mdql.md"), schema_content).unwrap();
1220
1221 let table = Table::new(dir.path()).unwrap();
1222 let mut data = HashMap::new();
1223 data.insert("title".into(), Value::String("Doomed".into()));
1224 table.insert(&data, None, Some("doomed"), false).unwrap();
1225
1226 table.delete("doomed").unwrap();
1227 let (rows, _) = table.load().unwrap();
1228 assert!(rows.is_empty());
1229 }
1230
1231 #[test]
1232 fn test_table_validate() {
1233 let dir = tempfile::tempdir().unwrap();
1234 let schema_content = "---\ntype: schema\ntable: test\nprimary_key: path\nfrontmatter:\n title:\n type: string\n required: true\n---\n";
1235 std::fs::write(dir.path().join("_mdql.md"), schema_content).unwrap();
1236 std::fs::write(dir.path().join("bad.md"), "---\n---\nNo title field\n").unwrap();
1237
1238 let table = Table::new(dir.path()).unwrap();
1239 let errors = table.validate().unwrap();
1240 assert!(!errors.is_empty());
1241 }
1242}