1use crate::parser::{parse_file, DatasourceDeclaration, ImportStatement, ParseError, PersistStatement, ParsedFile};
2use serde::Serialize;
3use std::collections::{HashMap, HashSet, VecDeque};
4use std::fs;
5use std::path::{Path, PathBuf};
6use thiserror::Error;
7
8#[derive(Error, Debug)]
9pub enum ResolveError {
10 #[error("Failed to read file {path}: {source}")]
11 IoError {
12 path: PathBuf,
13 source: std::io::Error,
14 },
15
16 #[error("Parse error in {path}: {source}")]
17 ParseError { path: PathBuf, source: ParseError },
18
19 #[error("Circular dependency detected: {cycle}")]
20 CircularDependency { cycle: String },
21
22 #[error("Import not found: {import_path} (resolved to {resolved_path})")]
23 ImportNotFound {
24 import_path: String,
25 resolved_path: PathBuf,
26 },
27}
28
29#[derive(Debug, Clone, Serialize)]
31pub struct ImportInfo {
32 pub raw_path: String,
33 pub alias: Option<String>,
34 pub resolved_path: Option<PathBuf>,
35 pub is_stdlib: bool,
36}
37
38impl From<&ImportStatement> for ImportInfo {
39 fn from(stmt: &ImportStatement) -> Self {
40 ImportInfo {
41 raw_path: stmt.raw_path.clone(),
42 alias: stmt.alias.clone(),
43 resolved_path: None,
44 is_stdlib: stmt.is_stdlib,
45 }
46 }
47}
48
49#[derive(Debug, Clone, Serialize)]
51pub struct DatasourceInfo {
52 pub name: String,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub address: Option<String>,
55 pub address_kind: String,
56 pub is_root: bool,
57 pub is_partial: bool,
58 pub is_partitioned: bool,
59}
60
61impl From<&DatasourceDeclaration> for DatasourceInfo {
62 fn from(ds: &DatasourceDeclaration) -> Self {
63 DatasourceInfo {
64 name: ds.name.clone(),
65 address: ds.address.clone(),
66 address_kind: ds.address_kind.to_string(),
67 is_root: ds.is_root,
68 is_partial: ds.is_partial,
69 is_partitioned: ds.is_partitioned,
70 }
71 }
72}
73
74#[derive(Debug, Clone, Serialize)]
76pub struct PersistInfo {
77 pub mode: String,
78 pub target_datasource: String,
79}
80
81impl From<&PersistStatement> for PersistInfo {
82 fn from(ps: &PersistStatement) -> Self {
83 PersistInfo {
84 mode: ps.mode.to_string(),
85 target_datasource: ps.target_datasource.clone(),
86 }
87 }
88}
89
90#[derive(Debug, Clone, Serialize)]
92pub struct FileNode {
93 pub path: PathBuf,
95 pub relative_path: PathBuf,
97 pub imports: Vec<ImportInfo>,
99 pub datasources: Vec<DatasourceInfo>,
101 pub persists: Vec<PersistInfo>,
103 pub import_dependencies: Vec<PathBuf>,
105 pub updates_datasources: Vec<String>,
107 pub declares_datasources: Vec<String>,
109 pub depends_on_datasources: Vec<String>,
111}
112
113#[derive(Debug, Clone, Serialize)]
115pub struct DependencyGraph {
116 pub root: PathBuf,
118 pub order: Vec<PathBuf>,
120 pub files: HashMap<PathBuf, FileNode>,
122 pub datasource_declarations: HashMap<String, PathBuf>,
124 pub datasource_updaters: HashMap<String, Vec<PathBuf>>,
126 pub warnings: Vec<String>,
128}
129
130pub struct ImportResolver {
132 parsed_cache: HashMap<PathBuf, ParsedFile>,
134 warnings: Vec<String>,
136}
137
138impl ImportResolver {
139 pub fn new() -> Self {
140 Self {
141 parsed_cache: HashMap::new(),
142 warnings: Vec::new(),
143 }
144 }
145
146 pub fn resolve(&mut self, root_path: &Path) -> Result<DependencyGraph, ResolveError> {
148 let root_path = fs::canonicalize(root_path).map_err(|e| ResolveError::IoError {
149 path: root_path.to_path_buf(),
150 source: e,
151 })?;
152
153 let root_dir = root_path.parent().unwrap_or(Path::new("."));
154 let mut files: HashMap<PathBuf, FileNode> = HashMap::new();
155
156 let mut queue: VecDeque<PathBuf> = VecDeque::new();
158 let mut seen: HashSet<PathBuf> = HashSet::new();
159
160 queue.push_back(root_path.clone());
161 seen.insert(root_path.clone());
162
163 while let Some(current_path) = queue.pop_front() {
164 let parsed = self.parse_file(¤t_path)?;
165 let file_dir = current_path.parent().unwrap_or(Path::new("."));
166
167 let mut import_infos: Vec<ImportInfo> = Vec::new();
168 let mut import_dependencies: Vec<PathBuf> = Vec::new();
169
170 for import in &parsed.imports {
171 let mut info = ImportInfo::from(import);
172
173 if import.is_stdlib {
174 import_infos.push(info);
175 continue;
176 }
177
178 if let Some(resolved) = import.resolve(file_dir) {
179 if resolved.exists() {
180 let canonical =
181 fs::canonicalize(&resolved).map_err(|e| ResolveError::IoError {
182 path: resolved.clone(),
183 source: e,
184 })?;
185
186 info.resolved_path = Some(canonical.clone());
187 import_dependencies.push(canonical.clone());
188
189 if !seen.contains(&canonical) {
190 seen.insert(canonical.clone());
191 queue.push_back(canonical);
192 }
193 } else {
194 self.warnings.push(format!(
195 "Import '{}' in {} resolved to non-existent file: {}",
196 import.raw_path,
197 current_path.display(),
198 resolved.display()
199 ));
200 }
201 }
202
203 import_infos.push(info);
204 }
205
206 let datasource_infos: Vec<DatasourceInfo> =
207 parsed.datasources.iter().map(DatasourceInfo::from).collect();
208 let persist_infos: Vec<PersistInfo> =
209 parsed.persists.iter().map(PersistInfo::from).collect();
210
211 let declares_datasources: Vec<String> =
212 parsed.datasources.iter().map(|d| d.name.clone()).collect();
213 let updates_datasources: Vec<String> = parsed
214 .persists
215 .iter()
216 .map(|p| p.target_datasource.clone())
217 .collect();
218
219 let relative_path = pathdiff::diff_paths(¤t_path, root_dir)
220 .unwrap_or_else(|| current_path.clone());
221
222 files.insert(
223 current_path.clone(),
224 FileNode {
225 path: current_path,
226 relative_path,
227 imports: import_infos,
228 datasources: datasource_infos,
229 persists: persist_infos,
230 import_dependencies,
231 updates_datasources,
232 declares_datasources,
233 depends_on_datasources: Vec::new(), },
235 );
236 }
237
238 let mut datasource_declarations: HashMap<String, PathBuf> = HashMap::new();
240 let mut datasource_updaters: HashMap<String, Vec<PathBuf>> = HashMap::new();
241
242 for (path, node) in &files {
243 for ds_name in &node.declares_datasources {
244 if let Some(existing) = datasource_declarations.get(ds_name) {
245 self.warnings.push(format!(
246 "Datasource '{}' declared in multiple files: {} and {}",
247 ds_name,
248 existing.display(),
249 path.display()
250 ));
251 } else {
252 datasource_declarations.insert(ds_name.clone(), path.clone());
253 }
254 }
255
256 for ds_name in &node.updates_datasources {
257 datasource_updaters
258 .entry(ds_name.clone())
259 .or_insert_with(Vec::new)
260 .push(path.clone());
261 }
262 }
263
264 self.compute_datasource_dependencies(&mut files, &datasource_declarations);
266
267 let order =
269 self.topological_sort_with_datasources(&files, &datasource_declarations, &datasource_updaters)?;
270
271 Ok(DependencyGraph {
272 root: root_path,
273 order,
274 files,
275 datasource_declarations,
276 datasource_updaters,
277 warnings: self.warnings.clone(),
278 })
279 }
280
281 fn parse_file(&mut self, path: &Path) -> Result<ParsedFile, ResolveError> {
282 if let Some(cached) = self.parsed_cache.get(path) {
283 return Ok(cached.clone());
284 }
285
286 let content = fs::read_to_string(path).map_err(|e| ResolveError::IoError {
287 path: path.to_path_buf(),
288 source: e,
289 })?;
290
291 let parsed = parse_file(&content).map_err(|e| ResolveError::ParseError {
292 path: path.to_path_buf(),
293 source: e,
294 })?;
295
296 self.parsed_cache.insert(path.to_path_buf(), parsed.clone());
297 Ok(parsed)
298 }
299
300 fn compute_datasource_dependencies(
302 &self,
303 files: &mut HashMap<PathBuf, FileNode>,
304 _datasource_declarations: &HashMap<String, PathBuf>,
305 ) {
306 let paths: Vec<PathBuf> = files.keys().cloned().collect();
308
309 for path in paths {
310 let mut reachable_datasources: HashSet<String> = HashSet::new();
311 let mut visited: HashSet<PathBuf> = HashSet::new();
312 let mut stack: Vec<PathBuf> = vec![path.clone()];
313
314 while let Some(current) = stack.pop() {
315 if visited.contains(¤t) {
316 continue;
317 }
318 visited.insert(current.clone());
319
320 if let Some(node) = files.get(¤t) {
321 if current != path {
323 for ds in &node.declares_datasources {
324 reachable_datasources.insert(ds.clone());
325 }
326 }
327
328 for dep in &node.import_dependencies {
330 if !visited.contains(dep) {
331 stack.push(dep.clone());
332 }
333 }
334 }
335 }
336
337 if let Some(node) = files.get_mut(&path) {
338 node.depends_on_datasources = reachable_datasources.into_iter().collect();
339 }
340 }
341 }
342
343 fn topological_sort_with_datasources(
351 &self,
352 files: &HashMap<PathBuf, FileNode>,
353 datasource_declarations: &HashMap<String, PathBuf>,
354 datasource_updaters: &HashMap<String, Vec<PathBuf>>,
355 ) -> Result<Vec<PathBuf>, ResolveError> {
356 let mut edges: HashMap<PathBuf, HashSet<PathBuf>> = HashMap::new();
359
360 for path in files.keys() {
362 edges.insert(path.clone(), HashSet::new());
363 }
364
365 for (path, node) in files {
367 for dep in &node.import_dependencies {
369 if files.contains_key(dep) {
370 edges.get_mut(dep).unwrap().insert(path.clone());
371 }
372 }
373
374 for ds_name in &node.declares_datasources {
379 if let Some(updaters) = datasource_updaters.get(ds_name) {
380 for updater_path in updaters {
381 if updater_path != path && files.contains_key(updater_path) {
382 let updater_node = files.get(updater_path).unwrap();
384 let imports_declarer = updater_node.import_dependencies.contains(path);
385
386 if !imports_declarer {
388 edges.get_mut(updater_path).unwrap().insert(path.clone());
390 }
391 }
392 }
393 }
394 }
395
396 for ds_name in &node.depends_on_datasources {
399 if let Some(declaring_path) = datasource_declarations.get(ds_name) {
400 if declaring_path != path && files.contains_key(declaring_path) {
401 edges.get_mut(declaring_path).unwrap().insert(path.clone());
403 }
404 }
405 }
406 }
407
408 let mut in_degree: HashMap<PathBuf, usize> = HashMap::new();
410 for path in files.keys() {
411 in_degree.insert(path.clone(), 0);
412 }
413
414 for dependents in edges.values() {
415 for dep in dependents {
416 *in_degree.get_mut(dep).unwrap() += 1;
417 }
418 }
419
420 let mut queue: VecDeque<PathBuf> = VecDeque::new();
421 let mut result: Vec<PathBuf> = Vec::new();
422
423 for (path, °ree) in &in_degree {
425 if degree == 0 {
426 queue.push_back(path.clone());
427 }
428 }
429
430 while let Some(current) = queue.pop_front() {
431 result.push(current.clone());
432
433 if let Some(dependents) = edges.get(¤t) {
434 for dependent in dependents {
435 let degree = in_degree.get_mut(dependent).unwrap();
436 *degree -= 1;
437 if *degree == 0 {
438 queue.push_back(dependent.clone());
439 }
440 }
441 }
442 }
443
444 if result.len() != files.len() {
446 let remaining: Vec<_> = files
447 .keys()
448 .filter(|p| !result.contains(p))
449 .map(|p| p.display().to_string())
450 .collect();
451 return Err(ResolveError::CircularDependency {
452 cycle: remaining.join(" -> "),
453 });
454 }
455
456 Ok(result)
457 }
458}
459
460impl Default for ImportResolver {
461 fn default() -> Self {
462 Self::new()
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469 use std::fs;
470 use tempfile::TempDir;
471
472 fn create_test_file(dir: &Path, name: &str, content: &str) -> PathBuf {
473 let path = dir.join(name);
474 if let Some(parent) = path.parent() {
475 fs::create_dir_all(parent).unwrap();
476 }
477 fs::write(&path, content).unwrap();
478 path
479 }
480
481 #[test]
482 fn test_simple_resolution() {
483 let temp = TempDir::new().unwrap();
484 let root = temp.path();
485
486 create_test_file(root, "a.preql", "import b;");
487 create_test_file(root, "b.preql", "// no imports");
488
489 let a_path = root.join("a.preql");
490 let mut resolver = ImportResolver::new();
491 let graph = resolver.resolve(&a_path).unwrap();
492
493 assert_eq!(graph.order.len(), 2);
494 let b_idx = graph
496 .order
497 .iter()
498 .position(|p| p.ends_with("b.preql"))
499 .unwrap();
500 let a_idx = graph
501 .order
502 .iter()
503 .position(|p| p.ends_with("a.preql"))
504 .unwrap();
505 assert!(b_idx < a_idx, "b should come before a");
506 }
507
508 #[test]
509 fn test_datasource_declaration_ordering() {
510 let temp = TempDir::new().unwrap();
511 let root = temp.path();
512
513 create_test_file(root, "a.preql", "import b;");
518 create_test_file(
519 root,
520 "b.preql",
521 r#"
522 datasource orders (
523 id: key
524 )
525 address db.orders;
526 "#,
527 );
528 create_test_file(root, "c.preql", "persist orders;");
529
530 let a_path = root.join("a.preql");
532 let mut resolver = ImportResolver::new();
533 let graph = resolver.resolve(&a_path).unwrap();
534
535 assert!(graph.datasource_declarations.contains_key("orders"));
537
538 let b_idx = graph.order.iter().position(|p| p.ends_with("b.preql")).unwrap();
540 let a_idx = graph.order.iter().position(|p| p.ends_with("a.preql")).unwrap();
541 assert!(b_idx < a_idx, "b should come before a due to import");
542 }
543
544 #[test]
545 fn test_persist_before_declare() {
546 let temp = TempDir::new().unwrap();
547 let root = temp.path();
548
549 create_test_file(root, "updater.preql", "persist orders;");
554 create_test_file(
555 root,
556 "declarer.preql",
557 r#"
558 import updater;
559 datasource orders (
560 id: key
561 )
562 address db.orders;
563 "#,
564 );
565
566 let declarer_path = root.join("declarer.preql");
567 let mut resolver = ImportResolver::new();
568 let graph = resolver.resolve(&declarer_path).unwrap();
569
570 let updater_idx = graph
571 .order
572 .iter()
573 .position(|p| p.ends_with("updater.preql"))
574 .unwrap();
575 let declarer_idx = graph
576 .order
577 .iter()
578 .position(|p| p.ends_with("declarer.preql"))
579 .unwrap();
580
581 assert!(
582 updater_idx < declarer_idx,
583 "updater (persist) should come before declarer (datasource)"
584 );
585 }
586
587 #[test]
588 fn test_full_dependency_chain() {
589 let temp = TempDir::new().unwrap();
590 let root = temp.path();
591
592 create_test_file(
603 root,
604 "base.preql",
605 r#"
606 datasource orders (
607 id: key,
608 amount: metric
609 )
610 address db.orders;
611 "#,
612 );
613 create_test_file(
614 root,
615 "updater.preql",
616 r#"
617 persist orders where amount > 100;
618 "#,
619 );
620 create_test_file(
621 root,
622 "consumer.preql",
623 r#"
624 import base;
625 // uses orders datasource
626 "#,
627 );
628
629 create_test_file(
631 root,
632 "main.preql",
633 r#"
634 import updater;
635 import consumer;
636 "#,
637 );
638
639 let main_path = root.join("main.preql");
640 let mut resolver = ImportResolver::new();
641 let graph = resolver.resolve(&main_path).unwrap();
642
643 assert_eq!(graph.order.len(), 4);
644
645 let updater_idx = graph
646 .order
647 .iter()
648 .position(|p| p.ends_with("updater.preql"))
649 .unwrap();
650 let base_idx = graph
651 .order
652 .iter()
653 .position(|p| p.ends_with("base.preql"))
654 .unwrap();
655 let consumer_idx = graph
656 .order
657 .iter()
658 .position(|p| p.ends_with("consumer.preql"))
659 .unwrap();
660 let main_idx = graph
661 .order
662 .iter()
663 .position(|p| p.ends_with("main.preql"))
664 .unwrap();
665
666 assert!(
668 updater_idx < base_idx,
669 "updater should come before base: updater={}, base={}",
670 updater_idx,
671 base_idx
672 );
673
674 assert!(
676 base_idx < consumer_idx,
677 "base should come before consumer: base={}, consumer={}",
678 base_idx,
679 consumer_idx
680 );
681
682 assert!(
684 updater_idx < main_idx && base_idx < main_idx && consumer_idx < main_idx,
685 "main should come after all others"
686 );
687 }
688
689 #[test]
690 fn test_multiple_datasources() {
691 let temp = TempDir::new().unwrap();
692 let root = temp.path();
693
694 create_test_file(
695 root,
696 "models.preql",
697 r#"
698 datasource customers (
699 id: key
700 )
701 address db.customers;
702
703 datasource orders (
704 id: key,
705 customer_id
706 )
707 address db.orders;
708 "#,
709 );
710
711 let models_path = root.join("models.preql");
712 let mut resolver = ImportResolver::new();
713 let graph = resolver.resolve(&models_path).unwrap();
714
715 assert_eq!(graph.datasource_declarations.len(), 2);
716 assert!(graph.datasource_declarations.contains_key("customers"));
717 assert!(graph.datasource_declarations.contains_key("orders"));
718 }
719
720 #[test]
721 fn test_datasource_metadata_reaches_file_nodes() {
722 let temp = TempDir::new().unwrap();
723 let root = temp.path();
724
725 create_test_file(
726 root,
727 "models.preql",
728 r#"
729 root partial datasource raw_events (
730 id: key
731 )
732 file `./ingest.py`;
733
734 datasource events (
735 id: key
736 )
737 address warehouse.events
738 partition by id;
739 "#,
740 );
741
742 let models_path = root.join("models.preql");
743 let mut resolver = ImportResolver::new();
744 let graph = resolver.resolve(&models_path).unwrap();
745
746 let node = graph
747 .files
748 .values()
749 .find(|n| n.path.ends_with("models.preql"))
750 .unwrap();
751
752 let raw = node
753 .datasources
754 .iter()
755 .find(|d| d.name == "raw_events")
756 .unwrap();
757 assert_eq!(raw.address_kind, "file");
758 assert_eq!(raw.address.as_deref(), Some("`./ingest.py`"));
759 assert!(raw.is_root);
760 assert!(raw.is_partial);
761 assert!(!raw.is_partitioned);
762
763 let events = node
764 .datasources
765 .iter()
766 .find(|d| d.name == "events")
767 .unwrap();
768 assert_eq!(events.address_kind, "literal");
769 assert_eq!(events.address.as_deref(), Some("warehouse.events"));
770 assert!(!events.is_root);
771 assert!(!events.is_partial);
772 assert!(events.is_partitioned);
773 }
774}