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
37pub 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
117pub 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 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 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 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 let reverse = (edge.to.clone(), edge.from.clone());
174 !persist_edges.contains(&reverse)
175 } else {
176 true
177 }
178 });
179
180 let transitive_imports = compute_transitive_imports(&graph.imports);
191
192 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 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 (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 if let Some(b_imports) = transitive_imports.get(declarer_b) {
221 if b_imports.contains(declarer_a) {
222 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
250fn 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(¤t) {
262 continue;
263 }
264 visited.insert(current.clone());
265
266 if let Some(current_imports) = imports.get(¤t) {
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 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 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 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}