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