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