Skip to main content

mdql_core/
database.rs

1//! Load and validate database-level _mdql.md files (type: database).
2
3use std::path::Path;
4
5use crate::errors::MdqlError;
6use crate::parser::parse_file;
7use crate::schema::MDQL_FILENAME;
8
9#[derive(Debug, Clone)]
10pub struct ForeignKey {
11    pub from_table: String,
12    pub from_column: String,
13    pub to_table: String,
14    pub to_column: String,
15}
16
17#[derive(Debug, Clone)]
18pub struct ViewDef {
19    pub name: String,
20    pub query: String,
21}
22
23#[derive(Debug, Clone)]
24pub struct SyncConfig {
25    pub remote_host: String,
26    pub remote_path: String,
27    pub ssh_key: Option<String>,
28}
29
30#[derive(Debug, Clone)]
31pub struct DatabaseConfig {
32    pub name: String,
33    pub foreign_keys: Vec<ForeignKey>,
34    pub views: Vec<ViewDef>,
35    pub sync: Option<SyncConfig>,
36}
37
38pub fn is_database_dir(folder: &Path) -> bool {
39    let mdql_file = folder.join(MDQL_FILENAME);
40    if !mdql_file.exists() {
41        return false;
42    }
43    if let Ok(text) = std::fs::read_to_string(&mdql_file) {
44        if let Some(fm_text) = text
45            .strip_prefix("---\n")
46            .and_then(|rest| rest.split_once("\n---").map(|(fm, _)| fm))
47        {
48            if let Ok(val) = serde_yaml::from_str::<serde_yaml::Value>(fm_text) {
49                if let Some(m) = val.as_mapping() {
50                    return m
51                        .get(&serde_yaml::Value::String("type".into()))
52                        .and_then(|v| v.as_str())
53                        == Some("database");
54                }
55            }
56        }
57    }
58    false
59}
60
61pub fn load_database_config(db_dir: &Path) -> crate::errors::Result<DatabaseConfig> {
62    let db_path = db_dir.join(MDQL_FILENAME);
63    if !db_path.exists() {
64        return Err(MdqlError::DatabaseConfig(format!(
65            "No {} in {}",
66            MDQL_FILENAME,
67            db_dir.display()
68        )));
69    }
70
71    let parsed = parse_file(&db_path, Some(db_dir), false)?;
72
73    if !parsed.parse_errors.is_empty() {
74        return Err(MdqlError::DatabaseConfig(format!(
75            "Cannot parse {}: {}",
76            MDQL_FILENAME,
77            parsed.parse_errors.join("; ")
78        )));
79    }
80
81    let fm = &parsed.raw_frontmatter;
82    let fm_map = fm.as_mapping().ok_or_else(|| {
83        MdqlError::DatabaseConfig(format!(
84            "{}: frontmatter must be a mapping",
85            MDQL_FILENAME
86        ))
87    })?;
88
89    let type_val = fm_map.get(&serde_yaml::Value::String("type".into()));
90    if type_val.and_then(|v| v.as_str()) != Some("database") {
91        return Err(MdqlError::DatabaseConfig(format!(
92            "{}: frontmatter must have 'type: database'",
93            MDQL_FILENAME
94        )));
95    }
96
97    let name = fm_map
98        .get(&serde_yaml::Value::String("name".into()))
99        .and_then(|v| v.as_str())
100        .ok_or_else(|| {
101            MdqlError::DatabaseConfig(format!(
102                "{}: frontmatter must have 'name' as a string",
103                MDQL_FILENAME
104            ))
105        })?
106        .to_string();
107
108    let mut fks = Vec::new();
109    if let Some(fk_list) = fm_map.get(&serde_yaml::Value::String("foreign_keys".into())) {
110        if let Some(seq) = fk_list.as_sequence() {
111            for fk_def in seq {
112                let fk_map = fk_def.as_mapping().ok_or_else(|| {
113                    MdqlError::DatabaseConfig(format!(
114                        "{}: each foreign_key must be a mapping",
115                        MDQL_FILENAME
116                    ))
117                })?;
118
119                let from_spec = fk_map
120                    .get(&serde_yaml::Value::String("from".into()))
121                    .and_then(|v| v.as_str())
122                    .unwrap_or("");
123                let to_spec = fk_map
124                    .get(&serde_yaml::Value::String("to".into()))
125                    .and_then(|v| v.as_str())
126                    .unwrap_or("");
127
128                if !from_spec.contains('.') || !to_spec.contains('.') {
129                    return Err(MdqlError::DatabaseConfig(format!(
130                        "{}: foreign_key 'from' and 'to' must be 'table.column' format",
131                        MDQL_FILENAME
132                    )));
133                }
134
135                let (from_table, from_col) = from_spec.split_once('.').unwrap();
136                let (to_table, to_col) = to_spec.split_once('.').unwrap();
137
138                fks.push(ForeignKey {
139                    from_table: from_table.to_string(),
140                    from_column: from_col.to_string(),
141                    to_table: to_table.to_string(),
142                    to_column: to_col.to_string(),
143                });
144            }
145        }
146    }
147
148    let mut views = Vec::new();
149    if let Some(view_list) = fm_map.get(&serde_yaml::Value::String("views".into())) {
150        if let Some(seq) = view_list.as_sequence() {
151            for view_def in seq {
152                let view_map = view_def.as_mapping().ok_or_else(|| {
153                    MdqlError::DatabaseConfig(format!(
154                        "{}: each view must be a mapping with 'name' and 'query'",
155                        MDQL_FILENAME
156                    ))
157                })?;
158
159                let view_name = view_map
160                    .get(&serde_yaml::Value::String("name".into()))
161                    .and_then(|v| v.as_str())
162                    .ok_or_else(|| {
163                        MdqlError::DatabaseConfig(format!(
164                            "{}: each view must have a 'name' string",
165                            MDQL_FILENAME
166                        ))
167                    })?
168                    .to_string();
169
170                let view_query = view_map
171                    .get(&serde_yaml::Value::String("query".into()))
172                    .and_then(|v| v.as_str())
173                    .ok_or_else(|| {
174                        MdqlError::DatabaseConfig(format!(
175                            "{}: view '{}' must have a 'query' string",
176                            MDQL_FILENAME, view_name
177                        ))
178                    })?
179                    .to_string();
180
181                views.push(ViewDef {
182                    name: view_name,
183                    query: view_query,
184                });
185            }
186        }
187    }
188
189    let sync = if let Some(sync_val) = fm_map.get(&serde_yaml::Value::String("sync".into())) {
190        let sync_map = sync_val.as_mapping().ok_or_else(|| {
191            MdqlError::DatabaseConfig(format!(
192                "{}: 'sync' must be a mapping with 'remote_host' and 'remote_path'",
193                MDQL_FILENAME
194            ))
195        })?;
196        let remote_host = sync_map
197            .get(&serde_yaml::Value::String("remote_host".into()))
198            .and_then(|v| v.as_str())
199            .ok_or_else(|| {
200                MdqlError::DatabaseConfig(format!(
201                    "{}: sync must have 'remote_host' as a string",
202                    MDQL_FILENAME
203                ))
204            })?
205            .to_string();
206        let remote_path = sync_map
207            .get(&serde_yaml::Value::String("remote_path".into()))
208            .and_then(|v| v.as_str())
209            .ok_or_else(|| {
210                MdqlError::DatabaseConfig(format!(
211                    "{}: sync must have 'remote_path' as a string",
212                    MDQL_FILENAME
213                ))
214            })?
215            .to_string();
216        let ssh_key = sync_map
217            .get(&serde_yaml::Value::String("ssh_key".into()))
218            .and_then(|v| v.as_str())
219            .map(|s| s.to_string());
220        Some(SyncConfig {
221            remote_host,
222            remote_path,
223            ssh_key,
224        })
225    } else {
226        None
227    };
228
229    Ok(DatabaseConfig {
230        name,
231        foreign_keys: fks,
232        views,
233        sync,
234    })
235}
236
237pub(crate) fn save_database_config(db_dir: &Path, config: &DatabaseConfig) -> crate::errors::Result<()> {
238    let db_path = db_dir.join(MDQL_FILENAME);
239    let text = std::fs::read_to_string(&db_path)?;
240
241    let (_before_fm, fm_text, after_fm) = if let Some(rest) = text.strip_prefix("---\n") {
242        if let Some((fm, after)) = rest.split_once("\n---") {
243            ("---\n".to_string(), fm.to_string(), format!("\n---{}", after))
244        } else {
245            return Err(MdqlError::DatabaseConfig("Malformed frontmatter".into()));
246        }
247    } else {
248        return Err(MdqlError::DatabaseConfig("No frontmatter found".into()));
249    };
250
251    let mut fm: serde_yaml::Value = serde_yaml::from_str(&fm_text)
252        .map_err(|e| MdqlError::DatabaseConfig(format!("YAML parse error: {}", e)))?;
253
254    let fm_map = fm.as_mapping_mut().ok_or_else(|| {
255        MdqlError::DatabaseConfig("Frontmatter is not a mapping".into())
256    })?;
257
258    let views_key = serde_yaml::Value::String("views".into());
259    if config.views.is_empty() {
260        fm_map.remove(&views_key);
261    } else {
262        let views_seq: Vec<serde_yaml::Value> = config
263            .views
264            .iter()
265            .map(|v| {
266                let mut m = serde_yaml::Mapping::new();
267                m.insert(
268                    serde_yaml::Value::String("name".into()),
269                    serde_yaml::Value::String(v.name.clone()),
270                );
271                m.insert(
272                    serde_yaml::Value::String("query".into()),
273                    serde_yaml::Value::String(v.query.clone()),
274                );
275                serde_yaml::Value::Mapping(m)
276            })
277            .collect();
278        fm_map.insert(views_key, serde_yaml::Value::Sequence(views_seq));
279    }
280
281    let new_fm_text = serde_yaml::to_string(&fm)
282        .map_err(|e| MdqlError::DatabaseConfig(format!("YAML serialize error: {}", e)))?;
283
284    let new_content = format!("---\n{}---{}", new_fm_text, &after_fm[4..]);
285    crate::txn::atomic_write(&db_path, &new_content)?;
286    Ok(())
287}