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