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