Skip to main content

trilogy_parser/
resolver.rs

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/// Information about a single import
30#[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/// Information about a datasource declaration
50#[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/// Information about a persist statement
75#[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/// A node in the dependency graph
91#[derive(Debug, Clone, Serialize)]
92pub struct FileNode {
93    /// Absolute path to the file
94    pub path: PathBuf,
95    /// Relative path from the root
96    pub relative_path: PathBuf,
97    /// List of imports in this file
98    pub imports: Vec<ImportInfo>,
99    /// List of datasources declared in this file
100    pub datasources: Vec<DatasourceInfo>,
101    /// List of persist statements in this file
102    pub persists: Vec<PersistInfo>,
103    /// List of resolved import dependencies (paths to other files)
104    pub import_dependencies: Vec<PathBuf>,
105    /// Datasources this file updates (via persist)
106    pub updates_datasources: Vec<String>,
107    /// Datasources this file declares
108    pub declares_datasources: Vec<String>,
109    /// Datasources this file depends on (through imports)
110    pub depends_on_datasources: Vec<String>,
111}
112
113/// Result of dependency resolution
114#[derive(Debug, Clone, Serialize)]
115pub struct DependencyGraph {
116    /// Root file that was analyzed
117    pub root: PathBuf,
118    /// All files in dependency order (dependencies come before dependents)
119    pub order: Vec<PathBuf>,
120    /// Detailed information about each file
121    pub files: HashMap<PathBuf, FileNode>,
122    /// Mapping of datasource names to the file that declares them
123    pub datasource_declarations: HashMap<String, PathBuf>,
124    /// Mapping of datasource names to files that update them (via persist)
125    pub datasource_updaters: HashMap<String, Vec<PathBuf>>,
126    /// Any errors encountered (non-fatal)
127    pub warnings: Vec<String>,
128}
129
130/// Resolver for PreQL import dependencies
131pub struct ImportResolver {
132    /// Cache of parsed files
133    parsed_cache: HashMap<PathBuf, ParsedFile>,
134    /// Warnings accumulated during resolution
135    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    /// Resolve all dependencies starting from a root file
147    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        // BFS to collect all files first
157        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(&current_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(&current_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(), // Will be computed later
234                },
235            );
236        }
237
238        // Build datasource mappings
239        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        // Compute transitive datasource dependencies through imports
265        self.compute_datasource_dependencies(&mut files, &datasource_declarations);
266
267        // Topological sort with datasource-aware ordering
268        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    /// Compute which datasources each file depends on through its import chain
301    fn compute_datasource_dependencies(
302        &self,
303        files: &mut HashMap<PathBuf, FileNode>,
304        _datasource_declarations: &HashMap<String, PathBuf>,
305    ) {
306        // For each file, find all datasources reachable through imports
307        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(&current) {
316                    continue;
317                }
318                visited.insert(current.clone());
319
320                if let Some(node) = files.get(&current) {
321                    // Add datasources declared in imported files (not the file itself for the starting file)
322                    if current != path {
323                        for ds in &node.declares_datasources {
324                            reachable_datasources.insert(ds.clone());
325                        }
326                    }
327
328                    // Follow imports
329                    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    /// Topological sort with datasource-aware dependency edges
344    ///
345    /// The ordering rules are:
346    /// 1. Standard import dependencies (imported files run before importing files) - HIGHEST PRIORITY
347    /// 2. Files that UPDATE a datasource (via persist) must run BEFORE files that DECLARE that datasource
348    ///    BUT ONLY if the updater doesn't import the declarer (import takes precedence)
349    /// 3. Files that DECLARE a datasource must run BEFORE files that IMPORT something containing that datasource
350    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        // Build adjacency list with all dependency edges
357        // Edge A -> B means A must be processed before B
358        let mut edges: HashMap<PathBuf, HashSet<PathBuf>> = HashMap::new();
359
360        // Initialize
361        for path in files.keys() {
362            edges.insert(path.clone(), HashSet::new());
363        }
364
365        // Add edges for each dependency type
366        for (path, node) in files {
367            // Rule 1: Import dependencies - imported file must run before importing file (HIGHEST PRIORITY)
368            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            // Rule 2: Files that UPDATE a datasource must run BEFORE files that DECLARE it
375            // BUT ONLY if the updater doesn't import the declarer
376            // If this file declares a datasource, all files that update it must run first
377            // (unless they import this file, in which case import dependency takes precedence)
378            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                            // Check if updater imports this file (directly or transitively)
383                            let updater_node = files.get(updater_path).unwrap();
384                            let imports_declarer = updater_node.import_dependencies.contains(path);
385
386                            // Only add persist-before-declare edge if there's no import dependency
387                            if !imports_declarer {
388                                // updater must run before declarer
389                                edges.get_mut(updater_path).unwrap().insert(path.clone());
390                            }
391                        }
392                    }
393                }
394            }
395
396            // Rule 3: Files that DECLARE a datasource must run BEFORE files that depend on it (through imports)
397            // If this file depends on a datasource (through imports), the declaring file must run first
398            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                        // declarer must run before dependent
402                        edges.get_mut(declaring_path).unwrap().insert(path.clone());
403                    }
404                }
405            }
406        }
407
408        // Kahn's algorithm
409        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        // Start with nodes that have no incoming edges (no dependencies)
424        for (path, &degree) 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(&current) {
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        // Check for cycles
445        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        // b should come before a
495        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        // a.preql imports b, which declares datasource "orders"
514        // c.preql persists to "orders"
515        // Order should be: c (updates orders) -> b (declares orders) -> a (imports b which has orders)
516
517        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        // Start from a file that imports all others (or just test individual relationships)
531        let a_path = root.join("a.preql");
532        let mut resolver = ImportResolver::new();
533        let graph = resolver.resolve(&a_path).unwrap();
534
535        // Verify datasource is tracked
536        assert!(graph.datasource_declarations.contains_key("orders"));
537        
538        // b should come before a (import dependency)
539        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        // updater.preql persists to "orders"
550        // declarer.preql declares datasource "orders" and imports updater
551        // Order should be: updater (updates orders) -> declarer (declares orders)
552
553        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        // Setup:
593        // - base.preql: declares datasource "orders"
594        // - updater.preql: persists to "orders" (doesn't import base)
595        // - consumer.preql: imports base (uses orders datasource)
596        //
597        // Expected order: updater -> base -> consumer
598        // Because:
599        // - updater updates orders, so must run before base (which declares it)
600        // - base declares orders, so must run before consumer (which imports base and thus depends on orders)
601
602        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 an entry point that imports everything
630        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        // updater must come before base (persist before declare)
667        assert!(
668            updater_idx < base_idx,
669            "updater should come before base: updater={}, base={}",
670            updater_idx,
671            base_idx
672        );
673
674        // base must come before consumer (consumer imports base which has the datasource)
675        assert!(
676            base_idx < consumer_idx,
677            "base should come before consumer: base={}, consumer={}",
678            base_idx,
679            consumer_idx
680        );
681
682        // main comes last (imports everything)
683        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}