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 pub fn exec_delete_matched(&self, filenames: &[String]) -> crate::errors::Result<String> {
699 if filenames.is_empty() {
700 return Ok("DELETE 0".to_string());
701 }
702 let _lock = TableLock::acquire(&self.path)?;
703 let count;
704 {
705 let mut txn = TableTransaction::new(&self.path, "DELETE")?;
706 let mut c = 0;
707 for path_str in filenames {
708 let filepath = self.path.join(path_str);
709 let content = std::fs::read_to_string(&filepath)?;
710 txn.record_delete(&filepath, &content)?;
711 self.delete_no_lock(path_str)?;
712 c += 1;
713 }
714 count = c;
715 txn.commit()?;
716 }
717 Ok(format!("DELETE {}", count))
718 }
719
720 fn data_files(&self) -> Vec<PathBuf> {
721 let mut files: Vec<PathBuf> = std::fs::read_dir(&self.path)
722 .into_iter()
723 .flatten()
724 .filter_map(|e| e.ok())
725 .map(|e| e.path())
726 .filter(|p| {
727 p.extension().map_or(false, |e| e == "md")
728 && p.file_name()
729 .map_or(false, |n| n.to_string_lossy() != MDQL_FILENAME)
730 })
731 .collect();
732 files.sort();
733 files
734 }
735
736 fn field_kind(&self, name: &str) -> crate::errors::Result<&str> {
737 if self.schema.frontmatter.contains_key(name) {
738 return Ok("frontmatter");
739 }
740 if self.schema.sections.contains_key(name) {
741 return Ok("section");
742 }
743 Err(MdqlError::General(format!(
744 "Field '{}' not found in schema (not a frontmatter field or section)",
745 name
746 )))
747 }
748
749 pub fn rename_field(&mut self, old_name: &str, new_name: &str) -> crate::errors::Result<usize> {
750 let kind = self.field_kind(old_name)?.to_string();
751 let normalize = self.schema.rules.normalize_numbered_headings;
752
753 let _lock = TableLock::acquire(&self.path)?;
754 let mut count = 0;
755
756 with_multi_file_txn(
757 &self.path,
758 &format!("RENAME FIELD {} -> {}", old_name, new_name),
759 |txn| {
760 for md_file in self.data_files() {
761 txn.backup(&md_file)?;
762 if kind == "frontmatter" {
763 if migrate::rename_frontmatter_key_in_file(&md_file, old_name, new_name)? {
764 count += 1;
765 }
766 } else {
767 if migrate::rename_section_in_file(&md_file, old_name, new_name, normalize)? {
768 count += 1;
769 }
770 }
771 }
772
773 let schema_path = self.path.join(MDQL_FILENAME);
774 txn.backup(&schema_path)?;
775 if kind == "frontmatter" {
776 migrate::update_schema(&schema_path, Some((old_name, new_name)), None, None, None, None)?;
777 } else {
778 migrate::update_schema(&schema_path, None, None, Some((old_name, new_name)), None, None)?;
779 }
780 Ok(())
781 },
782 )?;
783
784 self.schema = load_schema(&self.path)?;
785 Ok(count)
786 }
787
788 pub fn drop_field(&mut self, field_name: &str) -> crate::errors::Result<usize> {
789 let kind = self.field_kind(field_name)?.to_string();
790 let normalize = self.schema.rules.normalize_numbered_headings;
791
792 let _lock = TableLock::acquire(&self.path)?;
793 let mut count = 0;
794
795 with_multi_file_txn(
796 &self.path,
797 &format!("DROP FIELD {}", field_name),
798 |txn| {
799 for md_file in self.data_files() {
800 txn.backup(&md_file)?;
801 if kind == "frontmatter" {
802 if migrate::drop_frontmatter_key_in_file(&md_file, field_name)? {
803 count += 1;
804 }
805 } else {
806 if migrate::drop_section_in_file(&md_file, field_name, normalize)? {
807 count += 1;
808 }
809 }
810 }
811
812 let schema_path = self.path.join(MDQL_FILENAME);
813 txn.backup(&schema_path)?;
814 if kind == "frontmatter" {
815 migrate::update_schema(&schema_path, None, Some(field_name), None, None, None)?;
816 } else {
817 migrate::update_schema(&schema_path, None, None, None, Some(field_name), None)?;
818 }
819 Ok(())
820 },
821 )?;
822
823 self.schema = load_schema(&self.path)?;
824 Ok(count)
825 }
826
827 pub fn merge_fields(&mut self, sources: &[String], into: &str) -> crate::errors::Result<usize> {
828 for name in sources {
829 let kind = self.field_kind(name)?;
830 if kind != "section" {
831 return Err(MdqlError::General(format!(
832 "Cannot merge frontmatter field '{}' — merge is only supported for section fields",
833 name
834 )));
835 }
836 }
837
838 let normalize = self.schema.rules.normalize_numbered_headings;
839 let _lock = TableLock::acquire(&self.path)?;
840 let mut count = 0;
841
842 let sources_owned: Vec<String> = sources.to_vec();
843
844 with_multi_file_txn(
845 &self.path,
846 &format!("MERGE FIELDS -> {}", into),
847 |txn| {
848 for md_file in self.data_files() {
849 txn.backup(&md_file)?;
850 if migrate::merge_sections_in_file(&md_file, &sources_owned, into, normalize)? {
851 count += 1;
852 }
853 }
854
855 let schema_path = self.path.join(MDQL_FILENAME);
856 txn.backup(&schema_path)?;
857 migrate::update_schema(
858 &schema_path,
859 None, None, None, None,
860 Some((&sources_owned, into)),
861 )?;
862 Ok(())
863 },
864 )?;
865
866 self.schema = load_schema(&self.path)?;
867 Ok(count)
868 }
869
870 pub fn load(&self) -> crate::errors::Result<(Vec<Row>, Vec<ValidationError>)> {
871 let (_, rows, errors) = crate::loader::load_table_cached(
872 &self.path,
873 &mut self.cache.lock().unwrap(),
874 )?;
875 Ok((rows, errors))
876 }
877
878 pub fn validate(&self) -> crate::errors::Result<Vec<ValidationError>> {
879 let (_, _, errors) = crate::loader::load_table_cached(
880 &self.path,
881 &mut self.cache.lock().unwrap(),
882 )?;
883 Ok(errors)
884 }
885}
886
887pub struct Database {
890 pub path: PathBuf,
891 config: DatabaseConfig,
892 tables: HashMap<String, Table>,
893}
894
895impl Database {
896 pub fn new(path: impl Into<PathBuf>) -> crate::errors::Result<Self> {
897 let path = path.into();
898 let config = load_database_config(&path)?;
899 let mut tables = HashMap::new();
900
901 let mut children: Vec<_> = std::fs::read_dir(&path)?
902 .filter_map(|e| e.ok())
903 .map(|e| e.path())
904 .filter(|p| p.is_dir() && p.join(MDQL_FILENAME).exists())
905 .collect();
906 children.sort();
907
908 for child in children {
909 let t = Table::new(&child)?;
910 tables.insert(t.name().to_string(), t);
911 }
912
913 Ok(Database {
914 path,
915 config,
916 tables,
917 })
918 }
919
920 pub fn name(&self) -> &str {
921 &self.config.name
922 }
923
924 pub fn config(&self) -> &DatabaseConfig {
925 &self.config
926 }
927
928 pub fn table_names(&self) -> Vec<String> {
929 let mut names: Vec<String> = self.tables.keys().cloned().collect();
930 names.sort();
931 names
932 }
933
934 pub fn rename(
936 &self,
937 table_name: &str,
938 old_filename: &str,
939 new_filename: &str,
940 ) -> crate::errors::Result<String> {
941 let old_name = normalize_filename(old_filename);
942 let new_name = normalize_filename(new_filename);
943
944 let table = self.tables.get(table_name).ok_or_else(|| {
945 MdqlError::General(format!("Table '{}' not found", table_name))
946 })?;
947
948 let old_path = table.path.join(&old_name);
949 if !old_path.exists() {
950 return Err(MdqlError::General(format!(
951 "File not found: {}/{}",
952 table_name, old_name
953 )));
954 }
955
956 let new_path = table.path.join(&new_name);
957 if new_path.exists() {
958 return Err(MdqlError::General(format!(
959 "Target already exists: {}/{}",
960 table_name, new_name
961 )));
962 }
963
964 let referencing_fks: Vec<_> = self
966 .config
967 .foreign_keys
968 .iter()
969 .filter(|fk| fk.to_table == table_name && fk.to_column == "path")
970 .collect();
971
972 let mut updates: Vec<(PathBuf, String, String)> = Vec::new(); for fk in &referencing_fks {
976 let ref_table = self.tables.get(&fk.from_table).ok_or_else(|| {
977 MdqlError::General(format!(
978 "Referencing table '{}' not found",
979 fk.from_table
980 ))
981 })?;
982
983 let entries: Vec<_> = std::fs::read_dir(&ref_table.path)?
985 .filter_map(|e| e.ok())
986 .map(|e| e.path())
987 .filter(|p| {
988 p.extension().and_then(|e| e.to_str()) == Some("md")
989 && p.file_name()
990 .and_then(|n| n.to_str())
991 .map_or(true, |n| n != MDQL_FILENAME)
992 })
993 .collect();
994
995 for entry in entries {
996 if let Ok((fm, _body)) = read_existing(&entry) {
997 if let Some(val) = fm.get(&fk.from_column) {
998 if val == &old_name {
999 updates.push((
1000 entry,
1001 fk.from_column.clone(),
1002 val.clone(),
1003 ));
1004 }
1005 }
1006 }
1007 }
1008 }
1009
1010 let mut ref_count = 0;
1012 for (filepath, column, _old_val) in &updates {
1013 let text = std::fs::read_to_string(filepath)?;
1014 let old_pattern = format!("{}: {}", column, old_name);
1016 let new_pattern = format!("{}: {}", column, new_name);
1017 let updated = text.replacen(&old_pattern, &new_pattern, 1);
1018 let old_quoted = format!("{}: \"{}\"", column, old_name);
1020 let new_quoted = format!("{}: \"{}\"", column, new_name);
1021 let updated = updated.replacen(&old_quoted, &new_quoted, 1);
1022 atomic_write(filepath, &updated)?;
1023 ref_count += 1;
1024 }
1025
1026 std::fs::rename(&old_path, &new_path)?;
1028
1029 let mut msg = format!("RENAME {}/{} → {}", table_name, old_name, new_name);
1030 if ref_count > 0 {
1031 msg.push_str(&format!(
1032 " — updated {} reference{}",
1033 ref_count,
1034 if ref_count == 1 { "" } else { "s" }
1035 ));
1036 }
1037 Ok(msg)
1038 }
1039
1040 pub fn table(&mut self, name: &str) -> crate::errors::Result<&mut Table> {
1041 if self.tables.contains_key(name) {
1042 Ok(self.tables.get_mut(name).expect("key verified above"))
1043 } else {
1044 let available: Vec<String> = self.tables.keys().cloned().collect();
1045 Err(MdqlError::General(format!(
1046 "Table '{}' not found in database '{}'. Available: {}",
1047 name,
1048 self.config.name,
1049 if available.is_empty() {
1050 "(none)".to_string()
1051 } else {
1052 available.join(", ")
1053 }
1054 )))
1055 }
1056 }
1057
1058 pub fn rename_table(&mut self, old_name: &str, new_name: &str) -> crate::errors::Result<String> {
1059 if !self.tables.contains_key(old_name) {
1060 return Err(MdqlError::General(format!("Table '{}' not found", old_name)));
1061 }
1062 if self.tables.contains_key(new_name) {
1063 return Err(MdqlError::General(format!("Table '{}' already exists", new_name)));
1064 }
1065
1066 let old_dir = self.path.join(old_name);
1067 let new_dir = self.path.join(new_name);
1068
1069 let schema_path = old_dir.join(MDQL_FILENAME);
1071 let schema_text = std::fs::read_to_string(&schema_path)?;
1072 let updated_schema = schema_text.replacen(
1073 &format!("table: {}", old_name),
1074 &format!("table: {}", new_name),
1075 1,
1076 );
1077 atomic_write(&schema_path, &updated_schema)?;
1078
1079 std::fs::rename(&old_dir, &new_dir)?;
1081
1082 let db_config_path = self.path.join(MDQL_FILENAME);
1084 if db_config_path.exists() {
1085 let config_text = std::fs::read_to_string(&db_config_path)?;
1086 if config_text.contains(old_name) {
1087 let mut updated_config = config_text.clone();
1088 for fk in &self.config.foreign_keys {
1089 if fk.from_table == old_name {
1090 updated_config = updated_config.replace(
1091 &format!("from: {}.{}", old_name, fk.from_column),
1092 &format!("from: {}.{}", new_name, fk.from_column),
1093 );
1094 }
1095 if fk.to_table == old_name {
1096 updated_config = updated_config.replace(
1097 &format!("to: {}.{}", old_name, fk.to_column),
1098 &format!("to: {}.{}", new_name, fk.to_column),
1099 );
1100 }
1101 }
1102 atomic_write(&db_config_path, &updated_config)?;
1103 }
1104 }
1105
1106 if let Some(_) = self.tables.remove(old_name) {
1108 let new_table = Table::new(&new_dir)?;
1109 self.tables.insert(new_name.to_string(), new_table);
1110 }
1111
1112 self.config = load_database_config(&self.path)?;
1114
1115 Ok(format!("RENAME TABLE {} → {}", old_name, new_name))
1116 }
1117}
1118
1119#[cfg(test)]
1120mod tests {
1121 use super::*;
1122
1123 #[test]
1124 fn test_slugify_basic() {
1125 assert_eq!(slugify("Hello World", 80), "hello-world");
1126 }
1127
1128 #[test]
1129 fn test_slugify_special_chars() {
1130 assert_eq!(slugify("My Strategy: Alpha & Beta!", 80), "my-strategy-alpha-beta");
1131 }
1132
1133 #[test]
1134 fn test_slugify_max_length() {
1135 let result = slugify("a very long title that exceeds the limit", 10);
1136 assert!(result.len() <= 10);
1137 assert!(!result.ends_with('-'));
1138 }
1139
1140 #[test]
1141 fn test_slugify_whitespace() {
1142 assert_eq!(slugify(" hello world ", 80), "hello-world");
1143 }
1144
1145 #[test]
1146 fn test_coerce_int() {
1147 let v = coerce_cli_value("42", &FieldType::Int).unwrap();
1148 assert_eq!(v, Value::Int(42));
1149 }
1150
1151 #[test]
1152 fn test_coerce_int_error() {
1153 assert!(coerce_cli_value("abc", &FieldType::Int).is_err());
1154 }
1155
1156 #[test]
1157 fn test_coerce_float() {
1158 let v = coerce_cli_value("3.14", &FieldType::Float).unwrap();
1159 assert_eq!(v, Value::Float(3.14));
1160 }
1161
1162 #[test]
1163 fn test_coerce_bool_true() {
1164 assert_eq!(coerce_cli_value("true", &FieldType::Bool).unwrap(), Value::Bool(true));
1165 assert_eq!(coerce_cli_value("yes", &FieldType::Bool).unwrap(), Value::Bool(true));
1166 assert_eq!(coerce_cli_value("1", &FieldType::Bool).unwrap(), Value::Bool(true));
1167 }
1168
1169 #[test]
1170 fn test_coerce_bool_false() {
1171 assert_eq!(coerce_cli_value("false", &FieldType::Bool).unwrap(), Value::Bool(false));
1172 assert_eq!(coerce_cli_value("no", &FieldType::Bool).unwrap(), Value::Bool(false));
1173 }
1174
1175 #[test]
1176 fn test_coerce_string_array() {
1177 let v = coerce_cli_value("a, b, c", &FieldType::StringArray).unwrap();
1178 assert_eq!(v, Value::List(vec!["a".into(), "b".into(), "c".into()]));
1179 }
1180
1181 #[test]
1182 fn test_coerce_date() {
1183 let v = coerce_cli_value("2026-04-16", &FieldType::Date).unwrap();
1184 assert_eq!(v, Value::Date(chrono::NaiveDate::from_ymd_opt(2026, 4, 16).unwrap()));
1185 }
1186
1187 #[test]
1188 fn test_coerce_datetime() {
1189 let v = coerce_cli_value("2026-04-16T10:30:00", &FieldType::DateTime).unwrap();
1190 match v {
1191 Value::DateTime(dt) => {
1192 assert_eq!(dt.date(), chrono::NaiveDate::from_ymd_opt(2026, 4, 16).unwrap());
1193 }
1194 _ => panic!("expected DateTime"),
1195 }
1196 }
1197
1198 #[test]
1199 fn test_coerce_string() {
1200 let v = coerce_cli_value("hello", &FieldType::String).unwrap();
1201 assert_eq!(v, Value::String("hello".into()));
1202 }
1203
1204 #[test]
1205 fn test_table_new_missing_schema() {
1206 let dir = tempfile::tempdir().unwrap();
1207 let result = Table::new(dir.path());
1208 assert!(result.is_err());
1209 }
1210
1211 #[test]
1212 fn test_table_insert_and_load() {
1213 let dir = tempfile::tempdir().unwrap();
1214 let schema_content = "---\ntype: schema\ntable: test\nprimary_key: path\nfrontmatter:\n title:\n type: string\n required: true\n---\n";
1215 std::fs::write(dir.path().join("_mdql.md"), schema_content).unwrap();
1216
1217 let table = Table::new(dir.path()).unwrap();
1218 let mut data = HashMap::new();
1219 data.insert("title".into(), Value::String("Hello".into()));
1220
1221 let path = table.insert(&data, None, Some("hello"), false).unwrap();
1222 assert!(path.exists());
1223
1224 let (rows, errors) = table.load().unwrap();
1225 assert_eq!(rows.len(), 1);
1226 assert_eq!(rows[0].get("title"), Some(&Value::String("Hello".into())));
1227 assert!(errors.is_empty());
1228 }
1229
1230 #[test]
1231 fn test_table_insert_duplicate_rejected() {
1232 let dir = tempfile::tempdir().unwrap();
1233 let schema_content = "---\ntype: schema\ntable: test\nprimary_key: path\nfrontmatter:\n title:\n type: string\n required: true\n---\n";
1234 std::fs::write(dir.path().join("_mdql.md"), schema_content).unwrap();
1235
1236 let table = Table::new(dir.path()).unwrap();
1237 let mut data = HashMap::new();
1238 data.insert("title".into(), Value::String("Hello".into()));
1239
1240 table.insert(&data, None, Some("hello"), false).unwrap();
1241 let result = table.insert(&data, None, Some("hello"), false);
1242 assert!(result.is_err());
1243 }
1244
1245 #[test]
1246 fn test_table_update() {
1247 let dir = tempfile::tempdir().unwrap();
1248 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";
1249 std::fs::write(dir.path().join("_mdql.md"), schema_content).unwrap();
1250
1251 let table = Table::new(dir.path()).unwrap();
1252 let mut data = HashMap::new();
1253 data.insert("title".into(), Value::String("Test".into()));
1254 data.insert("score".into(), Value::Int(10));
1255 table.insert(&data, None, Some("test"), false).unwrap();
1256
1257 let mut update = HashMap::new();
1258 update.insert("score".into(), Value::Int(20));
1259 table.update("test", &update, None).unwrap();
1260
1261 let (rows, _) = table.load().unwrap();
1262 assert_eq!(rows[0].get("score"), Some(&Value::Int(20)));
1263 }
1264
1265 #[test]
1266 fn test_table_delete() {
1267 let dir = tempfile::tempdir().unwrap();
1268 let schema_content = "---\ntype: schema\ntable: test\nprimary_key: path\nfrontmatter:\n title:\n type: string\n required: true\n---\n";
1269 std::fs::write(dir.path().join("_mdql.md"), schema_content).unwrap();
1270
1271 let table = Table::new(dir.path()).unwrap();
1272 let mut data = HashMap::new();
1273 data.insert("title".into(), Value::String("Doomed".into()));
1274 table.insert(&data, None, Some("doomed"), false).unwrap();
1275
1276 table.delete("doomed").unwrap();
1277 let (rows, _) = table.load().unwrap();
1278 assert!(rows.is_empty());
1279 }
1280
1281 #[test]
1282 fn test_table_validate() {
1283 let dir = tempfile::tempdir().unwrap();
1284 let schema_content = "---\ntype: schema\ntable: test\nprimary_key: path\nfrontmatter:\n title:\n type: string\n required: true\n---\n";
1285 std::fs::write(dir.path().join("_mdql.md"), schema_content).unwrap();
1286 std::fs::write(dir.path().join("bad.md"), "---\n---\nNo title field\n").unwrap();
1287
1288 let table = Table::new(dir.path()).unwrap();
1289 let errors = table.validate().unwrap();
1290 assert!(!errors.is_empty());
1291 }
1292}