use crate::parser::parse_file;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct FileInfo {
pub path: PathBuf,
pub datasources: Vec<String>,
pub persists: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct DirectoryGraph {
pub files: HashMap<PathBuf, FileInfo>,
pub imports: HashMap<PathBuf, Vec<PathBuf>>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct Edge {
pub from: PathBuf,
pub to: PathBuf,
pub reason: EdgeReason,
}
#[derive(Debug, Clone)]
pub enum EdgeReason {
Import,
PersistBeforeDeclare { datasource: String },
TransitivePersistOrder {
upstream_datasource: String,
downstream_datasource: String,
},
}
pub fn process_directory_with_imports(
initial_files: Vec<PathBuf>,
) -> Result<DirectoryGraph, String> {
let mut all_imports: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
let mut files_info: HashMap<PathBuf, FileInfo> = HashMap::new();
let mut files_to_process = initial_files;
let mut processed_files: HashSet<PathBuf> = HashSet::new();
let mut warnings = Vec::new();
while let Some(file) = files_to_process.pop() {
let canonical = match fs::canonicalize(&file) {
Ok(c) => c,
Err(e) => {
warnings.push(format!("Failed to canonicalize {}: {}", file.display(), e));
continue;
}
};
if processed_files.contains(&canonical) {
continue;
}
processed_files.insert(canonical.clone());
let content = match fs::read_to_string(&file) {
Ok(c) => c,
Err(e) => {
warnings.push(format!("Failed to read {}: {}", file.display(), e));
continue;
}
};
let parsed = match parse_file(&content) {
Ok(p) => p,
Err(e) => {
warnings.push(format!("Failed to parse {}: {}", file.display(), e));
continue;
}
};
let mut resolved_imports = Vec::new();
let file_dir = file.parent().unwrap_or(std::path::Path::new("."));
for import in &parsed.imports {
if import.is_stdlib {
continue;
}
if let Some(resolved) = import.resolve(file_dir) {
if resolved.exists() {
if let Ok(resolved_canonical) = fs::canonicalize(&resolved) {
resolved_imports.push(resolved_canonical.clone());
if !processed_files.contains(&resolved_canonical) {
files_to_process.push(resolved_canonical);
}
}
}
}
}
let datasources: Vec<String> = parsed.datasources.iter().map(|d| d.name.clone()).collect();
let persists: Vec<String> = parsed.persists.iter().map(|p| p.target_datasource.clone()).collect();
all_imports.insert(canonical.clone(), resolved_imports);
files_info.insert(
canonical.clone(),
FileInfo {
path: canonical,
datasources,
persists,
},
);
}
Ok(DirectoryGraph {
files: files_info,
imports: all_imports,
warnings,
})
}
pub fn build_edges(graph: &DirectoryGraph) -> Vec<Edge> {
let mut edges = Vec::new();
let known_files: HashSet<PathBuf> = graph.files.keys().cloned().collect();
for (file, imports) in &graph.imports {
for resolved_path in imports {
if !known_files.contains(resolved_path) {
continue;
}
edges.push(Edge {
from: resolved_path.clone(),
to: file.clone(),
reason: EdgeReason::Import,
});
}
}
for (declarer_path, declarer_info) in &graph.files {
for ds_name in &declarer_info.datasources {
for (updater_path, updater_info) in &graph.files {
if updater_path == declarer_path {
continue;
}
if updater_info.persists.contains(ds_name) {
edges.push(Edge {
from: updater_path.clone(),
to: declarer_path.clone(),
reason: EdgeReason::PersistBeforeDeclare {
datasource: ds_name.clone(),
},
});
}
}
}
}
let persist_edges: HashSet<(PathBuf, PathBuf)> = edges
.iter()
.filter(|e| matches!(e.reason, EdgeReason::PersistBeforeDeclare { .. }))
.map(|e| (e.from.clone(), e.to.clone()))
.collect();
edges.retain(|edge| {
if matches!(edge.reason, EdgeReason::Import) {
let reverse = (edge.to.clone(), edge.from.clone());
!persist_edges.contains(&reverse)
} else {
true
}
});
let transitive_imports = compute_transitive_imports(&graph.imports);
let mut datasource_to_declarer: HashMap<String, PathBuf> = HashMap::new();
for (path, info) in &graph.files {
for ds_name in &info.datasources {
datasource_to_declarer.insert(ds_name.clone(), path.clone());
}
}
let mut datasource_to_updaters: HashMap<String, Vec<PathBuf>> = HashMap::new();
for (path, info) in &graph.files {
for persist_target in &info.persists {
datasource_to_updaters
.entry(persist_target.clone())
.or_default()
.push(path.clone());
}
}
for (ds_a, declarer_a) in &datasource_to_declarer {
for (ds_b, declarer_b) in &datasource_to_declarer {
if ds_a == ds_b || declarer_a == declarer_b {
continue;
}
if let Some(b_imports) = transitive_imports.get(declarer_b) {
if b_imports.contains(declarer_a) {
if let (Some(updaters_a), Some(updaters_b)) = (
datasource_to_updaters.get(ds_a),
datasource_to_updaters.get(ds_b),
) {
for updater_a in updaters_a {
for updater_b in updaters_b {
if updater_a != updater_b && known_files.contains(updater_a) && known_files.contains(updater_b) {
edges.push(Edge {
from: updater_a.clone(),
to: updater_b.clone(),
reason: EdgeReason::TransitivePersistOrder {
upstream_datasource: ds_a.clone(),
downstream_datasource: ds_b.clone(),
},
});
}
}
}
}
}
}
}
}
edges
}
fn compute_transitive_imports(
imports: &HashMap<PathBuf, Vec<PathBuf>>,
) -> HashMap<PathBuf, HashSet<PathBuf>> {
let mut result: HashMap<PathBuf, HashSet<PathBuf>> = HashMap::new();
for file in imports.keys() {
let mut visited: HashSet<PathBuf> = HashSet::new();
let mut stack: Vec<PathBuf> = imports.get(file).cloned().unwrap_or_default();
while let Some(current) = stack.pop() {
if visited.contains(¤t) {
continue;
}
visited.insert(current.clone());
if let Some(current_imports) = imports.get(¤t) {
for imp in current_imports {
if !visited.contains(imp) {
stack.push(imp.clone());
}
}
}
}
result.insert(file.clone(), visited);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn create_test_file(dir: &std::path::Path, name: &str, content: &str) -> PathBuf {
let path = dir.join(name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, content).unwrap();
path
}
#[test]
fn test_transitive_persist_order() {
let temp = TempDir::new().unwrap();
let root = temp.path();
create_test_file(
root,
"order_product_items.preql",
r#"
datasource order_product_items (
id: key
)
address db.opi;
"#,
);
create_test_file(
root,
"sales_reporting.preql",
r#"
import order_product_items;
datasource sales_reporting (
id: key
)
address db.sales;
"#,
);
create_test_file(
root,
"incremental_opi.preql",
r#"
import order_product_items;
persist order_product_items where id = 1;
"#,
);
create_test_file(
root,
"incremental_sales.preql",
r#"
import sales_reporting;
persist sales_reporting where id = 1;
"#,
);
let files: Vec<PathBuf> = fs::read_dir(root)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().map_or(false, |ext| ext == "preql"))
.collect();
let graph = process_directory_with_imports(files).unwrap();
let edges = build_edges(&graph);
let transitive_edge = edges.iter().find(|e| {
matches!(e.reason, EdgeReason::TransitivePersistOrder { .. })
});
assert!(
transitive_edge.is_some(),
"Expected a transitive persist order edge. Edges: {:?}",
edges.iter().map(|e| (e.from.file_name(), e.to.file_name(), &e.reason)).collect::<Vec<_>>()
);
let edge = transitive_edge.unwrap();
assert!(
edge.from.ends_with("incremental_opi.preql"),
"Expected from to be incremental_opi.preql, got {:?}",
edge.from
);
assert!(
edge.to.ends_with("incremental_sales.preql"),
"Expected to to be incremental_sales.preql, got {:?}",
edge.to
);
}
}