Skip to main content

trilogy_parser/
directory_resolver.rs

1use crate::parser::parse_file;
2use std::collections::{HashMap, HashSet};
3use std::fs;
4use std::path::PathBuf;
5
6#[derive(Debug, Clone)]
7pub struct FileInfo {
8    pub path: PathBuf,
9    pub datasources: Vec<String>,
10    pub persists: Vec<String>,
11}
12
13#[derive(Debug, Clone)]
14pub struct DirectoryGraph {
15    pub files: HashMap<PathBuf, FileInfo>,
16    pub imports: HashMap<PathBuf, Vec<PathBuf>>,
17    pub warnings: Vec<String>,
18}
19
20#[derive(Debug, Clone)]
21pub struct Edge {
22    pub from: PathBuf,
23    pub to: PathBuf,
24    pub reason: EdgeReason,
25}
26
27#[derive(Debug, Clone)]
28pub enum EdgeReason {
29    Import,
30    PersistBeforeDeclare { datasource: String },
31    TransitivePersistOrder {
32        upstream_datasource: String,
33        downstream_datasource: String,
34    },
35}
36
37/// Process files in a directory, discovering transitive imports
38pub fn process_directory_with_imports(
39    initial_files: Vec<PathBuf>,
40) -> Result<DirectoryGraph, String> {
41    let mut all_imports: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
42    let mut files_info: HashMap<PathBuf, FileInfo> = HashMap::new();
43    let mut files_to_process = initial_files;
44    let mut processed_files: HashSet<PathBuf> = HashSet::new();
45    let mut warnings = Vec::new();
46
47    while let Some(file) = files_to_process.pop() {
48        let canonical = match fs::canonicalize(&file) {
49            Ok(c) => c,
50            Err(e) => {
51                warnings.push(format!("Failed to canonicalize {}: {}", file.display(), e));
52                continue;
53            }
54        };
55
56        if processed_files.contains(&canonical) {
57            continue;
58        }
59        processed_files.insert(canonical.clone());
60
61        let content = match fs::read_to_string(&file) {
62            Ok(c) => c,
63            Err(e) => {
64                warnings.push(format!("Failed to read {}: {}", file.display(), e));
65                continue;
66            }
67        };
68
69        let parsed = match parse_file(&content) {
70            Ok(p) => p,
71            Err(e) => {
72                warnings.push(format!("Failed to parse {}: {}", file.display(), e));
73                continue;
74            }
75        };
76
77        let mut resolved_imports = Vec::new();
78        let file_dir = file.parent().unwrap_or(std::path::Path::new("."));
79
80        for import in &parsed.imports {
81            if import.is_stdlib {
82                continue;
83            }
84            if let Some(resolved) = import.resolve(file_dir) {
85                if resolved.exists() {
86                    if let Ok(resolved_canonical) = fs::canonicalize(&resolved) {
87                        resolved_imports.push(resolved_canonical.clone());
88                        if !processed_files.contains(&resolved_canonical) {
89                            files_to_process.push(resolved_canonical);
90                        }
91                    }
92                }
93            }
94        }
95
96        let datasources: Vec<String> = parsed.datasources.iter().map(|d| d.name.clone()).collect();
97        let persists: Vec<String> = parsed.persists.iter().map(|p| p.target_datasource.clone()).collect();
98
99        all_imports.insert(canonical.clone(), resolved_imports);
100        files_info.insert(
101            canonical.clone(),
102            FileInfo {
103                path: canonical,
104                datasources,
105                persists,
106            },
107        );
108    }
109
110    Ok(DirectoryGraph {
111        files: files_info,
112        imports: all_imports,
113        warnings,
114    })
115}
116
117/// Build edges from a directory graph
118pub fn build_edges(graph: &DirectoryGraph) -> Vec<Edge> {
119    let mut edges = Vec::new();
120    let known_files: HashSet<PathBuf> = graph.files.keys().cloned().collect();
121
122    // Rule 1: Import dependencies (imported files run before importing files)
123    for (file, imports) in &graph.imports {
124        for resolved_path in imports {
125            if !known_files.contains(resolved_path) {
126                continue;
127            }
128            edges.push(Edge {
129                from: resolved_path.clone(),
130                to: file.clone(),
131                reason: EdgeReason::Import,
132            });
133        }
134    }
135
136    // Rule 2: Persist-before-declare
137    // Files that persist to a datasource must run BEFORE files that declare that datasource.
138    // This takes precedence over import edges - if the updater imports the declarer,
139    // we need to remove that import edge and add the persist-before-declare edge instead.
140    for (declarer_path, declarer_info) in &graph.files {
141        for ds_name in &declarer_info.datasources {
142            for (updater_path, updater_info) in &graph.files {
143                if updater_path == declarer_path {
144                    continue;
145                }
146
147                if updater_info.persists.contains(ds_name) {
148                    edges.push(Edge {
149                        from: updater_path.clone(),
150                        to: declarer_path.clone(),
151                        reason: EdgeReason::PersistBeforeDeclare {
152                            datasource: ds_name.clone(),
153                        },
154                    });
155                }
156            }
157        }
158    }
159
160    // Rule 3: Remove import edges that conflict with persist-before-declare
161    // If file A imports file B, but A also persists to a datasource declared by B,
162    // then the import edge (B -> A) conflicts with persist-before-declare (A -> B).
163    // In this case, persist-before-declare takes precedence.
164    let persist_edges: HashSet<(PathBuf, PathBuf)> = edges
165        .iter()
166        .filter(|e| matches!(e.reason, EdgeReason::PersistBeforeDeclare { .. }))
167        .map(|e| (e.from.clone(), e.to.clone()))
168        .collect();
169
170    edges.retain(|edge| {
171        if matches!(edge.reason, EdgeReason::Import) {
172            // Check if there's a conflicting persist-before-declare edge in the opposite direction
173            let reverse = (edge.to.clone(), edge.from.clone());
174            !persist_edges.contains(&reverse)
175        } else {
176            true
177        }
178    });
179
180    // Rule 4: Transitive persist ordering between updaters
181    // If X1 persists to datasource A (declared in declarer_A), and
182    // X2 persists to datasource B (declared in declarer_B), and
183    // declarer_B imports declarer_A (directly or transitively),
184    // then X1 must run before X2.
185    //
186    // This ensures that when B's datasource depends on A's data (through imports),
187    // any updates to A complete before updates to B.
188
189    // First, compute transitive imports for each file
190    let transitive_imports = compute_transitive_imports(&graph.imports);
191
192    // Build a map from datasource name to the file that declares it
193    let mut datasource_to_declarer: HashMap<String, PathBuf> = HashMap::new();
194    for (path, info) in &graph.files {
195        for ds_name in &info.datasources {
196            datasource_to_declarer.insert(ds_name.clone(), path.clone());
197        }
198    }
199
200    // Build a map from datasource name to files that persist to it
201    let mut datasource_to_updaters: HashMap<String, Vec<PathBuf>> = HashMap::new();
202    for (path, info) in &graph.files {
203        for persist_target in &info.persists {
204            datasource_to_updaters
205                .entry(persist_target.clone())
206                .or_default()
207                .push(path.clone());
208        }
209    }
210
211    // For each pair of datasources where one's declarer imports the other's declarer,
212    // add edges between their updaters
213    for (ds_a, declarer_a) in &datasource_to_declarer {
214        for (ds_b, declarer_b) in &datasource_to_declarer {
215            if ds_a == ds_b || declarer_a == declarer_b {
216                continue;
217            }
218
219            // Check if declarer_B transitively imports declarer_A
220            if let Some(b_imports) = transitive_imports.get(declarer_b) {
221                if b_imports.contains(declarer_a) {
222                    // declarer_B imports declarer_A, so all updaters of A must run before updaters of B
223                    if let (Some(updaters_a), Some(updaters_b)) = (
224                        datasource_to_updaters.get(ds_a),
225                        datasource_to_updaters.get(ds_b),
226                    ) {
227                        for updater_a in updaters_a {
228                            for updater_b in updaters_b {
229                                if updater_a != updater_b && known_files.contains(updater_a) && known_files.contains(updater_b) {
230                                    edges.push(Edge {
231                                        from: updater_a.clone(),
232                                        to: updater_b.clone(),
233                                        reason: EdgeReason::TransitivePersistOrder {
234                                            upstream_datasource: ds_a.clone(),
235                                            downstream_datasource: ds_b.clone(),
236                                        },
237                                    });
238                                }
239                            }
240                        }
241                    }
242                }
243            }
244        }
245    }
246
247    edges
248}
249
250/// Compute transitive imports for each file
251fn compute_transitive_imports(
252    imports: &HashMap<PathBuf, Vec<PathBuf>>,
253) -> HashMap<PathBuf, HashSet<PathBuf>> {
254    let mut result: HashMap<PathBuf, HashSet<PathBuf>> = HashMap::new();
255
256    for file in imports.keys() {
257        let mut visited: HashSet<PathBuf> = HashSet::new();
258        let mut stack: Vec<PathBuf> = imports.get(file).cloned().unwrap_or_default();
259
260        while let Some(current) = stack.pop() {
261            if visited.contains(&current) {
262                continue;
263            }
264            visited.insert(current.clone());
265
266            if let Some(current_imports) = imports.get(&current) {
267                for imp in current_imports {
268                    if !visited.contains(imp) {
269                        stack.push(imp.clone());
270                    }
271                }
272            }
273        }
274
275        result.insert(file.clone(), visited);
276    }
277
278    result
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use std::fs;
285    use tempfile::TempDir;
286
287    fn create_test_file(dir: &std::path::Path, name: &str, content: &str) -> PathBuf {
288        let path = dir.join(name);
289        if let Some(parent) = path.parent() {
290            fs::create_dir_all(parent).unwrap();
291        }
292        fs::write(&path, content).unwrap();
293        path
294    }
295
296    #[test]
297    fn test_transitive_persist_order() {
298        // Setup:
299        // - order_product_items.preql: declares datasource "order_product_items"
300        // - sales_reporting.preql: declares datasource "sales_reporting" AND imports order_product_items
301        // - incremental_opi.preql: persists to "order_product_items"
302        // - incremental_sales.preql: persists to "sales_reporting"
303        //
304        // Expected: incremental_opi -> incremental_sales (transitive persist order)
305        // Because sales_reporting imports order_product_items, so updates to order_product_items
306        // must complete before updates to sales_reporting.
307
308        let temp = TempDir::new().unwrap();
309        let root = temp.path();
310
311        create_test_file(
312            root,
313            "order_product_items.preql",
314            r#"
315            datasource order_product_items (
316                id: key
317            )
318            address db.opi;
319            "#,
320        );
321
322        create_test_file(
323            root,
324            "sales_reporting.preql",
325            r#"
326            import order_product_items;
327
328            datasource sales_reporting (
329                id: key
330            )
331            address db.sales;
332            "#,
333        );
334
335        create_test_file(
336            root,
337            "incremental_opi.preql",
338            r#"
339            import order_product_items;
340            persist order_product_items where id = 1;
341            "#,
342        );
343
344        create_test_file(
345            root,
346            "incremental_sales.preql",
347            r#"
348            import sales_reporting;
349            persist sales_reporting where id = 1;
350            "#,
351        );
352
353        // Collect all files
354        let files: Vec<PathBuf> = fs::read_dir(root)
355            .unwrap()
356            .filter_map(|e| e.ok())
357            .map(|e| e.path())
358            .filter(|p| p.extension().map_or(false, |ext| ext == "preql"))
359            .collect();
360
361        let graph = process_directory_with_imports(files).unwrap();
362        let edges = build_edges(&graph);
363
364        // Find the transitive persist order edge
365        let transitive_edge = edges.iter().find(|e| {
366            matches!(e.reason, EdgeReason::TransitivePersistOrder { .. })
367        });
368
369        assert!(
370            transitive_edge.is_some(),
371            "Expected a transitive persist order edge. Edges: {:?}",
372            edges.iter().map(|e| (e.from.file_name(), e.to.file_name(), &e.reason)).collect::<Vec<_>>()
373        );
374
375        let edge = transitive_edge.unwrap();
376        assert!(
377            edge.from.ends_with("incremental_opi.preql"),
378            "Expected from to be incremental_opi.preql, got {:?}",
379            edge.from
380        );
381        assert!(
382            edge.to.ends_with("incremental_sales.preql"),
383            "Expected to to be incremental_sales.preql, got {:?}",
384            edge.to
385        );
386    }
387}