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