Skip to main content

mdql_core/
model.rs

1//! Convert parsed files to normalized row dicts.
2
3use std::collections::HashMap;
4
5use crate::parser::ParsedFile;
6use crate::schema::{FieldType, Schema};
7
8/// A row is a flat map from column name to value.
9pub type Row = HashMap<String, Value>;
10
11/// Dynamic value type for row entries.
12#[derive(Debug, Clone, PartialEq)]
13pub enum Value {
14    Null,
15    String(String),
16    Int(i64),
17    Float(f64),
18    Bool(bool),
19    Date(chrono::NaiveDate),
20    DateTime(chrono::NaiveDateTime),
21    List(Vec<String>),
22}
23
24impl Value {
25    pub fn as_str(&self) -> Option<&str> {
26        match self {
27            Value::String(s) => Some(s),
28            _ => None,
29        }
30    }
31
32    pub fn as_i64(&self) -> Option<i64> {
33        match self {
34            Value::Int(n) => Some(*n),
35            _ => None,
36        }
37    }
38
39    pub fn as_f64(&self) -> Option<f64> {
40        match self {
41            Value::Float(f) => Some(*f),
42            Value::Int(n) => Some(*n as f64),
43            _ => None,
44        }
45    }
46
47    pub fn as_bool(&self) -> Option<bool> {
48        match self {
49            Value::Bool(b) => Some(*b),
50            _ => None,
51        }
52    }
53
54    pub fn is_null(&self) -> bool {
55        matches!(self, Value::Null)
56    }
57
58    pub fn to_display_string(&self) -> String {
59        match self {
60            Value::Null => String::new(),
61            Value::String(s) => s.clone(),
62            Value::Int(n) => n.to_string(),
63            Value::Float(f) => format!("{}", f),
64            Value::Bool(b) => b.to_string(),
65            Value::Date(d) => d.format("%Y-%m-%d").to_string(),
66            Value::DateTime(dt) => dt.format("%Y-%m-%dT%H:%M:%S").to_string(),
67            Value::List(items) => items.join(", "),
68        }
69    }
70}
71
72impl std::fmt::Display for Value {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        write!(f, "{}", self.to_display_string())
75    }
76}
77
78impl PartialOrd for Value {
79    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
80        match (self, other) {
81            (Value::Int(a), Value::Int(b)) => a.partial_cmp(b),
82            (Value::Float(a), Value::Float(b)) => a.partial_cmp(b),
83            (Value::Int(a), Value::Float(b)) => (*a as f64).partial_cmp(b),
84            (Value::Float(a), Value::Int(b)) => a.partial_cmp(&(*b as f64)),
85            (Value::String(a), Value::String(b)) => a.partial_cmp(b),
86            (Value::Bool(a), Value::Bool(b)) => a.partial_cmp(b),
87            (Value::Date(a), Value::Date(b)) => a.partial_cmp(b),
88            (Value::DateTime(a), Value::DateTime(b)) => a.partial_cmp(b),
89            (Value::Date(a), Value::DateTime(b)) => a.and_hms_opt(0, 0, 0).unwrap().partial_cmp(b),
90            (Value::DateTime(a), Value::Date(b)) => a.partial_cmp(&b.and_hms_opt(0, 0, 0).unwrap()),
91            // Fallback: compare as strings
92            _ => self.to_display_string().partial_cmp(&other.to_display_string()),
93        }
94    }
95}
96
97fn yaml_to_value(val: &serde_yaml::Value, field_type: Option<&FieldType>) -> Value {
98    match val {
99        serde_yaml::Value::Null => Value::Null,
100        serde_yaml::Value::Bool(b) => Value::Bool(*b),
101        serde_yaml::Value::Number(n) => {
102            if let Some(FieldType::Float) = field_type {
103                Value::Float(n.as_f64().unwrap_or(0.0))
104            } else if let Some(i) = n.as_i64() {
105                Value::Int(i)
106            } else if let Some(u) = n.as_u64() {
107                Value::Int(u as i64)
108            } else if let Some(f) = n.as_f64() {
109                Value::Float(f)
110            } else {
111                Value::Null
112            }
113        }
114        serde_yaml::Value::String(s) => {
115            // DateTime coercion
116            if let Some(FieldType::DateTime) = field_type {
117                if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
118                    return Value::DateTime(dt);
119                }
120                if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f") {
121                    return Value::DateTime(dt);
122                }
123            }
124            // Date coercion
125            if let Some(FieldType::Date) = field_type {
126                if let Ok(date) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
127                    return Value::Date(date);
128                }
129            }
130            Value::String(s.clone())
131        }
132        serde_yaml::Value::Sequence(seq) => {
133            let items: Vec<String> = seq
134                .iter()
135                .filter_map(|v| v.as_str().map(|s| s.to_string()))
136                .collect();
137            Value::List(items)
138        }
139        _ => Value::String(format!("{:?}", val)),
140    }
141}
142
143/// Convert a validated ParsedFile into a flat row dict.
144pub fn to_row(parsed: &ParsedFile, schema: &Schema) -> Row {
145    let mut row = Row::new();
146    row.insert("path".to_string(), Value::String(parsed.path.clone()));
147
148    // Frontmatter fields — coerce types
149    if let Some(fm_map) = parsed.raw_frontmatter.as_mapping() {
150        for (key_val, value) in fm_map {
151            if let Some(key) = key_val.as_str() {
152                let field_type = schema.frontmatter.get(key).map(|fd| &fd.field_type)
153                    .or_else(|| {
154                        if crate::stamp::TIMESTAMP_FIELDS.contains(&key) {
155                            Some(&FieldType::DateTime)
156                        } else {
157                            None
158                        }
159                    });
160                row.insert(key.to_string(), yaml_to_value(value, field_type));
161            }
162        }
163    }
164
165    // H1
166    if let Some(ref h1) = parsed.h1 {
167        row.insert("h1".to_string(), Value::String(h1.clone()));
168    }
169
170    // Sections
171    for section in &parsed.sections {
172        row.insert(
173            section.normalized_heading.clone(),
174            Value::String(section.body.clone()),
175        );
176    }
177
178    row
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use crate::parser::parse_text;
185    use crate::schema::*;
186    use indexmap::IndexMap;
187
188    fn test_schema() -> Schema {
189        let mut frontmatter = IndexMap::new();
190        frontmatter.insert("title".to_string(), FieldDef {
191            field_type: FieldType::String,
192            required: true,
193            enum_values: None,
194        });
195        frontmatter.insert("count".to_string(), FieldDef {
196            field_type: FieldType::Int,
197            required: true,
198            enum_values: None,
199        });
200
201        Schema {
202            table: "test".to_string(),
203            primary_key: "path".to_string(),
204            frontmatter,
205            h1_required: false,
206            h1_must_equal_frontmatter: None,
207            sections: IndexMap::new(),
208            rules: Rules {
209                reject_unknown_frontmatter: false,
210                reject_unknown_sections: false,
211                reject_duplicate_sections: true,
212                normalize_numbered_headings: false,
213            },
214        }
215    }
216
217    #[test]
218    fn test_to_row_basic() {
219        let text = "---\ntitle: \"Hello\"\ncount: 42\n---\n\n## Summary\n\nA summary.\n";
220        let parsed = parse_text(text, "test.md", false);
221        let row = to_row(&parsed, &test_schema());
222        assert_eq!(row["path"], Value::String("test.md".into()));
223        assert_eq!(row["title"], Value::String("Hello".into()));
224        assert_eq!(row["count"], Value::Int(42));
225        assert_eq!(row["Summary"], Value::String("A summary.".into()));
226    }
227
228    #[test]
229    fn test_to_row_with_h1() {
230        let text = "---\ntitle: \"Test\"\ncount: 1\n---\n\n# My Title\n\n## Section\n\nBody.\n";
231        let parsed = parse_text(text, "test.md", false);
232        let row = to_row(&parsed, &test_schema());
233        assert_eq!(row["h1"], Value::String("My Title".into()));
234    }
235
236    #[test]
237    fn test_date_coercion() {
238        let mut frontmatter = IndexMap::new();
239        frontmatter.insert("created".to_string(), FieldDef {
240            field_type: FieldType::Date,
241            required: true,
242            enum_values: None,
243        });
244
245        let schema = Schema {
246            table: "test".to_string(),
247            primary_key: "path".to_string(),
248            frontmatter,
249            h1_required: false,
250            h1_must_equal_frontmatter: None,
251            sections: IndexMap::new(),
252            rules: Rules {
253                reject_unknown_frontmatter: false,
254                reject_unknown_sections: false,
255                reject_duplicate_sections: true,
256                normalize_numbered_headings: false,
257            },
258        };
259
260        let text = "---\ncreated: \"2026-04-04\"\n---\n";
261        let parsed = parse_text(text, "test.md", false);
262        let row = to_row(&parsed, &schema);
263        assert_eq!(
264            row["created"],
265            Value::Date(chrono::NaiveDate::from_ymd_opt(2026, 4, 4).unwrap())
266        );
267    }
268}