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