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