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 pub adapter_type: Option<String>,
16}
17
18#[derive(Debug, Default, Deserialize)]
20pub struct Manifest {
21 #[serde(default)]
23 pub metadata: ManifestMetadata,
24 #[serde(default)]
26 pub nodes: HashMap<String, ManifestNode>,
27 #[serde(default)]
29 pub sources: HashMap<String, ManifestSource>,
30 #[serde(default)]
32 pub exposures: HashMap<String, ManifestExposure>,
33 #[serde(default)]
35 pub semantic_models: HashMap<String, ManifestSemanticModel>,
36 #[serde(default)]
38 pub metrics: HashMap<String, ManifestMetric>,
39 #[serde(default)]
41 pub saved_queries: HashMap<String, ManifestSavedQuery>,
42}
43
44#[derive(Debug, Deserialize)]
46pub struct ManifestNode {
47 pub unique_id: String,
48 pub name: String,
49 pub resource_type: String,
50 #[serde(default)]
51 pub depends_on: DependsOn,
52 #[serde(default)]
53 pub config: ManifestConfig,
54 pub description: Option<String>,
55 pub path: Option<String>,
56 pub original_file_path: Option<String>,
59 #[serde(default)]
61 pub columns: HashMap<String, ManifestColumn>,
62 pub compiled_code: Option<String>,
64 #[serde(default)]
66 pub database: Option<String>,
67 #[serde(default)]
69 pub schema: Option<String>,
70}
71
72#[derive(Debug, Deserialize)]
74pub struct ManifestSource {
75 pub unique_id: String,
76 pub name: String,
77 pub source_name: String,
78 #[serde(default)]
79 pub resource_type: String,
80 pub description: Option<String>,
81 pub path: Option<String>,
82 pub original_file_path: Option<String>,
84 #[serde(default)]
86 pub columns: HashMap<String, ManifestColumn>,
87 #[serde(default)]
89 pub database: Option<String>,
90 #[serde(default)]
92 pub schema: Option<String>,
93 #[serde(default)]
95 pub identifier: Option<String>,
96}
97
98#[derive(Debug, Deserialize)]
100pub struct ManifestColumn {
101 pub name: String,
102}
103
104#[derive(Debug, Deserialize)]
106pub struct ManifestExposure {
107 pub unique_id: String,
108 pub name: String,
109 #[serde(default)]
110 pub depends_on: DependsOn,
111 pub description: Option<String>,
112 pub label: Option<String>,
113 #[serde(rename = "type")]
114 pub exposure_type: Option<String>,
115 pub url: Option<String>,
116 pub maturity: Option<String>,
117 pub owner: Option<ManifestExposureOwner>,
118}
119
120#[derive(Debug, Deserialize)]
122pub struct ManifestExposureOwner {
123 pub name: Option<String>,
124 pub email: Option<String>,
125}
126
127#[derive(Debug, Deserialize)]
129pub struct ManifestSemanticModel {
130 pub unique_id: String,
131 pub name: String,
132 pub label: Option<String>,
133 #[serde(default)]
134 pub depends_on: DependsOn,
135 pub description: Option<String>,
136 pub path: Option<String>,
137 pub original_file_path: Option<String>,
138}
139
140#[derive(Debug, Deserialize)]
142pub struct ManifestMetric {
143 pub unique_id: String,
144 pub name: String,
145 pub label: Option<String>,
146 #[serde(default)]
147 pub depends_on: DependsOn,
148 pub description: Option<String>,
149 pub path: Option<String>,
150 pub original_file_path: Option<String>,
151}
152
153#[derive(Debug, Deserialize)]
155pub struct ManifestSavedQuery {
156 pub unique_id: String,
157 pub name: String,
158 pub label: Option<String>,
159 #[serde(default)]
160 pub depends_on: DependsOn,
161 pub description: Option<String>,
162 pub path: Option<String>,
163 pub original_file_path: Option<String>,
164}
165
166#[derive(Debug, Default, Deserialize)]
168pub struct DependsOn {
169 #[serde(default)]
170 pub nodes: Vec<String>,
171}
172
173#[derive(Debug, Default, Deserialize)]
175pub struct ManifestConfig {
176 pub materialized: Option<String>,
177 #[serde(default)]
178 pub tags: Vec<String>,
179}
180
181fn resource_type_to_node_type(resource_type: &str) -> NodeType {
183 match resource_type {
184 "model" => NodeType::Model,
185 "source" => NodeType::Source,
186 "seed" => NodeType::Seed,
187 "snapshot" => NodeType::Snapshot,
188 "test" => NodeType::Test,
189 "analysis" => NodeType::Model,
190 "exposure" => NodeType::Exposure,
191 _ => NodeType::Model,
192 }
193}
194
195fn simplify_unique_id(unique_id: &str, resource_type: &str) -> String {
200 let parts: Vec<&str> = unique_id.split('.').collect();
201 match resource_type {
202 "source" => {
203 if parts.len() >= 4 {
205 format!("{}.{}.{}", parts[0], parts[2], parts[3])
206 } else {
207 unique_id.to_string()
208 }
209 }
210 "test" => {
211 if parts.len() >= 3 {
213 format!("{}.{}", parts[0], parts[2])
214 } else {
215 unique_id.to_string()
216 }
217 }
218 _ => {
219 if parts.len() >= 3 {
222 format!("{}.{}", parts[0], parts[2..].join("."))
223 } else {
224 unique_id.to_string()
225 }
226 }
227 }
228}
229
230pub fn load_manifest(manifest_path: &Path) -> Result<Manifest> {
232 let content =
233 std::fs::read(manifest_path).map_err(|e| crate::error::DbtLineageError::FileReadError {
234 path: manifest_path.to_path_buf(),
235 source: e,
236 })?;
237
238 load_manifest_from_bytes(&content, manifest_path)
239}
240
241pub fn load_manifest_from_bytes(content: &[u8], manifest_path: &Path) -> Result<Manifest> {
242 let manifest: Manifest = serde_json::from_slice(content).map_err(|e| {
243 crate::error::DbtLineageError::ArtifactParseError {
244 path: manifest_path.to_path_buf(),
245 source: e,
246 }
247 })?;
248
249 Ok(manifest)
250}
251
252impl Manifest {
253 pub fn collect_sql_contents(&self) -> HashMap<String, String> {
261 let mut map = HashMap::new();
262 for (orig_id, node) in &self.nodes {
263 if let Some(ref code) = node.compiled_code {
264 let simple_id = simplify_unique_id(orig_id, &node.resource_type);
265 map.insert(simple_id, code.clone());
266 }
267 }
268 map
269 }
270
271 pub fn collect_file_paths(&self) -> BTreeSet<String> {
274 let mut paths = BTreeSet::new();
275 for node in self.nodes.values() {
276 let p = node.original_file_path.as_ref().or(node.path.as_ref());
277 if let Some(p) = p {
278 paths.insert(p.clone());
279 }
280 }
281 for source in self.sources.values() {
282 let p = source.original_file_path.as_ref().or(source.path.as_ref());
283 if let Some(p) = p {
284 paths.insert(p.clone());
285 }
286 }
287 for sm in self.semantic_models.values() {
288 let p = sm.original_file_path.as_ref().or(sm.path.as_ref());
289 if let Some(p) = p {
290 paths.insert(p.clone());
291 }
292 }
293 for metric in self.metrics.values() {
294 let p = metric.original_file_path.as_ref().or(metric.path.as_ref());
295 if let Some(p) = p {
296 paths.insert(p.clone());
297 }
298 }
299 for sq in self.saved_queries.values() {
300 let p = sq.original_file_path.as_ref().or(sq.path.as_ref());
301 if let Some(p) = p {
302 paths.insert(p.clone());
303 }
304 }
305 paths
306 }
307}
308
309pub fn build_graph_from_manifest(manifest_path: &Path) -> Result<LineageGraph> {
311 let manifest = load_manifest(manifest_path)?;
312 build_graph_from_parsed_manifest(&manifest)
313}
314
315pub fn build_graph_from_parsed_manifest(manifest: &Manifest) -> Result<LineageGraph> {
318 let mut graph = LineageGraph::new();
319 let mut node_map: HashMap<String, NodeIndex> = HashMap::new();
321
322 add_source_nodes(&mut graph, &mut node_map, &manifest.sources);
324
325 add_regular_nodes(&mut graph, &mut node_map, &manifest.nodes);
327
328 add_exposure_nodes(&mut graph, &mut node_map, &manifest.exposures);
330
331 add_semantic_layer_nodes(&mut graph, &mut node_map, &manifest.semantic_models);
333 add_semantic_layer_nodes(&mut graph, &mut node_map, &manifest.metrics);
334 add_semantic_layer_nodes(&mut graph, &mut node_map, &manifest.saved_queries);
335
336 add_node_edges(&mut graph, &node_map, &manifest.nodes);
338
339 add_exposure_edges(&mut graph, &node_map, &manifest.exposures);
341
342 add_depends_on_edges(&mut graph, &node_map, &manifest.semantic_models);
344 add_depends_on_edges(&mut graph, &node_map, &manifest.metrics);
345 add_depends_on_edges(&mut graph, &node_map, &manifest.saved_queries);
346
347 Ok(graph)
348}
349
350fn add_source_nodes(
351 graph: &mut LineageGraph,
352 node_map: &mut HashMap<String, NodeIndex>,
353 sources: &HashMap<String, ManifestSource>,
354) {
355 for (orig_id, source) in sources {
356 let simple_id = simplify_unique_id(orig_id, "source");
357 let label = format!("{}.{}", source.source_name, source.name);
358
359 let idx = graph.add_node(NodeData {
360 unique_id: simple_id.clone(),
361 label,
362 node_type: NodeType::Source,
363 file_path: source
364 .original_file_path
365 .as_ref()
366 .or(source.path.as_ref())
367 .map(|p| p.into()),
368 description: non_empty_string(&source.description),
369 materialization: None,
370 tags: vec![],
371 columns: {
372 let mut cols: Vec<String> = source.columns.keys().cloned().collect();
373 cols.sort();
374 cols
375 },
376 exposure: None,
377 aliases: vec![],
378 });
379 node_map.insert(orig_id.clone(), idx);
380 node_map.insert(simple_id, idx);
382 }
383}
384
385fn add_regular_nodes(
386 graph: &mut LineageGraph,
387 node_map: &mut HashMap<String, NodeIndex>,
388 nodes: &HashMap<String, ManifestNode>,
389) {
390 for (orig_id, node) in nodes {
391 let node_type = resource_type_to_node_type(&node.resource_type);
392 let simple_id = simplify_unique_id(orig_id, &node.resource_type);
393
394 let idx = graph.add_node(NodeData {
395 unique_id: simple_id.clone(),
396 label: node.name.clone(),
397 node_type,
398 file_path: node
399 .original_file_path
400 .as_ref()
401 .or(node.path.as_ref())
402 .map(|p| p.into()),
403 description: non_empty_string(&node.description),
404 materialization: node.config.materialized.clone(),
405 tags: node.config.tags.clone(),
406 columns: {
407 let mut cols: Vec<String> = node.columns.keys().cloned().collect();
408 cols.sort();
409 cols
410 },
411 exposure: None,
412 aliases: vec![],
413 });
414 node_map.insert(orig_id.clone(), idx);
415 node_map.insert(simple_id, idx);
416 }
417}
418
419fn add_exposure_nodes(
420 graph: &mut LineageGraph,
421 node_map: &mut HashMap<String, NodeIndex>,
422 exposures: &HashMap<String, ManifestExposure>,
423) {
424 for (orig_id, exposure) in exposures {
425 let simple_id = simplify_unique_id(orig_id, "exposure");
426
427 let idx = graph.add_node(NodeData {
428 unique_id: simple_id.clone(),
429 label: exposure.name.clone(),
430 node_type: NodeType::Exposure,
431 file_path: None,
432 description: non_empty_string(&exposure.description),
433 materialization: None,
434 tags: vec![],
435 columns: vec![],
436 exposure: Some(ExposureInfo {
437 label: non_empty_string(&exposure.label),
438 exposure_type: non_empty_string(&exposure.exposure_type),
439 url: non_empty_string(&exposure.url),
440 maturity: non_empty_string(&exposure.maturity),
441 owner: exposure.owner.as_ref().map(|o| OwnerInfo {
442 name: non_empty_string(&o.name),
443 email: non_empty_string(&o.email),
444 }),
445 }),
446 aliases: vec![],
447 });
448 node_map.insert(orig_id.clone(), idx);
449 node_map.insert(simple_id, idx);
450 }
451}
452
453fn add_node_edges(
454 graph: &mut LineageGraph,
455 node_map: &HashMap<String, NodeIndex>,
456 nodes: &HashMap<String, ManifestNode>,
457) {
458 for (orig_id, node) in nodes {
459 let current_idx = match node_map.get(orig_id) {
460 Some(&idx) => idx,
461 None => continue,
462 };
463
464 let current_is_test = graph[current_idx].node_type == NodeType::Test;
467
468 for dep_id in &node.depends_on.nodes {
469 if let Some(&dep_idx) = node_map.get(dep_id) {
470 let edge_type = if current_is_test {
471 EdgeType::Test
472 } else {
473 infer_edge_type(dep_id)
474 };
475 graph.add_edge(dep_idx, current_idx, EdgeData::direct(edge_type));
476 }
477 }
478 }
479}
480
481fn add_exposure_edges(
482 graph: &mut LineageGraph,
483 node_map: &HashMap<String, NodeIndex>,
484 exposures: &HashMap<String, ManifestExposure>,
485) {
486 for (orig_id, exposure) in exposures {
487 let current_idx = match node_map.get(orig_id) {
488 Some(&idx) => idx,
489 None => continue,
490 };
491
492 for dep_id in &exposure.depends_on.nodes {
493 if let Some(&dep_idx) = node_map.get(dep_id) {
494 graph.add_edge(dep_idx, current_idx, EdgeData::direct(EdgeType::Exposure));
495 }
496 }
497 }
498}
499
500trait HasSemanticLayerFields {
501 fn name(&self) -> &str;
502 fn label(&self) -> Option<&str>;
503 fn depends_on_nodes(&self) -> &[String];
504 fn description(&self) -> Option<&str>;
505 fn original_file_path(&self) -> Option<&str>;
506 fn path(&self) -> Option<&str>;
507 fn node_type(&self) -> NodeType;
508}
509
510impl HasSemanticLayerFields for ManifestSemanticModel {
511 fn name(&self) -> &str {
512 &self.name
513 }
514 fn label(&self) -> Option<&str> {
515 self.label.as_deref()
516 }
517 fn depends_on_nodes(&self) -> &[String] {
518 &self.depends_on.nodes
519 }
520 fn description(&self) -> Option<&str> {
521 self.description.as_deref()
522 }
523 fn original_file_path(&self) -> Option<&str> {
524 self.original_file_path.as_deref()
525 }
526 fn path(&self) -> Option<&str> {
527 self.path.as_deref()
528 }
529 fn node_type(&self) -> NodeType {
530 NodeType::SemanticModel
531 }
532}
533
534impl HasSemanticLayerFields for ManifestMetric {
535 fn name(&self) -> &str {
536 &self.name
537 }
538 fn label(&self) -> Option<&str> {
539 self.label.as_deref()
540 }
541 fn depends_on_nodes(&self) -> &[String] {
542 &self.depends_on.nodes
543 }
544 fn description(&self) -> Option<&str> {
545 self.description.as_deref()
546 }
547 fn original_file_path(&self) -> Option<&str> {
548 self.original_file_path.as_deref()
549 }
550 fn path(&self) -> Option<&str> {
551 self.path.as_deref()
552 }
553 fn node_type(&self) -> NodeType {
554 NodeType::Metric
555 }
556}
557
558impl HasSemanticLayerFields for ManifestSavedQuery {
559 fn name(&self) -> &str {
560 &self.name
561 }
562 fn label(&self) -> Option<&str> {
563 self.label.as_deref()
564 }
565 fn depends_on_nodes(&self) -> &[String] {
566 &self.depends_on.nodes
567 }
568 fn description(&self) -> Option<&str> {
569 self.description.as_deref()
570 }
571 fn original_file_path(&self) -> Option<&str> {
572 self.original_file_path.as_deref()
573 }
574 fn path(&self) -> Option<&str> {
575 self.path.as_deref()
576 }
577 fn node_type(&self) -> NodeType {
578 NodeType::SavedQuery
579 }
580}
581
582fn add_semantic_layer_nodes<T: HasSemanticLayerFields>(
583 graph: &mut LineageGraph,
584 node_map: &mut HashMap<String, NodeIndex>,
585 items: &HashMap<String, T>,
586) {
587 for (orig_id, item) in items {
588 let resource_type = item.node_type().label();
589 let simple_id = simplify_unique_id(orig_id, resource_type);
590 let idx = graph.add_node(NodeData {
591 unique_id: simple_id.clone(),
592 label: item.label().unwrap_or_else(|| item.name()).to_string(),
593 node_type: item.node_type(),
594 file_path: item
595 .original_file_path()
596 .or_else(|| item.path())
597 .map(|p| p.into()),
598 description: item
599 .description()
600 .filter(|s| !s.trim().is_empty())
601 .map(str::to_string),
602 materialization: None,
603 tags: vec![],
604 columns: vec![],
605 exposure: None,
606 aliases: vec![],
607 });
608 node_map.insert(orig_id.clone(), idx);
609 node_map.insert(simple_id, idx);
610 }
611}
612
613fn add_depends_on_edges<T: HasSemanticLayerFields>(
614 graph: &mut LineageGraph,
615 node_map: &HashMap<String, NodeIndex>,
616 items: &HashMap<String, T>,
617) {
618 for (orig_id, item) in items {
619 let Some(¤t_idx) = node_map.get(orig_id) else {
620 continue;
621 };
622 for dep_id in item.depends_on_nodes() {
623 if let Some(&dep_idx) = node_map.get(dep_id) {
624 graph.add_edge(
625 dep_idx,
626 current_idx,
627 EdgeData::direct(infer_edge_type(dep_id)),
628 );
629 }
630 }
631 }
632}
633
634fn infer_edge_type(dep_unique_id: &str) -> EdgeType {
636 if dep_unique_id.starts_with("source.") {
637 EdgeType::Source
638 } else if dep_unique_id.starts_with("test.") {
639 EdgeType::Test
640 } else {
641 EdgeType::Ref
642 }
643}
644
645fn non_empty_string(s: &Option<String>) -> Option<String> {
647 s.as_ref().filter(|v| !v.trim().is_empty()).cloned()
648}
649
650#[cfg(test)]
651mod tests {
652 use super::*;
653 use std::fs;
654
655 #[test]
656 fn test_resource_type_to_node_type() {
657 assert_eq!(resource_type_to_node_type("model"), NodeType::Model);
658 assert_eq!(resource_type_to_node_type("source"), NodeType::Source);
659 assert_eq!(resource_type_to_node_type("seed"), NodeType::Seed);
660 assert_eq!(resource_type_to_node_type("snapshot"), NodeType::Snapshot);
661 assert_eq!(resource_type_to_node_type("test"), NodeType::Test);
662 assert_eq!(resource_type_to_node_type("analysis"), NodeType::Model);
663 assert_eq!(resource_type_to_node_type("exposure"), NodeType::Exposure);
664 assert_eq!(resource_type_to_node_type("unknown"), NodeType::Model);
665 }
666
667 #[test]
668 fn test_simplify_unique_id_model() {
669 assert_eq!(
670 simplify_unique_id("model.my_project.stg_orders", "model"),
671 "model.stg_orders"
672 );
673 }
674
675 #[test]
676 fn test_simplify_unique_id_source() {
677 assert_eq!(
678 simplify_unique_id("source.my_project.raw.orders", "source"),
679 "source.raw.orders"
680 );
681 }
682
683 #[test]
684 fn test_simplify_unique_id_short() {
685 assert_eq!(
686 simplify_unique_id("model.stg_orders", "model"),
687 "model.stg_orders"
688 );
689 }
690
691 #[test]
692 fn test_simplify_unique_id_source_short() {
693 assert_eq!(
694 simplify_unique_id("source.raw.orders", "source"),
695 "source.raw.orders"
696 );
697 }
698
699 #[test]
700 fn test_simplify_unique_id_test() {
701 assert_eq!(
703 simplify_unique_id(
704 "test.jaffle_shop.not_null_orders_order_id.cf6c17daed",
705 "test"
706 ),
707 "test.not_null_orders_order_id"
708 );
709 }
710
711 #[test]
712 fn test_simplify_unique_id_test_short() {
713 assert_eq!(
714 simplify_unique_id("test.not_null_orders_order_id", "test"),
715 "test.not_null_orders_order_id"
716 );
717 }
718
719 #[test]
720 fn test_simplify_unique_id_versioned_model() {
721 assert_eq!(
723 simplify_unique_id("model.my_project.my_model.v1", "model"),
724 "model.my_model.v1"
725 );
726 assert_eq!(
727 simplify_unique_id("model.my_project.my_model.v2", "model"),
728 "model.my_model.v2"
729 );
730 assert_eq!(
732 simplify_unique_id("model.my_project.stg_orders", "model"),
733 "model.stg_orders"
734 );
735 }
736
737 #[test]
738 fn test_infer_edge_type() {
739 assert_eq!(
740 infer_edge_type("source.my_project.raw.orders"),
741 EdgeType::Source
742 );
743 assert_eq!(
744 infer_edge_type("model.my_project.stg_orders"),
745 EdgeType::Ref
746 );
747 assert_eq!(infer_edge_type("test.my_project.some_test"), EdgeType::Test);
748 assert_eq!(infer_edge_type("seed.my_project.countries"), EdgeType::Ref);
749 }
750
751 #[test]
752 fn test_non_empty_string() {
753 assert_eq!(non_empty_string(&None), None);
754 assert_eq!(non_empty_string(&Some("".to_string())), None);
755 assert_eq!(non_empty_string(&Some(" ".to_string())), None);
756 assert_eq!(
757 non_empty_string(&Some("hello".to_string())),
758 Some("hello".to_string())
759 );
760 }
761
762 #[test]
763 fn test_build_graph_from_minimal_manifest() {
764 let manifest = Manifest {
765 nodes: HashMap::from([(
766 "model.proj.stg_orders".to_string(),
767 ManifestNode {
768 unique_id: "model.proj.stg_orders".to_string(),
769 name: "stg_orders".to_string(),
770 resource_type: "model".to_string(),
771 depends_on: DependsOn {
772 nodes: vec!["source.proj.raw.orders".to_string()],
773 },
774 config: ManifestConfig {
775 materialized: Some("view".to_string()),
776 tags: vec!["staging".to_string()],
777 },
778 description: Some("Staged orders".to_string()),
779 path: Some("models/staging/stg_orders.sql".to_string()),
780 original_file_path: None,
781 columns: HashMap::new(),
782 compiled_code: None,
783 database: None,
784 schema: None,
785 },
786 )]),
787 sources: HashMap::from([(
788 "source.proj.raw.orders".to_string(),
789 ManifestSource {
790 unique_id: "source.proj.raw.orders".to_string(),
791 name: "orders".to_string(),
792 source_name: "raw".to_string(),
793 resource_type: "source".to_string(),
794 description: Some("Raw orders table".to_string()),
795 path: Some("models/staging/schema.yml".to_string()),
796 original_file_path: None,
797 columns: HashMap::new(),
798 database: None,
799 schema: None,
800 identifier: None,
801 },
802 )]),
803 ..Default::default()
804 };
805
806 let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
807
808 assert_eq!(graph.node_count(), 2);
809 assert_eq!(graph.edge_count(), 1);
810
811 let model = graph
813 .node_indices()
814 .find(|&i| graph[i].node_type == NodeType::Model)
815 .expect("Should have a model node");
816 assert_eq!(graph[model].label, "stg_orders");
817 assert_eq!(graph[model].unique_id, "model.stg_orders");
818 assert_eq!(graph[model].materialization.as_deref(), Some("view"));
819 assert_eq!(graph[model].tags, vec!["staging"]);
820 assert_eq!(graph[model].description.as_deref(), Some("Staged orders"));
821
822 let source = graph
824 .node_indices()
825 .find(|&i| graph[i].node_type == NodeType::Source)
826 .expect("Should have a source node");
827 assert_eq!(graph[source].label, "raw.orders");
828 assert_eq!(graph[source].unique_id, "source.raw.orders");
829 }
830
831 #[test]
832 fn test_build_graph_with_exposures() {
833 let manifest = Manifest {
834 nodes: HashMap::from([(
835 "model.proj.orders".to_string(),
836 ManifestNode {
837 unique_id: "model.proj.orders".to_string(),
838 name: "orders".to_string(),
839 resource_type: "model".to_string(),
840 depends_on: DependsOn::default(),
841 config: ManifestConfig::default(),
842 description: None,
843 path: None,
844 original_file_path: None,
845 columns: HashMap::new(),
846 compiled_code: None,
847 database: None,
848 schema: None,
849 },
850 )]),
851 sources: HashMap::new(),
852 exposures: HashMap::from([(
853 "exposure.proj.weekly_report".to_string(),
854 ManifestExposure {
855 unique_id: "exposure.proj.weekly_report".to_string(),
856 name: "weekly_report".to_string(),
857 depends_on: DependsOn {
858 nodes: vec!["model.proj.orders".to_string()],
859 },
860 description: Some("Weekly dashboard".to_string()),
861 label: None,
862 exposure_type: None,
863 url: None,
864 maturity: None,
865 owner: None,
866 },
867 )]),
868 ..Default::default()
869 };
870
871 let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
872 assert_eq!(graph.node_count(), 2);
873 assert_eq!(graph.edge_count(), 1);
874
875 let exposure = graph
876 .node_indices()
877 .find(|&i| graph[i].node_type == NodeType::Exposure)
878 .expect("Should have an exposure node");
879 assert_eq!(graph[exposure].label, "weekly_report");
880 assert_eq!(
881 graph[exposure].description.as_deref(),
882 Some("Weekly dashboard")
883 );
884 }
885
886 #[test]
887 fn test_exposure_metadata_parsed() {
888 let manifest = Manifest {
889 nodes: HashMap::new(),
890 sources: HashMap::new(),
891 exposures: HashMap::from([(
892 "exposure.proj.dashboard".to_string(),
893 ManifestExposure {
894 unique_id: "exposure.proj.dashboard".to_string(),
895 name: "dashboard".to_string(),
896 depends_on: DependsOn { nodes: vec![] },
897 description: Some("Main dashboard".to_string()),
898 label: Some("Main Dashboard".to_string()),
899 exposure_type: Some("dashboard".to_string()),
900 url: Some("https://bi.example.com".to_string()),
901 maturity: Some("high".to_string()),
902 owner: Some(ManifestExposureOwner {
903 name: Some("Data Team".to_string()),
904 email: Some("data@example.com".to_string()),
905 }),
906 },
907 )]),
908 ..Default::default()
909 };
910
911 let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
912 let exp_idx = graph
913 .node_indices()
914 .find(|&i| graph[i].node_type == NodeType::Exposure)
915 .expect("Should have an exposure node");
916 let exp = &graph[exp_idx];
917
918 let info = exp.exposure.as_ref().expect("Should have exposure info");
919 assert_eq!(info.label.as_deref(), Some("Main Dashboard"));
920 assert_eq!(info.exposure_type.as_deref(), Some("dashboard"));
921 assert_eq!(info.url.as_deref(), Some("https://bi.example.com"));
922 assert_eq!(info.maturity.as_deref(), Some("high"));
923
924 let owner = info.owner.as_ref().expect("Should have owner");
925 assert_eq!(owner.name.as_deref(), Some("Data Team"));
926 assert_eq!(owner.email.as_deref(), Some("data@example.com"));
927 }
928
929 #[test]
930 fn test_exposure_metadata_from_fixture() {
931 let manifest_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
932 .join("../../tests/fixtures/simple_project/target/manifest.json");
933 let graph = build_graph_from_manifest(&manifest_path).unwrap();
934
935 let exp_idx = graph
936 .node_indices()
937 .find(|&i| graph[i].node_type == NodeType::Exposure)
938 .expect("Should have an exposure node from fixture");
939 let exp = &graph[exp_idx];
940 assert_eq!(exp.label, "weekly_report");
941
942 let info = exp.exposure.as_ref().expect("Should have exposure info");
943 assert_eq!(info.label.as_deref(), Some("Weekly Report"));
944 assert_eq!(info.exposure_type.as_deref(), Some("dashboard"));
945 assert_eq!(info.url.as_deref(), Some("https://bi.example.com/weekly"));
946 assert_eq!(info.maturity.as_deref(), Some("high"));
947
948 let owner = info.owner.as_ref().expect("Should have owner");
949 assert_eq!(owner.name.as_deref(), Some("Data Team"));
950 assert_eq!(owner.email.as_deref(), Some("data@example.com"));
951 }
952
953 #[test]
954 fn test_build_graph_with_seeds_and_snapshots() {
955 let manifest = Manifest {
956 nodes: HashMap::from([
957 (
958 "seed.proj.countries".to_string(),
959 ManifestNode {
960 unique_id: "seed.proj.countries".to_string(),
961 name: "countries".to_string(),
962 resource_type: "seed".to_string(),
963 depends_on: DependsOn::default(),
964 config: ManifestConfig::default(),
965 description: None,
966 path: Some("seeds/countries.csv".to_string()),
967 original_file_path: None,
968 columns: HashMap::new(),
969 compiled_code: None,
970 database: None,
971 schema: None,
972 },
973 ),
974 (
975 "snapshot.proj.snap_orders".to_string(),
976 ManifestNode {
977 unique_id: "snapshot.proj.snap_orders".to_string(),
978 name: "snap_orders".to_string(),
979 resource_type: "snapshot".to_string(),
980 depends_on: DependsOn::default(),
981 config: ManifestConfig {
982 materialized: Some("snapshot".to_string()),
983 tags: vec![],
984 },
985 description: None,
986 path: Some("snapshots/snap_orders.sql".to_string()),
987 original_file_path: None,
988 columns: HashMap::new(),
989 compiled_code: None,
990 database: None,
991 schema: None,
992 },
993 ),
994 ]),
995 sources: HashMap::new(),
996 ..Default::default()
997 };
998
999 let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
1000 assert_eq!(graph.node_count(), 2);
1001
1002 let seed = graph
1003 .node_indices()
1004 .find(|&i| graph[i].node_type == NodeType::Seed)
1005 .expect("Should have a seed node");
1006 assert_eq!(graph[seed].label, "countries");
1007
1008 let snap = graph
1009 .node_indices()
1010 .find(|&i| graph[i].node_type == NodeType::Snapshot)
1011 .expect("Should have a snapshot node");
1012 assert_eq!(graph[snap].label, "snap_orders");
1013 }
1014
1015 #[test]
1016 fn test_build_graph_with_tests() {
1017 let manifest = Manifest {
1018 nodes: HashMap::from([
1019 (
1020 "model.proj.orders".to_string(),
1021 ManifestNode {
1022 unique_id: "model.proj.orders".to_string(),
1023 name: "orders".to_string(),
1024 resource_type: "model".to_string(),
1025 depends_on: DependsOn::default(),
1026 config: ManifestConfig::default(),
1027 description: None,
1028 path: None,
1029 original_file_path: None,
1030 columns: HashMap::new(),
1031 compiled_code: None,
1032 database: None,
1033 schema: None,
1034 },
1035 ),
1036 (
1037 "test.proj.assert_positive".to_string(),
1038 ManifestNode {
1039 unique_id: "test.proj.assert_positive".to_string(),
1040 name: "assert_positive".to_string(),
1041 resource_type: "test".to_string(),
1042 depends_on: DependsOn {
1043 nodes: vec!["model.proj.orders".to_string()],
1044 },
1045 config: ManifestConfig::default(),
1046 description: None,
1047 path: Some("tests/assert_positive.sql".to_string()),
1048 original_file_path: None,
1049 columns: HashMap::new(),
1050 compiled_code: None,
1051 database: None,
1052 schema: None,
1053 },
1054 ),
1055 ]),
1056 sources: HashMap::new(),
1057 ..Default::default()
1058 };
1059
1060 let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
1061 assert_eq!(graph.node_count(), 2);
1062 assert_eq!(graph.edge_count(), 1);
1063
1064 let test_node = graph
1065 .node_indices()
1066 .find(|&i| graph[i].node_type == NodeType::Test)
1067 .expect("Should have a test node");
1068 assert_eq!(graph[test_node].label, "assert_positive");
1069
1070 use petgraph::visit::IntoEdgeReferences;
1072 let edge = graph.edge_references().next().unwrap();
1073 assert_eq!(edge.weight().edge_type, EdgeType::Test);
1074 }
1075
1076 #[test]
1077 fn test_build_graph_empty_manifest() {
1078 let manifest = Manifest {
1079 nodes: HashMap::new(),
1080 sources: HashMap::new(),
1081 ..Default::default()
1082 };
1083
1084 let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
1085 assert_eq!(graph.node_count(), 0);
1086 assert_eq!(graph.edge_count(), 0);
1087 }
1088
1089 #[test]
1090 fn test_build_graph_missing_dependency() {
1091 let manifest = Manifest {
1093 nodes: HashMap::from([(
1094 "model.proj.orders".to_string(),
1095 ManifestNode {
1096 unique_id: "model.proj.orders".to_string(),
1097 name: "orders".to_string(),
1098 resource_type: "model".to_string(),
1099 depends_on: DependsOn {
1100 nodes: vec!["model.proj.nonexistent".to_string()],
1101 },
1102 config: ManifestConfig::default(),
1103 description: None,
1104 path: None,
1105 original_file_path: None,
1106 columns: HashMap::new(),
1107 compiled_code: None,
1108 database: None,
1109 schema: None,
1110 },
1111 )]),
1112 sources: HashMap::new(),
1113 ..Default::default()
1114 };
1115
1116 let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
1117 assert_eq!(graph.node_count(), 1);
1118 assert_eq!(graph.edge_count(), 0); }
1120
1121 #[test]
1122 fn test_build_graph_optional_fields() {
1123 let manifest = Manifest {
1124 nodes: HashMap::from([(
1125 "model.proj.bare".to_string(),
1126 ManifestNode {
1127 unique_id: "model.proj.bare".to_string(),
1128 name: "bare".to_string(),
1129 resource_type: "model".to_string(),
1130 depends_on: DependsOn::default(),
1131 config: ManifestConfig {
1132 materialized: None,
1133 tags: vec![],
1134 },
1135 description: None,
1136 path: None,
1137 original_file_path: None,
1138 columns: HashMap::new(),
1139 compiled_code: None,
1140 database: None,
1141 schema: None,
1142 },
1143 )]),
1144 sources: HashMap::new(),
1145 ..Default::default()
1146 };
1147
1148 let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
1149 let node = &graph[graph.node_indices().next().unwrap()];
1150 assert!(node.description.is_none());
1151 assert!(node.materialization.is_none());
1152 assert!(node.tags.is_empty());
1153 assert!(node.file_path.is_none());
1154 }
1155
1156 #[test]
1157 fn test_build_graph_from_manifest_file() {
1158 let tmp = tempfile::tempdir().unwrap();
1159 let manifest_path = tmp.path().join("manifest.json");
1160
1161 let manifest_json = r#"{
1162 "nodes": {
1163 "model.proj.stg_orders": {
1164 "unique_id": "model.proj.stg_orders",
1165 "name": "stg_orders",
1166 "resource_type": "model",
1167 "depends_on": { "nodes": ["source.proj.raw.orders"] },
1168 "config": { "materialized": "view", "tags": [] },
1169 "description": "Staged orders",
1170 "path": "models/staging/stg_orders.sql"
1171 }
1172 },
1173 "sources": {
1174 "source.proj.raw.orders": {
1175 "unique_id": "source.proj.raw.orders",
1176 "name": "orders",
1177 "source_name": "raw",
1178 "resource_type": "source",
1179 "description": "Raw orders",
1180 "path": "models/staging/schema.yml"
1181 }
1182 },
1183 "exposures": {}
1184 }"#;
1185
1186 fs::write(&manifest_path, manifest_json).unwrap();
1187
1188 let graph = build_graph_from_manifest(&manifest_path).unwrap();
1189 assert_eq!(graph.node_count(), 2);
1190 assert_eq!(graph.edge_count(), 1);
1191 }
1192
1193 #[test]
1194 fn test_build_graph_from_manifest_file_not_found() {
1195 let result = build_graph_from_manifest(Path::new("/nonexistent/manifest.json"));
1196 assert!(result.is_err());
1197 }
1198
1199 #[test]
1200 fn test_build_graph_from_manifest_invalid_json() {
1201 let tmp = tempfile::tempdir().unwrap();
1202 let manifest_path = tmp.path().join("manifest.json");
1203 fs::write(&manifest_path, "not valid json").unwrap();
1204
1205 let result = build_graph_from_manifest(&manifest_path);
1206 assert!(result.is_err());
1207 }
1208
1209 #[test]
1210 fn test_original_file_path_preferred_over_path() {
1211 let manifest = Manifest {
1216 nodes: HashMap::from([(
1217 "model.proj.stg_orders".to_string(),
1218 ManifestNode {
1219 unique_id: "model.proj.stg_orders".to_string(),
1220 name: "stg_orders".to_string(),
1221 resource_type: "model".to_string(),
1222 depends_on: DependsOn::default(),
1223 config: ManifestConfig::default(),
1224 description: None,
1225 path: Some("staging/stg_orders.sql".to_string()),
1226 original_file_path: Some("models/staging/stg_orders.sql".to_string()),
1227 columns: HashMap::new(),
1228 compiled_code: None,
1229 database: None,
1230 schema: None,
1231 },
1232 )]),
1233 sources: HashMap::new(),
1234 ..Default::default()
1235 };
1236
1237 let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
1238 let node = &graph[graph.node_indices().next().unwrap()];
1239 assert_eq!(
1240 node.file_path.as_ref().map(|p| p.to_str().unwrap()),
1241 Some("models/staging/stg_orders.sql")
1242 );
1243 }
1244
1245 #[test]
1246 fn test_build_graph_analysis_maps_to_model() {
1247 let manifest = Manifest {
1248 nodes: HashMap::from([(
1249 "analysis.proj.my_analysis".to_string(),
1250 ManifestNode {
1251 unique_id: "analysis.proj.my_analysis".to_string(),
1252 name: "my_analysis".to_string(),
1253 resource_type: "analysis".to_string(),
1254 depends_on: DependsOn::default(),
1255 config: ManifestConfig::default(),
1256 description: None,
1257 path: None,
1258 original_file_path: None,
1259 columns: HashMap::new(),
1260 compiled_code: None,
1261 database: None,
1262 schema: None,
1263 },
1264 )]),
1265 sources: HashMap::new(),
1266 ..Default::default()
1267 };
1268
1269 let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
1270 let node = &graph[graph.node_indices().next().unwrap()];
1271 assert_eq!(node.node_type, NodeType::Model);
1272 }
1273
1274 #[test]
1275 fn test_build_graph_complex_chain() {
1276 let manifest = Manifest {
1278 nodes: HashMap::from([
1279 (
1280 "model.proj.stg_orders".to_string(),
1281 ManifestNode {
1282 unique_id: "model.proj.stg_orders".to_string(),
1283 name: "stg_orders".to_string(),
1284 resource_type: "model".to_string(),
1285 depends_on: DependsOn {
1286 nodes: vec!["source.proj.raw.orders".to_string()],
1287 },
1288 config: ManifestConfig {
1289 materialized: Some("view".to_string()),
1290 tags: vec![],
1291 },
1292 description: None,
1293 path: None,
1294 original_file_path: None,
1295 columns: HashMap::new(),
1296 compiled_code: None,
1297 database: None,
1298 schema: None,
1299 },
1300 ),
1301 (
1302 "model.proj.stg_payments".to_string(),
1303 ManifestNode {
1304 unique_id: "model.proj.stg_payments".to_string(),
1305 name: "stg_payments".to_string(),
1306 resource_type: "model".to_string(),
1307 depends_on: DependsOn {
1308 nodes: vec!["source.proj.raw.payments".to_string()],
1309 },
1310 config: ManifestConfig::default(),
1311 description: None,
1312 path: None,
1313 original_file_path: None,
1314 columns: HashMap::new(),
1315 compiled_code: None,
1316 database: None,
1317 schema: None,
1318 },
1319 ),
1320 (
1321 "model.proj.orders".to_string(),
1322 ManifestNode {
1323 unique_id: "model.proj.orders".to_string(),
1324 name: "orders".to_string(),
1325 resource_type: "model".to_string(),
1326 depends_on: DependsOn {
1327 nodes: vec![
1328 "model.proj.stg_orders".to_string(),
1329 "model.proj.stg_payments".to_string(),
1330 ],
1331 },
1332 config: ManifestConfig {
1333 materialized: Some("table".to_string()),
1334 tags: vec!["marts".to_string()],
1335 },
1336 description: Some("Order fact table".to_string()),
1337 path: None,
1338 original_file_path: None,
1339 columns: HashMap::new(),
1340 compiled_code: None,
1341 database: None,
1342 schema: None,
1343 },
1344 ),
1345 ]),
1346 sources: HashMap::from([
1347 (
1348 "source.proj.raw.orders".to_string(),
1349 ManifestSource {
1350 unique_id: "source.proj.raw.orders".to_string(),
1351 name: "orders".to_string(),
1352 source_name: "raw".to_string(),
1353 resource_type: "source".to_string(),
1354 description: None,
1355 path: None,
1356 original_file_path: None,
1357 columns: HashMap::new(),
1358 database: None,
1359 schema: None,
1360 identifier: None,
1361 },
1362 ),
1363 (
1364 "source.proj.raw.payments".to_string(),
1365 ManifestSource {
1366 unique_id: "source.proj.raw.payments".to_string(),
1367 name: "payments".to_string(),
1368 source_name: "raw".to_string(),
1369 resource_type: "source".to_string(),
1370 description: None,
1371 path: None,
1372 original_file_path: None,
1373 columns: HashMap::new(),
1374 database: None,
1375 schema: None,
1376 identifier: None,
1377 },
1378 ),
1379 ]),
1380 ..Default::default()
1381 };
1382
1383 let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
1384 assert_eq!(graph.node_count(), 5);
1386 assert_eq!(graph.edge_count(), 4);
1389 }
1390
1391 #[test]
1392 fn test_build_graph_from_fixture_manifest() {
1393 let fixture_path = Path::new(env!("CARGO_MANIFEST_DIR"))
1394 .join("../../tests/fixtures/simple_project/target/manifest.json");
1395
1396 if !fixture_path.exists() {
1397 return;
1399 }
1400
1401 let graph = build_graph_from_manifest(&fixture_path).unwrap();
1402
1403 assert!(
1406 graph.node_count() >= 10,
1407 "Expected at least 10 nodes, got {}",
1408 graph.node_count()
1409 );
1410
1411 let has_source = graph
1413 .node_indices()
1414 .any(|i| graph[i].node_type == NodeType::Source);
1415 let has_model = graph
1416 .node_indices()
1417 .any(|i| graph[i].node_type == NodeType::Model);
1418 let has_seed = graph
1419 .node_indices()
1420 .any(|i| graph[i].node_type == NodeType::Seed);
1421 let has_test = graph
1422 .node_indices()
1423 .any(|i| graph[i].node_type == NodeType::Test);
1424 let has_exposure = graph
1425 .node_indices()
1426 .any(|i| graph[i].node_type == NodeType::Exposure);
1427
1428 assert!(has_source, "Should have source nodes");
1429 assert!(has_model, "Should have model nodes");
1430 assert!(has_seed, "Should have seed nodes");
1431 assert!(has_test, "Should have test nodes");
1432 assert!(has_exposure, "Should have exposure nodes");
1433
1434 assert!(graph.edge_count() > 0, "Should have edges");
1436 }
1437
1438 #[test]
1439 fn test_collect_file_paths() {
1440 let manifest = Manifest {
1441 nodes: HashMap::from([
1442 (
1443 "model.proj.stg_orders".to_string(),
1444 ManifestNode {
1445 unique_id: "model.proj.stg_orders".to_string(),
1446 name: "stg_orders".to_string(),
1447 resource_type: "model".to_string(),
1448 depends_on: DependsOn::default(),
1449 config: ManifestConfig::default(),
1450 description: None,
1451 path: Some("models/staging/stg_orders.sql".to_string()),
1452 original_file_path: None,
1453 columns: HashMap::new(),
1454 compiled_code: None,
1455 database: None,
1456 schema: None,
1457 },
1458 ),
1459 (
1460 "model.proj.orders".to_string(),
1461 ManifestNode {
1462 unique_id: "model.proj.orders".to_string(),
1463 name: "orders".to_string(),
1464 resource_type: "model".to_string(),
1465 depends_on: DependsOn::default(),
1466 config: ManifestConfig::default(),
1467 description: None,
1468 path: Some("models/marts/orders.sql".to_string()),
1469 original_file_path: None,
1470 columns: HashMap::new(),
1471 compiled_code: None,
1472 database: None,
1473 schema: None,
1474 },
1475 ),
1476 (
1477 "model.proj.bare".to_string(),
1478 ManifestNode {
1479 unique_id: "model.proj.bare".to_string(),
1480 name: "bare".to_string(),
1481 resource_type: "model".to_string(),
1482 depends_on: DependsOn::default(),
1483 config: ManifestConfig::default(),
1484 description: None,
1485 path: None,
1486 original_file_path: None,
1487 columns: HashMap::new(),
1488 compiled_code: None,
1489 database: None,
1490 schema: None,
1491 },
1492 ),
1493 ]),
1494 sources: HashMap::from([(
1495 "source.proj.raw.orders".to_string(),
1496 ManifestSource {
1497 unique_id: "source.proj.raw.orders".to_string(),
1498 name: "orders".to_string(),
1499 source_name: "raw".to_string(),
1500 resource_type: "source".to_string(),
1501 description: None,
1502 path: Some("models/staging/schema.yml".to_string()),
1503 original_file_path: None,
1504 columns: HashMap::new(),
1505 database: None,
1506 schema: None,
1507 identifier: None,
1508 },
1509 )]),
1510 ..Default::default()
1511 };
1512
1513 let paths = manifest.collect_file_paths();
1514 assert_eq!(paths.len(), 3);
1515 assert!(paths.contains("models/staging/stg_orders.sql"));
1516 assert!(paths.contains("models/marts/orders.sql"));
1517 assert!(paths.contains("models/staging/schema.yml"));
1518 assert!(!paths.iter().any(|p| p.contains("bare")));
1520 }
1521
1522 #[test]
1523 fn test_collect_file_paths_deduplicates() {
1524 let manifest = Manifest {
1526 nodes: HashMap::new(),
1527 sources: HashMap::from([
1528 (
1529 "source.proj.raw.orders".to_string(),
1530 ManifestSource {
1531 unique_id: "source.proj.raw.orders".to_string(),
1532 name: "orders".to_string(),
1533 source_name: "raw".to_string(),
1534 resource_type: "source".to_string(),
1535 description: None,
1536 path: Some("models/staging/schema.yml".to_string()),
1537 original_file_path: None,
1538 columns: HashMap::new(),
1539 database: None,
1540 schema: None,
1541 identifier: None,
1542 },
1543 ),
1544 (
1545 "source.proj.raw.customers".to_string(),
1546 ManifestSource {
1547 unique_id: "source.proj.raw.customers".to_string(),
1548 name: "customers".to_string(),
1549 source_name: "raw".to_string(),
1550 resource_type: "source".to_string(),
1551 description: None,
1552 path: Some("models/staging/schema.yml".to_string()),
1553 original_file_path: None,
1554 columns: HashMap::new(),
1555 database: None,
1556 schema: None,
1557 identifier: None,
1558 },
1559 ),
1560 ]),
1561 ..Default::default()
1562 };
1563
1564 let paths = manifest.collect_file_paths();
1565 assert_eq!(paths.len(), 1, "Duplicate paths should be deduplicated");
1566 }
1567
1568 #[test]
1569 fn test_load_manifest() {
1570 let fixture_path = Path::new(env!("CARGO_MANIFEST_DIR"))
1571 .join("../../tests/fixtures/simple_project/target/manifest.json");
1572
1573 let manifest = load_manifest(&fixture_path).unwrap();
1574 assert!(!manifest.nodes.is_empty());
1575 assert!(!manifest.sources.is_empty());
1576
1577 let paths = manifest.collect_file_paths();
1578 assert!(paths.contains("models/staging/stg_orders.sql"));
1579 assert!(paths.contains("models/staging/schema.yml"));
1580 }
1581
1582 #[test]
1583 fn test_collect_sql_contents_from_manifest() {
1584 let manifest = Manifest {
1585 nodes: HashMap::from([
1586 (
1587 "model.proj.stg_orders".to_string(),
1588 ManifestNode {
1589 unique_id: "model.proj.stg_orders".to_string(),
1590 name: "stg_orders".to_string(),
1591 resource_type: "model".to_string(),
1592 depends_on: DependsOn::default(),
1593 config: ManifestConfig::default(),
1594 description: None,
1595 path: None,
1596 original_file_path: None,
1597 columns: HashMap::new(),
1598 compiled_code: Some("select * from raw.orders".to_string()),
1599 database: None,
1600 schema: None,
1601 },
1602 ),
1603 (
1604 "test.proj.not_null_orders_id.abc123".to_string(),
1605 ManifestNode {
1606 unique_id: "test.proj.not_null_orders_id.abc123".to_string(),
1607 name: "not_null_orders_id".to_string(),
1608 resource_type: "test".to_string(),
1609 depends_on: DependsOn::default(),
1610 config: ManifestConfig::default(),
1611 description: None,
1612 path: None,
1613 original_file_path: None,
1614 columns: HashMap::new(),
1615 compiled_code: Some(
1616 "select count(*) from orders where id is null".to_string(),
1617 ),
1618 database: None,
1619 schema: None,
1620 },
1621 ),
1622 (
1623 "model.proj.no_compile".to_string(),
1624 ManifestNode {
1625 unique_id: "model.proj.no_compile".to_string(),
1626 name: "no_compile".to_string(),
1627 resource_type: "model".to_string(),
1628 depends_on: DependsOn::default(),
1629 config: ManifestConfig::default(),
1630 description: None,
1631 path: None,
1632 original_file_path: None,
1633 columns: HashMap::new(),
1634 compiled_code: None,
1635 database: None,
1636 schema: None,
1637 },
1638 ),
1639 ]),
1640 sources: HashMap::new(),
1641 ..Default::default()
1642 };
1643
1644 let sql_contents = manifest.collect_sql_contents();
1645
1646 assert_eq!(
1648 sql_contents.get("model.stg_orders").map(|s| s.as_str()),
1649 Some("select * from raw.orders")
1650 );
1651 assert_eq!(
1653 sql_contents
1654 .get("test.not_null_orders_id")
1655 .map(|s| s.as_str()),
1656 Some("select count(*) from orders where id is null")
1657 );
1658 assert!(!sql_contents.contains_key("model.no_compile"));
1660 }
1661
1662 #[test]
1663 fn test_collect_sql_contents_from_fixture() {
1664 let fixture_path = Path::new(env!("CARGO_MANIFEST_DIR"))
1665 .join("../../tests/fixtures/simple_project/target/manifest.json");
1666
1667 let manifest = load_manifest(&fixture_path).unwrap();
1668 let sql_contents = manifest.collect_sql_contents();
1669
1670 assert!(
1672 sql_contents.contains_key("model.stg_orders"),
1673 "stg_orders should have compiled_code"
1674 );
1675 assert!(
1676 sql_contents.contains_key("test.assert_orders_positive_amount"),
1677 "test node should have compiled_code"
1678 );
1679 assert!(
1681 !sql_contents.contains_key("model.customers"),
1682 "customers has no compiled_code in fixture"
1683 );
1684 }
1685
1686 #[test]
1687 fn test_build_graph_with_semantic_layer_nodes() {
1688 let manifest = Manifest {
1689 nodes: HashMap::from([(
1690 "model.proj.orders".to_string(),
1691 ManifestNode {
1692 unique_id: "model.proj.orders".to_string(),
1693 name: "orders".to_string(),
1694 resource_type: "model".to_string(),
1695 depends_on: DependsOn::default(),
1696 config: ManifestConfig::default(),
1697 description: None,
1698 path: None,
1699 original_file_path: None,
1700 columns: HashMap::new(),
1701 compiled_code: None,
1702 database: None,
1703 schema: None,
1704 },
1705 )]),
1706 sources: HashMap::new(),
1707 semantic_models: HashMap::from([(
1708 "semantic_model.proj.orders".to_string(),
1709 ManifestSemanticModel {
1710 unique_id: "semantic_model.proj.orders".to_string(),
1711 name: "orders".to_string(),
1712 label: None,
1713 depends_on: DependsOn {
1714 nodes: vec!["model.proj.orders".to_string()],
1715 },
1716 description: Some("Orders semantic model".to_string()),
1717 path: None,
1718 original_file_path: None,
1719 },
1720 )]),
1721 metrics: HashMap::from([(
1722 "metric.proj.order_count".to_string(),
1723 ManifestMetric {
1724 unique_id: "metric.proj.order_count".to_string(),
1725 name: "order_count".to_string(),
1726 label: None,
1727 depends_on: DependsOn {
1728 nodes: vec!["semantic_model.proj.orders".to_string()],
1729 },
1730 description: None,
1731 path: None,
1732 original_file_path: None,
1733 },
1734 )]),
1735 saved_queries: HashMap::from([(
1736 "saved_query.proj.order_metrics".to_string(),
1737 ManifestSavedQuery {
1738 unique_id: "saved_query.proj.order_metrics".to_string(),
1739 name: "order_metrics".to_string(),
1740 label: None,
1741 depends_on: DependsOn {
1742 nodes: vec!["metric.proj.order_count".to_string()],
1743 },
1744 description: None,
1745 path: None,
1746 original_file_path: None,
1747 },
1748 )]),
1749 ..Default::default()
1750 };
1751
1752 let graph = build_graph_from_parsed_manifest(&manifest).unwrap();
1753
1754 assert_eq!(graph.node_count(), 4);
1756 assert_eq!(graph.edge_count(), 3);
1758
1759 let sem = graph
1760 .node_indices()
1761 .find(|&i| graph[i].node_type == NodeType::SemanticModel)
1762 .expect("Should have a semantic_model node");
1763 assert_eq!(graph[sem].unique_id, "semantic_model.orders");
1764 assert_eq!(graph[sem].label, "orders");
1765 assert_eq!(
1766 graph[sem].description.as_deref(),
1767 Some("Orders semantic model")
1768 );
1769
1770 let metric = graph
1771 .node_indices()
1772 .find(|&i| graph[i].node_type == NodeType::Metric)
1773 .expect("Should have a metric node");
1774 assert_eq!(graph[metric].unique_id, "metric.order_count");
1775
1776 let sq = graph
1777 .node_indices()
1778 .find(|&i| graph[i].node_type == NodeType::SavedQuery)
1779 .expect("Should have a saved_query node");
1780 assert_eq!(graph[sq].unique_id, "saved_query.order_metrics");
1781 }
1782
1783 #[test]
1784 fn test_semantic_layer_nodes_from_jaffle_shop_manifest() {
1785 let manifest_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1786 .join("../../../../refs/jaffle-shop/target/manifest.json");
1787 if !manifest_path.exists() {
1788 eprintln!(
1789 "SKIP: jaffle-shop fixture not found at {manifest_path:?}; run `make fixtures` to enable this test"
1790 );
1791 return;
1792 }
1793 let graph = build_graph_from_manifest(&manifest_path).unwrap();
1794
1795 let sem_models: Vec<_> = graph
1796 .node_indices()
1797 .filter(|&i| graph[i].node_type == NodeType::SemanticModel)
1798 .collect();
1799 assert!(!sem_models.is_empty(), "Should have semantic_model nodes");
1800
1801 let metrics: Vec<_> = graph
1802 .node_indices()
1803 .filter(|&i| graph[i].node_type == NodeType::Metric)
1804 .collect();
1805 assert!(!metrics.is_empty(), "Should have metric nodes");
1806
1807 let saved_queries: Vec<_> = graph
1808 .node_indices()
1809 .filter(|&i| graph[i].node_type == NodeType::SavedQuery)
1810 .collect();
1811 assert!(!saved_queries.is_empty(), "Should have saved_query nodes");
1812
1813 let sem_idx = sem_models[0];
1815 let has_upstream = graph
1816 .edges_directed(sem_idx, petgraph::Direction::Incoming)
1817 .next()
1818 .is_some();
1819 assert!(
1820 has_upstream,
1821 "semantic_model should have upstream model edge"
1822 );
1823 }
1824}