1use std::collections::{BTreeSet, HashMap};
2use std::path::Path;
3
4use anyhow::Result;
5use petgraph::stable_graph::NodeIndex;
6use serde::Deserialize;
7
8use crate::graph::types::*;
9
10#[derive(Debug, Default, Deserialize)]
12pub struct ManifestMetadata {
13 pub project_name: Option<String>,
14}
15
16#[derive(Debug, Default, Deserialize)]
18pub struct Manifest {
19 #[serde(default)]
21 pub metadata: ManifestMetadata,
22 #[serde(default)]
24 pub nodes: HashMap<String, ManifestNode>,
25 #[serde(default)]
27 pub sources: HashMap<String, ManifestSource>,
28 #[serde(default)]
30 pub exposures: HashMap<String, ManifestExposure>,
31}
32
33#[derive(Debug, Deserialize)]
35pub struct ManifestNode {
36 pub unique_id: String,
37 pub name: String,
38 pub resource_type: String,
39 #[serde(default)]
40 pub depends_on: DependsOn,
41 #[serde(default)]
42 pub config: ManifestConfig,
43 pub description: Option<String>,
44 pub path: Option<String>,
45 pub original_file_path: Option<String>,
48 #[serde(default)]
50 pub columns: HashMap<String, ManifestColumn>,
51 pub compiled_code: Option<String>,
53 #[serde(default)]
55 pub database: Option<String>,
56 #[serde(default)]
58 pub schema: Option<String>,
59}
60
61#[derive(Debug, Deserialize)]
63pub struct ManifestSource {
64 pub unique_id: String,
65 pub name: String,
66 pub source_name: String,
67 #[serde(default)]
68 pub resource_type: String,
69 pub description: Option<String>,
70 pub path: Option<String>,
71 pub original_file_path: Option<String>,
73 #[serde(default)]
75 pub columns: HashMap<String, ManifestColumn>,
76 #[serde(default)]
78 pub database: Option<String>,
79 #[serde(default)]
81 pub schema: Option<String>,
82 #[serde(default)]
84 pub identifier: Option<String>,
85}
86
87#[derive(Debug, Deserialize)]
89pub struct ManifestColumn {
90 pub name: String,
91}
92
93#[derive(Debug, Deserialize)]
95pub struct ManifestExposure {
96 pub unique_id: String,
97 pub name: String,
98 #[serde(default)]
99 pub depends_on: DependsOn,
100 pub description: Option<String>,
101 pub label: Option<String>,
102 #[serde(rename = "type")]
103 pub exposure_type: Option<String>,
104 pub url: Option<String>,
105 pub maturity: Option<String>,
106 pub owner: Option<ManifestExposureOwner>,
107}
108
109#[derive(Debug, Deserialize)]
111pub struct ManifestExposureOwner {
112 pub name: Option<String>,
113 pub email: Option<String>,
114}
115
116#[derive(Debug, Default, Deserialize)]
118pub struct DependsOn {
119 #[serde(default)]
120 pub nodes: Vec<String>,
121}
122
123#[derive(Debug, Default, Deserialize)]
125pub struct ManifestConfig {
126 pub materialized: Option<String>,
127 #[serde(default)]
128 pub tags: Vec<String>,
129}
130
131fn resource_type_to_node_type(resource_type: &str) -> NodeType {
133 match resource_type {
134 "model" => NodeType::Model,
135 "source" => NodeType::Source,
136 "seed" => NodeType::Seed,
137 "snapshot" => NodeType::Snapshot,
138 "test" => NodeType::Test,
139 "analysis" => NodeType::Model,
140 "exposure" => NodeType::Exposure,
141 _ => NodeType::Model,
142 }
143}
144
145fn simplify_unique_id(unique_id: &str, resource_type: &str) -> String {
150 let parts: Vec<&str> = unique_id.split('.').collect();
151 match resource_type {
152 "source" => {
153 if parts.len() >= 4 {
155 format!("{}.{}.{}", parts[0], parts[2], parts[3])
156 } else {
157 unique_id.to_string()
158 }
159 }
160 "test" => {
161 if parts.len() >= 3 {
163 format!("{}.{}", parts[0], parts[2])
164 } else {
165 unique_id.to_string()
166 }
167 }
168 _ => {
169 if parts.len() >= 3 {
171 format!("{}.{}", parts[0], parts[parts.len() - 1])
172 } else {
173 unique_id.to_string()
174 }
175 }
176 }
177}
178
179pub fn load_manifest(manifest_path: &Path) -> Result<Manifest> {
181 let content =
182 std::fs::read(manifest_path).map_err(|e| crate::error::DbtLineageError::FileReadError {
183 path: manifest_path.to_path_buf(),
184 source: e,
185 })?;
186
187 load_manifest_from_bytes(&content, manifest_path)
188}
189
190pub fn load_manifest_from_bytes(content: &[u8], manifest_path: &Path) -> Result<Manifest> {
191 let manifest: Manifest = serde_json::from_slice(content).map_err(|e| {
192 crate::error::DbtLineageError::ArtifactParseError {
193 path: manifest_path.to_path_buf(),
194 source: e,
195 }
196 })?;
197
198 Ok(manifest)
199}
200
201impl Manifest {
202 pub fn collect_sql_contents(&self) -> HashMap<String, String> {
210 let mut map = HashMap::new();
211 for (orig_id, node) in &self.nodes {
212 if let Some(ref code) = node.compiled_code {
213 let simple_id = simplify_unique_id(orig_id, &node.resource_type);
214 map.insert(simple_id, code.clone());
215 }
216 }
217 map
218 }
219
220 pub fn collect_file_paths(&self) -> BTreeSet<String> {
223 let mut paths = BTreeSet::new();
224 for node in self.nodes.values() {
225 let p = node.original_file_path.as_ref().or(node.path.as_ref());
226 if let Some(p) = p {
227 paths.insert(p.clone());
228 }
229 }
230 for source in self.sources.values() {
231 let p = source.original_file_path.as_ref().or(source.path.as_ref());
232 if let Some(p) = p {
233 paths.insert(p.clone());
234 }
235 }
236 paths
237 }
238}
239
240pub fn build_graph_from_manifest(manifest_path: &Path) -> Result<LineageGraph> {
242 let manifest = load_manifest(manifest_path)?;
243 build_graph_from_parsed_manifest(&manifest)
244}
245
246pub fn build_graph_from_parsed_manifest(manifest: &Manifest) -> Result<LineageGraph> {
249 let mut graph = LineageGraph::new();
250 let mut node_map: HashMap<String, NodeIndex> = HashMap::new();
252
253 add_source_nodes(&mut graph, &mut node_map, &manifest.sources);
255
256 add_regular_nodes(&mut graph, &mut node_map, &manifest.nodes);
258
259 add_exposure_nodes(&mut graph, &mut node_map, &manifest.exposures);
261
262 add_node_edges(&mut graph, &node_map, &manifest.nodes);
264
265 add_exposure_edges(&mut graph, &node_map, &manifest.exposures);
267
268 Ok(graph)
269}
270
271fn add_source_nodes(
272 graph: &mut LineageGraph,
273 node_map: &mut HashMap<String, NodeIndex>,
274 sources: &HashMap<String, ManifestSource>,
275) {
276 for (orig_id, source) in sources {
277 let simple_id = simplify_unique_id(orig_id, "source");
278 let label = format!("{}.{}", source.source_name, source.name);
279
280 let idx = graph.add_node(NodeData {
281 unique_id: simple_id.clone(),
282 label,
283 node_type: NodeType::Source,
284 file_path: source
285 .original_file_path
286 .as_ref()
287 .or(source.path.as_ref())
288 .map(|p| p.into()),
289 description: non_empty_string(&source.description),
290 materialization: None,
291 tags: vec![],
292 columns: {
293 let mut cols: Vec<String> = source.columns.keys().cloned().collect();
294 cols.sort();
295 cols
296 },
297 exposure: None,
298 });
299 node_map.insert(orig_id.clone(), idx);
300 node_map.insert(simple_id, idx);
302 }
303}
304
305fn add_regular_nodes(
306 graph: &mut LineageGraph,
307 node_map: &mut HashMap<String, NodeIndex>,
308 nodes: &HashMap<String, ManifestNode>,
309) {
310 for (orig_id, node) in nodes {
311 let node_type = resource_type_to_node_type(&node.resource_type);
312 let simple_id = simplify_unique_id(orig_id, &node.resource_type);
313
314 let idx = graph.add_node(NodeData {
315 unique_id: simple_id.clone(),
316 label: node.name.clone(),
317 node_type,
318 file_path: node
319 .original_file_path
320 .as_ref()
321 .or(node.path.as_ref())
322 .map(|p| p.into()),
323 description: non_empty_string(&node.description),
324 materialization: node.config.materialized.clone(),
325 tags: node.config.tags.clone(),
326 columns: {
327 let mut cols: Vec<String> = node.columns.keys().cloned().collect();
328 cols.sort();
329 cols
330 },
331 exposure: None,
332 });
333 node_map.insert(orig_id.clone(), idx);
334 node_map.insert(simple_id, idx);
335 }
336}
337
338fn add_exposure_nodes(
339 graph: &mut LineageGraph,
340 node_map: &mut HashMap<String, NodeIndex>,
341 exposures: &HashMap<String, ManifestExposure>,
342) {
343 for (orig_id, exposure) in exposures {
344 let simple_id = simplify_unique_id(orig_id, "exposure");
345
346 let idx = graph.add_node(NodeData {
347 unique_id: simple_id.clone(),
348 label: exposure.name.clone(),
349 node_type: NodeType::Exposure,
350 file_path: None,
351 description: non_empty_string(&exposure.description),
352 materialization: None,
353 tags: vec![],
354 columns: vec![],
355 exposure: Some(ExposureInfo {
356 label: non_empty_string(&exposure.label),
357 exposure_type: non_empty_string(&exposure.exposure_type),
358 url: non_empty_string(&exposure.url),
359 maturity: non_empty_string(&exposure.maturity),
360 owner: exposure.owner.as_ref().map(|o| OwnerInfo {
361 name: non_empty_string(&o.name),
362 email: non_empty_string(&o.email),
363 }),
364 }),
365 });
366 node_map.insert(orig_id.clone(), idx);
367 node_map.insert(simple_id, idx);
368 }
369}
370
371fn add_node_edges(
372 graph: &mut LineageGraph,
373 node_map: &HashMap<String, NodeIndex>,
374 nodes: &HashMap<String, ManifestNode>,
375) {
376 for (orig_id, node) in nodes {
377 let current_idx = match node_map.get(orig_id) {
378 Some(&idx) => idx,
379 None => continue,
380 };
381
382 let current_is_test = graph[current_idx].node_type == NodeType::Test;
385
386 for dep_id in &node.depends_on.nodes {
387 if let Some(&dep_idx) = node_map.get(dep_id) {
388 let edge_type = if current_is_test {
389 EdgeType::Test
390 } else {
391 infer_edge_type(dep_id)
392 };
393 graph.add_edge(dep_idx, current_idx, EdgeData::direct(edge_type));
394 }
395 }
396 }
397}
398
399fn add_exposure_edges(
400 graph: &mut LineageGraph,
401 node_map: &HashMap<String, NodeIndex>,
402 exposures: &HashMap<String, ManifestExposure>,
403) {
404 for (orig_id, exposure) in exposures {
405 let current_idx = match node_map.get(orig_id) {
406 Some(&idx) => idx,
407 None => continue,
408 };
409
410 for dep_id in &exposure.depends_on.nodes {
411 if let Some(&dep_idx) = node_map.get(dep_id) {
412 graph.add_edge(dep_idx, current_idx, EdgeData::direct(EdgeType::Exposure));
413 }
414 }
415 }
416}
417
418fn infer_edge_type(dep_unique_id: &str) -> EdgeType {
420 if dep_unique_id.starts_with("source.") {
421 EdgeType::Source
422 } else if dep_unique_id.starts_with("test.") {
423 EdgeType::Test
424 } else {
425 EdgeType::Ref
426 }
427}
428
429fn non_empty_string(s: &Option<String>) -> Option<String> {
431 s.as_ref().filter(|v| !v.trim().is_empty()).cloned()
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437 use std::fs;
438
439 #[test]
440 fn test_resource_type_to_node_type() {
441 assert_eq!(resource_type_to_node_type("model"), NodeType::Model);
442 assert_eq!(resource_type_to_node_type("source"), NodeType::Source);
443 assert_eq!(resource_type_to_node_type("seed"), NodeType::Seed);
444 assert_eq!(resource_type_to_node_type("snapshot"), NodeType::Snapshot);
445 assert_eq!(resource_type_to_node_type("test"), NodeType::Test);
446 assert_eq!(resource_type_to_node_type("analysis"), NodeType::Model);
447 assert_eq!(resource_type_to_node_type("exposure"), NodeType::Exposure);
448 assert_eq!(resource_type_to_node_type("unknown"), NodeType::Model);
449 }
450
451 #[test]
452 fn test_simplify_unique_id_model() {
453 assert_eq!(
454 simplify_unique_id("model.my_project.stg_orders", "model"),
455 "model.stg_orders"
456 );
457 }
458
459 #[test]
460 fn test_simplify_unique_id_source() {
461 assert_eq!(
462 simplify_unique_id("source.my_project.raw.orders", "source"),
463 "source.raw.orders"
464 );
465 }
466
467 #[test]
468 fn test_simplify_unique_id_short() {
469 assert_eq!(
470 simplify_unique_id("model.stg_orders", "model"),
471 "model.stg_orders"
472 );
473 }
474
475 #[test]
476 fn test_simplify_unique_id_source_short() {
477 assert_eq!(
478 simplify_unique_id("source.raw.orders", "source"),
479 "source.raw.orders"
480 );
481 }
482
483 #[test]
484 fn test_simplify_unique_id_test() {
485 assert_eq!(
487 simplify_unique_id(
488 "test.jaffle_shop.not_null_orders_order_id.cf6c17daed",
489 "test"
490 ),
491 "test.not_null_orders_order_id"
492 );
493 }
494
495 #[test]
496 fn test_simplify_unique_id_test_short() {
497 assert_eq!(
498 simplify_unique_id("test.not_null_orders_order_id", "test"),
499 "test.not_null_orders_order_id"
500 );
501 }
502
503 #[test]
504 fn test_infer_edge_type() {
505 assert_eq!(
506 infer_edge_type("source.my_project.raw.orders"),
507 EdgeType::Source
508 );
509 assert_eq!(
510 infer_edge_type("model.my_project.stg_orders"),
511 EdgeType::Ref
512 );
513 assert_eq!(infer_edge_type("test.my_project.some_test"), EdgeType::Test);
514 assert_eq!(infer_edge_type("seed.my_project.countries"), EdgeType::Ref);
515 }
516
517 #[test]
518 fn test_non_empty_string() {
519 assert_eq!(non_empty_string(&None), None);
520 assert_eq!(non_empty_string(&Some("".to_string())), None);
521 assert_eq!(non_empty_string(&Some(" ".to_string())), None);
522 assert_eq!(
523 non_empty_string(&Some("hello".to_string())),
524 Some("hello".to_string())
525 );
526 }
527
528 #[test]
529 fn test_build_graph_from_minimal_manifest() {
530 let manifest = Manifest {
531 nodes: HashMap::from([(
532 "model.proj.stg_orders".to_string(),
533 ManifestNode {
534 unique_id: "model.proj.stg_orders".to_string(),
535 name: "stg_orders".to_string(),
536 resource_type: "model".to_string(),
537 depends_on: DependsOn {
538 nodes: vec!["source.proj.raw.orders".to_string()],
539 },
540 config: ManifestConfig {
541 materialized: Some("view".to_string()),
542 tags: vec!["staging".to_string()],
543 },
544 description: Some("Staged orders".to_string()),
545 path: Some("models/staging/stg_orders.sql".to_string()),
546 original_file_path: None,
547 columns: HashMap::new(),
548 compiled_code: None,
549 database: None,
550 schema: None,
551 },
552 )]),
553 sources: HashMap::from([(
554 "source.proj.raw.orders".to_string(),
555 ManifestSource {
556 unique_id: "source.proj.raw.orders".to_string(),
557 name: "orders".to_string(),
558 source_name: "raw".to_string(),
559 resource_type: "source".to_string(),
560 description: Some("Raw orders table".to_string()),
561 path: Some("models/staging/schema.yml".to_string()),
562 original_file_path: None,
563 columns: HashMap::new(),
564 database: None,
565 schema: None,
566 identifier: None,
567 },
568 )]),
569 ..Default::default()
570 };
571
572 let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
573
574 assert_eq!(graph.node_count(), 2);
575 assert_eq!(graph.edge_count(), 1);
576
577 let model = graph
579 .node_indices()
580 .find(|&i| graph[i].node_type == NodeType::Model)
581 .expect("Should have a model node");
582 assert_eq!(graph[model].label, "stg_orders");
583 assert_eq!(graph[model].unique_id, "model.stg_orders");
584 assert_eq!(graph[model].materialization.as_deref(), Some("view"));
585 assert_eq!(graph[model].tags, vec!["staging"]);
586 assert_eq!(graph[model].description.as_deref(), Some("Staged orders"));
587
588 let source = graph
590 .node_indices()
591 .find(|&i| graph[i].node_type == NodeType::Source)
592 .expect("Should have a source node");
593 assert_eq!(graph[source].label, "raw.orders");
594 assert_eq!(graph[source].unique_id, "source.raw.orders");
595 }
596
597 #[test]
598 fn test_build_graph_with_exposures() {
599 let manifest = Manifest {
600 nodes: HashMap::from([(
601 "model.proj.orders".to_string(),
602 ManifestNode {
603 unique_id: "model.proj.orders".to_string(),
604 name: "orders".to_string(),
605 resource_type: "model".to_string(),
606 depends_on: DependsOn::default(),
607 config: ManifestConfig::default(),
608 description: None,
609 path: None,
610 original_file_path: None,
611 columns: HashMap::new(),
612 compiled_code: None,
613 database: None,
614 schema: None,
615 },
616 )]),
617 sources: HashMap::new(),
618 exposures: HashMap::from([(
619 "exposure.proj.weekly_report".to_string(),
620 ManifestExposure {
621 unique_id: "exposure.proj.weekly_report".to_string(),
622 name: "weekly_report".to_string(),
623 depends_on: DependsOn {
624 nodes: vec!["model.proj.orders".to_string()],
625 },
626 description: Some("Weekly dashboard".to_string()),
627 label: None,
628 exposure_type: None,
629 url: None,
630 maturity: None,
631 owner: None,
632 },
633 )]),
634 ..Default::default()
635 };
636
637 let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
638 assert_eq!(graph.node_count(), 2);
639 assert_eq!(graph.edge_count(), 1);
640
641 let exposure = graph
642 .node_indices()
643 .find(|&i| graph[i].node_type == NodeType::Exposure)
644 .expect("Should have an exposure node");
645 assert_eq!(graph[exposure].label, "weekly_report");
646 assert_eq!(
647 graph[exposure].description.as_deref(),
648 Some("Weekly dashboard")
649 );
650 }
651
652 #[test]
653 fn test_exposure_metadata_parsed() {
654 let manifest = Manifest {
655 nodes: HashMap::new(),
656 sources: HashMap::new(),
657 exposures: HashMap::from([(
658 "exposure.proj.dashboard".to_string(),
659 ManifestExposure {
660 unique_id: "exposure.proj.dashboard".to_string(),
661 name: "dashboard".to_string(),
662 depends_on: DependsOn { nodes: vec![] },
663 description: Some("Main dashboard".to_string()),
664 label: Some("Main Dashboard".to_string()),
665 exposure_type: Some("dashboard".to_string()),
666 url: Some("https://bi.example.com".to_string()),
667 maturity: Some("high".to_string()),
668 owner: Some(ManifestExposureOwner {
669 name: Some("Data Team".to_string()),
670 email: Some("data@example.com".to_string()),
671 }),
672 },
673 )]),
674 ..Default::default()
675 };
676
677 let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
678 let exp_idx = graph
679 .node_indices()
680 .find(|&i| graph[i].node_type == NodeType::Exposure)
681 .expect("Should have an exposure node");
682 let exp = &graph[exp_idx];
683
684 let info = exp.exposure.as_ref().expect("Should have exposure info");
685 assert_eq!(info.label.as_deref(), Some("Main Dashboard"));
686 assert_eq!(info.exposure_type.as_deref(), Some("dashboard"));
687 assert_eq!(info.url.as_deref(), Some("https://bi.example.com"));
688 assert_eq!(info.maturity.as_deref(), Some("high"));
689
690 let owner = info.owner.as_ref().expect("Should have owner");
691 assert_eq!(owner.name.as_deref(), Some("Data Team"));
692 assert_eq!(owner.email.as_deref(), Some("data@example.com"));
693 }
694
695 #[test]
696 fn test_exposure_metadata_from_fixture() {
697 let manifest_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
698 .join("../../tests/fixtures/simple_project/target/manifest.json");
699 let graph = build_graph_from_manifest(&manifest_path).unwrap();
700
701 let exp_idx = graph
702 .node_indices()
703 .find(|&i| graph[i].node_type == NodeType::Exposure)
704 .expect("Should have an exposure node from fixture");
705 let exp = &graph[exp_idx];
706 assert_eq!(exp.label, "weekly_report");
707
708 let info = exp.exposure.as_ref().expect("Should have exposure info");
709 assert_eq!(info.label.as_deref(), Some("Weekly Report"));
710 assert_eq!(info.exposure_type.as_deref(), Some("dashboard"));
711 assert_eq!(info.url.as_deref(), Some("https://bi.example.com/weekly"));
712 assert_eq!(info.maturity.as_deref(), Some("high"));
713
714 let owner = info.owner.as_ref().expect("Should have owner");
715 assert_eq!(owner.name.as_deref(), Some("Data Team"));
716 assert_eq!(owner.email.as_deref(), Some("data@example.com"));
717 }
718
719 #[test]
720 fn test_build_graph_with_seeds_and_snapshots() {
721 let manifest = Manifest {
722 nodes: HashMap::from([
723 (
724 "seed.proj.countries".to_string(),
725 ManifestNode {
726 unique_id: "seed.proj.countries".to_string(),
727 name: "countries".to_string(),
728 resource_type: "seed".to_string(),
729 depends_on: DependsOn::default(),
730 config: ManifestConfig::default(),
731 description: None,
732 path: Some("seeds/countries.csv".to_string()),
733 original_file_path: None,
734 columns: HashMap::new(),
735 compiled_code: None,
736 database: None,
737 schema: None,
738 },
739 ),
740 (
741 "snapshot.proj.snap_orders".to_string(),
742 ManifestNode {
743 unique_id: "snapshot.proj.snap_orders".to_string(),
744 name: "snap_orders".to_string(),
745 resource_type: "snapshot".to_string(),
746 depends_on: DependsOn::default(),
747 config: ManifestConfig {
748 materialized: Some("snapshot".to_string()),
749 tags: vec![],
750 },
751 description: None,
752 path: Some("snapshots/snap_orders.sql".to_string()),
753 original_file_path: None,
754 columns: HashMap::new(),
755 compiled_code: None,
756 database: None,
757 schema: None,
758 },
759 ),
760 ]),
761 sources: HashMap::new(),
762 ..Default::default()
763 };
764
765 let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
766 assert_eq!(graph.node_count(), 2);
767
768 let seed = graph
769 .node_indices()
770 .find(|&i| graph[i].node_type == NodeType::Seed)
771 .expect("Should have a seed node");
772 assert_eq!(graph[seed].label, "countries");
773
774 let snap = graph
775 .node_indices()
776 .find(|&i| graph[i].node_type == NodeType::Snapshot)
777 .expect("Should have a snapshot node");
778 assert_eq!(graph[snap].label, "snap_orders");
779 }
780
781 #[test]
782 fn test_build_graph_with_tests() {
783 let manifest = Manifest {
784 nodes: HashMap::from([
785 (
786 "model.proj.orders".to_string(),
787 ManifestNode {
788 unique_id: "model.proj.orders".to_string(),
789 name: "orders".to_string(),
790 resource_type: "model".to_string(),
791 depends_on: DependsOn::default(),
792 config: ManifestConfig::default(),
793 description: None,
794 path: None,
795 original_file_path: None,
796 columns: HashMap::new(),
797 compiled_code: None,
798 database: None,
799 schema: None,
800 },
801 ),
802 (
803 "test.proj.assert_positive".to_string(),
804 ManifestNode {
805 unique_id: "test.proj.assert_positive".to_string(),
806 name: "assert_positive".to_string(),
807 resource_type: "test".to_string(),
808 depends_on: DependsOn {
809 nodes: vec!["model.proj.orders".to_string()],
810 },
811 config: ManifestConfig::default(),
812 description: None,
813 path: Some("tests/assert_positive.sql".to_string()),
814 original_file_path: None,
815 columns: HashMap::new(),
816 compiled_code: None,
817 database: None,
818 schema: None,
819 },
820 ),
821 ]),
822 sources: HashMap::new(),
823 ..Default::default()
824 };
825
826 let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
827 assert_eq!(graph.node_count(), 2);
828 assert_eq!(graph.edge_count(), 1);
829
830 let test_node = graph
831 .node_indices()
832 .find(|&i| graph[i].node_type == NodeType::Test)
833 .expect("Should have a test node");
834 assert_eq!(graph[test_node].label, "assert_positive");
835
836 use petgraph::visit::IntoEdgeReferences;
838 let edge = graph.edge_references().next().unwrap();
839 assert_eq!(edge.weight().edge_type, EdgeType::Test);
840 }
841
842 #[test]
843 fn test_build_graph_empty_manifest() {
844 let manifest = Manifest {
845 nodes: HashMap::new(),
846 sources: HashMap::new(),
847 ..Default::default()
848 };
849
850 let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
851 assert_eq!(graph.node_count(), 0);
852 assert_eq!(graph.edge_count(), 0);
853 }
854
855 #[test]
856 fn test_build_graph_missing_dependency() {
857 let manifest = Manifest {
859 nodes: HashMap::from([(
860 "model.proj.orders".to_string(),
861 ManifestNode {
862 unique_id: "model.proj.orders".to_string(),
863 name: "orders".to_string(),
864 resource_type: "model".to_string(),
865 depends_on: DependsOn {
866 nodes: vec!["model.proj.nonexistent".to_string()],
867 },
868 config: ManifestConfig::default(),
869 description: None,
870 path: None,
871 original_file_path: None,
872 columns: HashMap::new(),
873 compiled_code: None,
874 database: None,
875 schema: None,
876 },
877 )]),
878 sources: HashMap::new(),
879 ..Default::default()
880 };
881
882 let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
883 assert_eq!(graph.node_count(), 1);
884 assert_eq!(graph.edge_count(), 0); }
886
887 #[test]
888 fn test_build_graph_optional_fields() {
889 let manifest = Manifest {
890 nodes: HashMap::from([(
891 "model.proj.bare".to_string(),
892 ManifestNode {
893 unique_id: "model.proj.bare".to_string(),
894 name: "bare".to_string(),
895 resource_type: "model".to_string(),
896 depends_on: DependsOn::default(),
897 config: ManifestConfig {
898 materialized: None,
899 tags: vec![],
900 },
901 description: None,
902 path: None,
903 original_file_path: None,
904 columns: HashMap::new(),
905 compiled_code: None,
906 database: None,
907 schema: None,
908 },
909 )]),
910 sources: HashMap::new(),
911 ..Default::default()
912 };
913
914 let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
915 let node = &graph[graph.node_indices().next().unwrap()];
916 assert!(node.description.is_none());
917 assert!(node.materialization.is_none());
918 assert!(node.tags.is_empty());
919 assert!(node.file_path.is_none());
920 }
921
922 #[test]
923 fn test_build_graph_from_manifest_file() {
924 let tmp = tempfile::tempdir().unwrap();
925 let manifest_path = tmp.path().join("manifest.json");
926
927 let manifest_json = r#"{
928 "nodes": {
929 "model.proj.stg_orders": {
930 "unique_id": "model.proj.stg_orders",
931 "name": "stg_orders",
932 "resource_type": "model",
933 "depends_on": { "nodes": ["source.proj.raw.orders"] },
934 "config": { "materialized": "view", "tags": [] },
935 "description": "Staged orders",
936 "path": "models/staging/stg_orders.sql"
937 }
938 },
939 "sources": {
940 "source.proj.raw.orders": {
941 "unique_id": "source.proj.raw.orders",
942 "name": "orders",
943 "source_name": "raw",
944 "resource_type": "source",
945 "description": "Raw orders",
946 "path": "models/staging/schema.yml"
947 }
948 },
949 "exposures": {}
950 }"#;
951
952 fs::write(&manifest_path, manifest_json).unwrap();
953
954 let graph = build_graph_from_manifest(&manifest_path).unwrap();
955 assert_eq!(graph.node_count(), 2);
956 assert_eq!(graph.edge_count(), 1);
957 }
958
959 #[test]
960 fn test_build_graph_from_manifest_file_not_found() {
961 let result = build_graph_from_manifest(Path::new("/nonexistent/manifest.json"));
962 assert!(result.is_err());
963 }
964
965 #[test]
966 fn test_build_graph_from_manifest_invalid_json() {
967 let tmp = tempfile::tempdir().unwrap();
968 let manifest_path = tmp.path().join("manifest.json");
969 fs::write(&manifest_path, "not valid json").unwrap();
970
971 let result = build_graph_from_manifest(&manifest_path);
972 assert!(result.is_err());
973 }
974
975 #[test]
976 fn test_original_file_path_preferred_over_path() {
977 let manifest = Manifest {
982 nodes: HashMap::from([(
983 "model.proj.stg_orders".to_string(),
984 ManifestNode {
985 unique_id: "model.proj.stg_orders".to_string(),
986 name: "stg_orders".to_string(),
987 resource_type: "model".to_string(),
988 depends_on: DependsOn::default(),
989 config: ManifestConfig::default(),
990 description: None,
991 path: Some("staging/stg_orders.sql".to_string()),
992 original_file_path: Some("models/staging/stg_orders.sql".to_string()),
993 columns: HashMap::new(),
994 compiled_code: None,
995 database: None,
996 schema: None,
997 },
998 )]),
999 sources: HashMap::new(),
1000 ..Default::default()
1001 };
1002
1003 let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
1004 let node = &graph[graph.node_indices().next().unwrap()];
1005 assert_eq!(
1006 node.file_path.as_ref().map(|p| p.to_str().unwrap()),
1007 Some("models/staging/stg_orders.sql")
1008 );
1009 }
1010
1011 #[test]
1012 fn test_build_graph_analysis_maps_to_model() {
1013 let manifest = Manifest {
1014 nodes: HashMap::from([(
1015 "analysis.proj.my_analysis".to_string(),
1016 ManifestNode {
1017 unique_id: "analysis.proj.my_analysis".to_string(),
1018 name: "my_analysis".to_string(),
1019 resource_type: "analysis".to_string(),
1020 depends_on: DependsOn::default(),
1021 config: ManifestConfig::default(),
1022 description: None,
1023 path: None,
1024 original_file_path: None,
1025 columns: HashMap::new(),
1026 compiled_code: None,
1027 database: None,
1028 schema: None,
1029 },
1030 )]),
1031 sources: HashMap::new(),
1032 ..Default::default()
1033 };
1034
1035 let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
1036 let node = &graph[graph.node_indices().next().unwrap()];
1037 assert_eq!(node.node_type, NodeType::Model);
1038 }
1039
1040 #[test]
1041 fn test_build_graph_complex_chain() {
1042 let manifest = Manifest {
1044 nodes: HashMap::from([
1045 (
1046 "model.proj.stg_orders".to_string(),
1047 ManifestNode {
1048 unique_id: "model.proj.stg_orders".to_string(),
1049 name: "stg_orders".to_string(),
1050 resource_type: "model".to_string(),
1051 depends_on: DependsOn {
1052 nodes: vec!["source.proj.raw.orders".to_string()],
1053 },
1054 config: ManifestConfig {
1055 materialized: Some("view".to_string()),
1056 tags: vec![],
1057 },
1058 description: None,
1059 path: None,
1060 original_file_path: None,
1061 columns: HashMap::new(),
1062 compiled_code: None,
1063 database: None,
1064 schema: None,
1065 },
1066 ),
1067 (
1068 "model.proj.stg_payments".to_string(),
1069 ManifestNode {
1070 unique_id: "model.proj.stg_payments".to_string(),
1071 name: "stg_payments".to_string(),
1072 resource_type: "model".to_string(),
1073 depends_on: DependsOn {
1074 nodes: vec!["source.proj.raw.payments".to_string()],
1075 },
1076 config: ManifestConfig::default(),
1077 description: None,
1078 path: None,
1079 original_file_path: None,
1080 columns: HashMap::new(),
1081 compiled_code: None,
1082 database: None,
1083 schema: None,
1084 },
1085 ),
1086 (
1087 "model.proj.orders".to_string(),
1088 ManifestNode {
1089 unique_id: "model.proj.orders".to_string(),
1090 name: "orders".to_string(),
1091 resource_type: "model".to_string(),
1092 depends_on: DependsOn {
1093 nodes: vec![
1094 "model.proj.stg_orders".to_string(),
1095 "model.proj.stg_payments".to_string(),
1096 ],
1097 },
1098 config: ManifestConfig {
1099 materialized: Some("table".to_string()),
1100 tags: vec!["marts".to_string()],
1101 },
1102 description: Some("Order fact table".to_string()),
1103 path: None,
1104 original_file_path: None,
1105 columns: HashMap::new(),
1106 compiled_code: None,
1107 database: None,
1108 schema: None,
1109 },
1110 ),
1111 ]),
1112 sources: HashMap::from([
1113 (
1114 "source.proj.raw.orders".to_string(),
1115 ManifestSource {
1116 unique_id: "source.proj.raw.orders".to_string(),
1117 name: "orders".to_string(),
1118 source_name: "raw".to_string(),
1119 resource_type: "source".to_string(),
1120 description: None,
1121 path: None,
1122 original_file_path: None,
1123 columns: HashMap::new(),
1124 database: None,
1125 schema: None,
1126 identifier: None,
1127 },
1128 ),
1129 (
1130 "source.proj.raw.payments".to_string(),
1131 ManifestSource {
1132 unique_id: "source.proj.raw.payments".to_string(),
1133 name: "payments".to_string(),
1134 source_name: "raw".to_string(),
1135 resource_type: "source".to_string(),
1136 description: None,
1137 path: None,
1138 original_file_path: None,
1139 columns: HashMap::new(),
1140 database: None,
1141 schema: None,
1142 identifier: None,
1143 },
1144 ),
1145 ]),
1146 ..Default::default()
1147 };
1148
1149 let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
1150 assert_eq!(graph.node_count(), 5);
1152 assert_eq!(graph.edge_count(), 4);
1155 }
1156
1157 #[test]
1158 fn test_build_graph_from_fixture_manifest() {
1159 let fixture_path = Path::new(env!("CARGO_MANIFEST_DIR"))
1160 .join("../../tests/fixtures/simple_project/target/manifest.json");
1161
1162 if !fixture_path.exists() {
1163 return;
1165 }
1166
1167 let graph = build_graph_from_manifest(&fixture_path).unwrap();
1168
1169 assert!(
1172 graph.node_count() >= 10,
1173 "Expected at least 10 nodes, got {}",
1174 graph.node_count()
1175 );
1176
1177 let has_source = graph
1179 .node_indices()
1180 .any(|i| graph[i].node_type == NodeType::Source);
1181 let has_model = graph
1182 .node_indices()
1183 .any(|i| graph[i].node_type == NodeType::Model);
1184 let has_seed = graph
1185 .node_indices()
1186 .any(|i| graph[i].node_type == NodeType::Seed);
1187 let has_test = graph
1188 .node_indices()
1189 .any(|i| graph[i].node_type == NodeType::Test);
1190 let has_exposure = graph
1191 .node_indices()
1192 .any(|i| graph[i].node_type == NodeType::Exposure);
1193
1194 assert!(has_source, "Should have source nodes");
1195 assert!(has_model, "Should have model nodes");
1196 assert!(has_seed, "Should have seed nodes");
1197 assert!(has_test, "Should have test nodes");
1198 assert!(has_exposure, "Should have exposure nodes");
1199
1200 assert!(graph.edge_count() > 0, "Should have edges");
1202 }
1203
1204 #[test]
1205 fn test_collect_file_paths() {
1206 let manifest = Manifest {
1207 nodes: HashMap::from([
1208 (
1209 "model.proj.stg_orders".to_string(),
1210 ManifestNode {
1211 unique_id: "model.proj.stg_orders".to_string(),
1212 name: "stg_orders".to_string(),
1213 resource_type: "model".to_string(),
1214 depends_on: DependsOn::default(),
1215 config: ManifestConfig::default(),
1216 description: None,
1217 path: Some("models/staging/stg_orders.sql".to_string()),
1218 original_file_path: None,
1219 columns: HashMap::new(),
1220 compiled_code: None,
1221 database: None,
1222 schema: None,
1223 },
1224 ),
1225 (
1226 "model.proj.orders".to_string(),
1227 ManifestNode {
1228 unique_id: "model.proj.orders".to_string(),
1229 name: "orders".to_string(),
1230 resource_type: "model".to_string(),
1231 depends_on: DependsOn::default(),
1232 config: ManifestConfig::default(),
1233 description: None,
1234 path: Some("models/marts/orders.sql".to_string()),
1235 original_file_path: None,
1236 columns: HashMap::new(),
1237 compiled_code: None,
1238 database: None,
1239 schema: None,
1240 },
1241 ),
1242 (
1243 "model.proj.bare".to_string(),
1244 ManifestNode {
1245 unique_id: "model.proj.bare".to_string(),
1246 name: "bare".to_string(),
1247 resource_type: "model".to_string(),
1248 depends_on: DependsOn::default(),
1249 config: ManifestConfig::default(),
1250 description: None,
1251 path: None,
1252 original_file_path: None,
1253 columns: HashMap::new(),
1254 compiled_code: None,
1255 database: None,
1256 schema: None,
1257 },
1258 ),
1259 ]),
1260 sources: HashMap::from([(
1261 "source.proj.raw.orders".to_string(),
1262 ManifestSource {
1263 unique_id: "source.proj.raw.orders".to_string(),
1264 name: "orders".to_string(),
1265 source_name: "raw".to_string(),
1266 resource_type: "source".to_string(),
1267 description: None,
1268 path: Some("models/staging/schema.yml".to_string()),
1269 original_file_path: None,
1270 columns: HashMap::new(),
1271 database: None,
1272 schema: None,
1273 identifier: None,
1274 },
1275 )]),
1276 ..Default::default()
1277 };
1278
1279 let paths = manifest.collect_file_paths();
1280 assert_eq!(paths.len(), 3);
1281 assert!(paths.contains("models/staging/stg_orders.sql"));
1282 assert!(paths.contains("models/marts/orders.sql"));
1283 assert!(paths.contains("models/staging/schema.yml"));
1284 assert!(!paths.iter().any(|p| p.contains("bare")));
1286 }
1287
1288 #[test]
1289 fn test_collect_file_paths_deduplicates() {
1290 let manifest = Manifest {
1292 nodes: HashMap::new(),
1293 sources: HashMap::from([
1294 (
1295 "source.proj.raw.orders".to_string(),
1296 ManifestSource {
1297 unique_id: "source.proj.raw.orders".to_string(),
1298 name: "orders".to_string(),
1299 source_name: "raw".to_string(),
1300 resource_type: "source".to_string(),
1301 description: None,
1302 path: Some("models/staging/schema.yml".to_string()),
1303 original_file_path: None,
1304 columns: HashMap::new(),
1305 database: None,
1306 schema: None,
1307 identifier: None,
1308 },
1309 ),
1310 (
1311 "source.proj.raw.customers".to_string(),
1312 ManifestSource {
1313 unique_id: "source.proj.raw.customers".to_string(),
1314 name: "customers".to_string(),
1315 source_name: "raw".to_string(),
1316 resource_type: "source".to_string(),
1317 description: None,
1318 path: Some("models/staging/schema.yml".to_string()),
1319 original_file_path: None,
1320 columns: HashMap::new(),
1321 database: None,
1322 schema: None,
1323 identifier: None,
1324 },
1325 ),
1326 ]),
1327 ..Default::default()
1328 };
1329
1330 let paths = manifest.collect_file_paths();
1331 assert_eq!(paths.len(), 1, "Duplicate paths should be deduplicated");
1332 }
1333
1334 #[test]
1335 fn test_load_manifest() {
1336 let fixture_path = Path::new(env!("CARGO_MANIFEST_DIR"))
1337 .join("../../tests/fixtures/simple_project/target/manifest.json");
1338
1339 let manifest = load_manifest(&fixture_path).unwrap();
1340 assert!(!manifest.nodes.is_empty());
1341 assert!(!manifest.sources.is_empty());
1342
1343 let paths = manifest.collect_file_paths();
1344 assert!(paths.contains("models/staging/stg_orders.sql"));
1345 assert!(paths.contains("models/staging/schema.yml"));
1346 }
1347
1348 #[test]
1349 fn test_collect_sql_contents_from_manifest() {
1350 let manifest = Manifest {
1351 nodes: HashMap::from([
1352 (
1353 "model.proj.stg_orders".to_string(),
1354 ManifestNode {
1355 unique_id: "model.proj.stg_orders".to_string(),
1356 name: "stg_orders".to_string(),
1357 resource_type: "model".to_string(),
1358 depends_on: DependsOn::default(),
1359 config: ManifestConfig::default(),
1360 description: None,
1361 path: None,
1362 original_file_path: None,
1363 columns: HashMap::new(),
1364 compiled_code: Some("select * from raw.orders".to_string()),
1365 database: None,
1366 schema: None,
1367 },
1368 ),
1369 (
1370 "test.proj.not_null_orders_id.abc123".to_string(),
1371 ManifestNode {
1372 unique_id: "test.proj.not_null_orders_id.abc123".to_string(),
1373 name: "not_null_orders_id".to_string(),
1374 resource_type: "test".to_string(),
1375 depends_on: DependsOn::default(),
1376 config: ManifestConfig::default(),
1377 description: None,
1378 path: None,
1379 original_file_path: None,
1380 columns: HashMap::new(),
1381 compiled_code: Some(
1382 "select count(*) from orders where id is null".to_string(),
1383 ),
1384 database: None,
1385 schema: None,
1386 },
1387 ),
1388 (
1389 "model.proj.no_compile".to_string(),
1390 ManifestNode {
1391 unique_id: "model.proj.no_compile".to_string(),
1392 name: "no_compile".to_string(),
1393 resource_type: "model".to_string(),
1394 depends_on: DependsOn::default(),
1395 config: ManifestConfig::default(),
1396 description: None,
1397 path: None,
1398 original_file_path: None,
1399 columns: HashMap::new(),
1400 compiled_code: None,
1401 database: None,
1402 schema: None,
1403 },
1404 ),
1405 ]),
1406 sources: HashMap::new(),
1407 ..Default::default()
1408 };
1409
1410 let sql_contents = manifest.collect_sql_contents();
1411
1412 assert_eq!(
1414 sql_contents.get("model.stg_orders").map(|s| s.as_str()),
1415 Some("select * from raw.orders")
1416 );
1417 assert_eq!(
1419 sql_contents
1420 .get("test.not_null_orders_id")
1421 .map(|s| s.as_str()),
1422 Some("select count(*) from orders where id is null")
1423 );
1424 assert!(!sql_contents.contains_key("model.no_compile"));
1426 }
1427
1428 #[test]
1429 fn test_collect_sql_contents_from_fixture() {
1430 let fixture_path = Path::new(env!("CARGO_MANIFEST_DIR"))
1431 .join("../../tests/fixtures/simple_project/target/manifest.json");
1432
1433 let manifest = load_manifest(&fixture_path).unwrap();
1434 let sql_contents = manifest.collect_sql_contents();
1435
1436 assert!(
1438 sql_contents.contains_key("model.stg_orders"),
1439 "stg_orders should have compiled_code"
1440 );
1441 assert!(
1442 sql_contents.contains_key("test.assert_orders_positive_amount"),
1443 "test node should have compiled_code"
1444 );
1445 assert!(
1447 !sql_contents.contains_key("model.customers"),
1448 "customers has no compiled_code in fixture"
1449 );
1450 }
1451}