1use super::frontmatter::{
2 BronzeCheckMode, BronzeDiagnostic, BronzeValidationReport, DiagnosticSeverity,
3 StagingFrontmatter,
4};
5use super::parser::{DependencyRef, SqlModelParser};
6use anyhow::{bail, Result};
7use petgraph::algo::{is_cyclic_directed, toposort};
8use petgraph::graph::{DiGraph, NodeIndex};
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11use std::path::Path;
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15pub enum Materialization {
16 View,
17 Table,
18 IncrementalAppend,
19 IncrementalMerge,
20 ZeroCopyClone,
22}
23
24pub fn parse_materialization_hint(s: &str) -> Result<Materialization> {
26 match s.trim().to_ascii_lowercase().as_str() {
27 "table" | "full_refresh" | "full-refresh" => Ok(Materialization::Table),
28 "view" => Ok(Materialization::View),
29 "incremental_append" | "append" | "incremental" => Ok(Materialization::IncrementalAppend),
30 "incremental_merge" | "merge" => Ok(Materialization::IncrementalMerge),
31 "zero_copy_clone" | "clone" => Ok(Materialization::ZeroCopyClone),
32 other => bail!(
33 "E_RBT_MATERIALIZATION: unknown materialization '{other}' \
34 (table | view | incremental_append | incremental_merge)"
35 ),
36 }
37}
38
39#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
41pub enum OutputFormat {
42 #[default]
44 Parquet,
45 Jsonl,
47 Csv,
49 Iceberg,
51 ParquetAndIceberg,
53 ZeroCopyClone,
55}
56
57#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
59pub enum ModelLayer {
60 Staging,
61 Transform,
62 Mart,
63}
64
65impl ModelLayer {
66 pub fn from_name(name: &str) -> Self {
67 if name.starts_with("stg_") {
68 Self::Staging
69 } else if name.starts_with("tf_") || name.starts_with("int_") {
70 Self::Transform
71 } else if name.starts_with("dim_")
72 || name.starts_with("fact_")
73 || name.starts_with("obt_")
74 || name.starts_with("fct_")
75 {
76 Self::Mart
77 } else {
78 Self::Transform
79 }
80 }
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct ModelNode {
86 pub name: String,
87 pub description: Option<String>,
88 pub raw_sql: String,
89 pub compiled_sql: String,
90 pub materialization: Materialization,
91 pub output_format: OutputFormat,
92 pub output_path: Option<String>,
93 pub dependencies: Vec<DependencyRef>,
94 pub layer: ModelLayer,
95 pub frontmatter: Option<StagingFrontmatter>,
96}
97
98#[derive(Debug, Default)]
100pub struct ModelDag {
101 pub graph: DiGraph<ModelNode, ()>,
102 pub node_map: HashMap<String, NodeIndex>,
103}
104
105impl ModelDag {
106 pub fn new() -> Self {
107 Self::default()
108 }
109
110 pub fn add_model(
112 &mut self,
113 name: impl Into<String>,
114 raw_sql: &str,
115 materialization: Materialization,
116 catalog_prefix: &str,
117 ) -> Result<NodeIndex> {
118 self.add_model_with_format(
119 name,
120 raw_sql,
121 materialization,
122 OutputFormat::Parquet,
123 None,
124 catalog_prefix,
125 )
126 }
127
128 pub fn add_model_with_format(
130 &mut self,
131 name: impl Into<String>,
132 raw_sql: &str,
133 materialization: Materialization,
134 output_format: OutputFormat,
135 output_path: Option<String>,
136 catalog_prefix: &str,
137 ) -> Result<NodeIndex> {
138 let name = name.into();
139 let (frontmatter, pure_sql) = SqlModelParser::parse_frontmatter(raw_sql)
140 .map_err(|e| anyhow::anyhow!("model '{}': {}", name, e))?;
141 let dependencies = SqlModelParser::extract_dependencies(&pure_sql)?;
142 let compiled_sql = SqlModelParser::compile_sql(&pure_sql, catalog_prefix)?;
143 let layer = ModelLayer::from_name(&name);
144 let description = frontmatter.as_ref().and_then(|f| f.description.clone());
145 let materialization = frontmatter
147 .as_ref()
148 .and_then(|f| f.materialization.as_deref())
149 .map(parse_materialization_hint)
150 .transpose()?
151 .unwrap_or(materialization);
152
153 let node = ModelNode {
154 name: name.clone(),
155 description,
156 raw_sql: raw_sql.to_string(),
157 compiled_sql,
158 materialization,
159 output_format,
160 output_path,
161 dependencies,
162 layer,
163 frontmatter,
164 };
165
166 let idx = self.graph.add_node(node);
167 self.node_map.insert(name, idx);
168 Ok(idx)
169 }
170
171 pub fn build_graph(&mut self) -> Result<()> {
173 let name_map = self.node_map.clone();
174 let mut edges = Vec::new();
175
176 for (idx, node) in self.node_map.values().map(|&i| (i, &self.graph[i])) {
177 for dep in &node.dependencies {
178 if let DependencyRef::Model(dep_name) = dep {
179 if let Some(&dep_idx) = name_map.get(dep_name) {
180 let dep_node = &self.graph[dep_idx];
181
182 if node.layer == ModelLayer::Transform && dep_node.layer == ModelLayer::Mart
184 {
185 bail!(
186 "Illegal DAG Layer Boundary: Transform model '{}' (tf_) cannot depend on Mart model '{}' (dim_/fact_/obt_)",
187 node.name,
188 dep_node.name
189 );
190 }
191
192 if node.layer == ModelLayer::Staging {
194 bail!(
195 "Illegal DAG Layer Boundary: Staging model '{}' (stg_) cannot depend on downstream model '{}'",
196 node.name,
197 dep_node.name
198 );
199 }
200
201 edges.push((dep_idx, idx));
202 } else {
203 bail!(
204 "Missing model dependency '{}' referenced by model '{}'",
205 dep_name,
206 node.name
207 );
208 }
209 }
210 }
211 }
212
213 for (from, to) in edges {
214 self.graph.add_edge(from, to, ());
215 }
216
217 if is_cyclic_directed(&self.graph) {
218 bail!("Circular dependency detected in model pipeline DAG!");
219 }
220
221 Ok(())
222 }
223
224 pub fn topological_sequence(&self) -> Result<Vec<ModelNode>> {
226 let indices = toposort(&self.graph, None)
227 .map_err(|_| anyhow::anyhow!("Circular dependency found during topological sort"))?;
228
229 Ok(indices.into_iter().map(|i| self.graph[i].clone()).collect())
230 }
231
232 pub fn execution_tiers(&self) -> Result<Vec<Vec<ModelNode>>> {
234 let _sorted = self.topological_sequence()?;
235 let mut in_degrees: HashMap<NodeIndex, usize> = HashMap::new();
236
237 for idx in self.graph.node_indices() {
238 in_degrees.insert(
239 idx,
240 self.graph
241 .neighbors_directed(idx, petgraph::Direction::Incoming)
242 .count(),
243 );
244 }
245
246 let mut current_tier: Vec<NodeIndex> = in_degrees
247 .iter()
248 .filter(|(_, °)| deg == 0)
249 .map(|(&idx, _)| idx)
250 .collect();
251
252 let mut tiers = Vec::new();
253 let mut visited = HashSet::new();
254
255 while !current_tier.is_empty() {
256 let tier_nodes: Vec<ModelNode> = current_tier
257 .iter()
258 .map(|&i| self.graph[i].clone())
259 .collect();
260 tiers.push(tier_nodes);
261
262 for &node_idx in ¤t_tier {
263 visited.insert(node_idx);
264 }
265
266 let mut next_tier = Vec::new();
267 for idx in self.graph.node_indices() {
268 if visited.contains(&idx) {
269 continue;
270 }
271 let incoming_unvisited = self
272 .graph
273 .neighbors_directed(idx, petgraph::Direction::Incoming)
274 .filter(|n| !visited.contains(n))
275 .count();
276
277 if incoming_unvisited == 0 {
278 next_tier.push(idx);
279 }
280 }
281
282 current_tier = next_tier;
283 }
284
285 Ok(tiers)
286 }
287
288 pub fn primary_source(node: &ModelNode) -> Option<(&str, &str)> {
290 node.dependencies.iter().find_map(|d| match d {
291 DependencyRef::Source {
292 source_name,
293 table_name,
294 } => Some((source_name.as_str(), table_name.as_str())),
295 _ => None,
296 })
297 }
298
299 pub fn bronze_source_ident(node: &ModelNode) -> Option<(String, String)> {
301 if let Some(fm) = &node.frontmatter {
302 if let (Some(s), Some(t)) = (&fm.source_name, &fm.source_table) {
303 return Some((s.clone(), t.clone()));
304 }
305 if let Some((s, t)) = Self::primary_source(node) {
306 let source = fm.source_name.clone().unwrap_or_else(|| s.to_string());
307 let table = fm.source_table.clone().unwrap_or_else(|| t.to_string());
308 return Some((source, table));
309 }
310 if fm.has_scan_contract() {
311 let table = fm.source_table.clone().unwrap_or_else(|| node.name.clone());
313 let source = fm
314 .source_name
315 .clone()
316 .unwrap_or_else(|| "bronze".to_string());
317 return Some((source, table));
318 }
319 }
320 Self::primary_source(node).map(|(s, t)| (s.to_string(), t.to_string()))
321 }
322
323 pub fn validate_bronze_sources(
329 &self,
330 project_dir: &Path,
331 mode: BronzeCheckMode,
332 ) -> Result<BronzeValidationReport> {
333 self.validate_bronze_sources_with_roots(
334 project_dir,
335 mode,
336 &std::collections::HashMap::new(),
337 )
338 }
339
340 pub fn validate_bronze_sources_with_roots(
342 &self,
343 project_dir: &Path,
344 mode: BronzeCheckMode,
345 roots: &std::collections::HashMap<String, String>,
346 ) -> Result<BronzeValidationReport> {
347 if mode == BronzeCheckMode::Off {
348 return Ok(BronzeValidationReport::default());
349 }
350
351 let severity = match mode {
352 BronzeCheckMode::Fail => DiagnosticSeverity::Error,
353 BronzeCheckMode::Warn => DiagnosticSeverity::Warning,
354 BronzeCheckMode::Off => unreachable!(),
355 };
356
357 let mut report = BronzeValidationReport::default();
358
359 for idx in self.graph.node_indices() {
360 let node = &self.graph[idx];
361 let has_source_dep = node
362 .dependencies
363 .iter()
364 .any(|d| matches!(d, DependencyRef::Source { .. }));
365 let fm = node.frontmatter.as_ref();
366
367 if has_source_dep && node.layer == ModelLayer::Staging {
369 let missing_scan = fm.map(|f| !f.has_scan_contract()).unwrap_or(true);
370 if missing_scan {
371 report.diagnostics.push(BronzeDiagnostic {
372 model: node.name.clone(),
373 severity,
374 code: "E_RBT_BRONZE_SCAN_PATH_MISSING",
375 message:
376 "staging model references source() but has no frontmatter scan_path; \
377 add YAML frontmatter with scan_path (and source_format)"
378 .to_string(),
379 });
380 continue;
381 }
382 }
383
384 let Some(fm) = fm else {
385 continue;
386 };
387
388 if !fm.has_scan_contract() {
389 continue;
390 }
391
392 let scan_path = fm.scan_path.as_deref().unwrap();
393
394 if let Err(e) = fm.resolve_format() {
396 report.diagnostics.push(BronzeDiagnostic {
397 model: node.name.clone(),
398 severity,
399 code: "E_RBT_BRONZE_FORMAT_UNKNOWN",
400 message: e.to_string(),
401 });
402 }
403
404 if !super::frontmatter::scan_path_exists_with_roots(project_dir, scan_path, roots) {
405 if fm.on_missing_policy() == super::run_scope::OnMissing::Empty {
407 if let Err(e) = fm.empty_frame_schema() {
408 report.diagnostics.push(BronzeDiagnostic {
409 model: node.name.clone(),
410 severity,
411 code: "E_RBT_EMPTY_SCHEMA",
412 message: format!(
413 "on_missing: empty but schema invalid while scan_path missing: {e}"
414 ),
415 });
416 }
417 } else {
418 let resolved = super::paths::resolve_project_path(project_dir, scan_path, roots)
419 .unwrap_or_else(|_| project_dir.join(scan_path));
420 report.diagnostics.push(BronzeDiagnostic {
421 model: node.name.clone(),
422 severity,
423 code: "E_RBT_BRONZE_SCAN_PATH_NOT_FOUND",
424 message: format!(
425 "scan_path '{}' does not exist (resolved: {}). \
426 Hint: set on_missing: empty for optional artifact families.",
427 scan_path,
428 resolved.display()
429 ),
430 });
431 }
432 }
433
434 if let Some(globs) = fm.path_glob.as_ref() {
436 if let Err(e) = super::paths::validate_glob_patterns(globs) {
437 report.diagnostics.push(BronzeDiagnostic {
438 model: node.name.clone(),
439 severity,
440 code: "E_RBT_PATH_GLOB_INVALID",
441 message: e.to_string(),
442 });
443 }
444 }
445
446 if let (Some((dep_s, dep_t)), Some(ident)) =
448 (Self::primary_source(node), Self::bronze_source_ident(node))
449 {
450 if let Some(fm_s) = &fm.source_name {
451 if fm_s != dep_s {
452 report.diagnostics.push(BronzeDiagnostic {
453 model: node.name.clone(),
454 severity: DiagnosticSeverity::Warning,
455 code: "W_RBT_BRONZE_SOURCE_NAME_MISMATCH",
456 message: format!(
457 "frontmatter source_name='{}' differs from source('{}', ...); \
458 registration will use '{}'.{} ",
459 fm_s, dep_s, ident.0, ident.1
460 ),
461 });
462 }
463 }
464 if let Some(fm_t) = &fm.source_table {
465 if fm_t != dep_t {
466 report.diagnostics.push(BronzeDiagnostic {
467 model: node.name.clone(),
468 severity: DiagnosticSeverity::Warning,
469 code: "W_RBT_BRONZE_SOURCE_TABLE_MISMATCH",
470 message: format!(
471 "frontmatter source_table='{}' differs from source(..., '{}')",
472 fm_t, dep_t
473 ),
474 });
475 }
476 }
477 }
478 }
479
480 self.append_modeling_hygiene_diagnostics(&mut report);
482 Ok(report)
483 }
484
485 pub fn modeling_hygiene_diagnostics(&self) -> Vec<BronzeDiagnostic> {
487 let mut report = BronzeValidationReport::default();
488 self.append_modeling_hygiene_diagnostics(&mut report);
489 report.diagnostics
490 }
491
492 fn append_modeling_hygiene_diagnostics(&self, report: &mut BronzeValidationReport) {
493 for idx in self.graph.node_indices() {
494 let node = &self.graph[idx];
495 let fm = node.frontmatter.as_ref();
496
497 for dep in &node.dependencies {
499 if let DependencyRef::Source {
500 source_name: _,
501 table_name,
502 } = dep
503 {
504 let t = table_name.as_str();
505 if t.starts_with("tf_") || t.starts_with("int_") {
506 report.diagnostics.push(BronzeDiagnostic {
507 model: node.name.clone(),
508 severity: DiagnosticSeverity::Warning,
509 code: "W_RBT_SOURCE_UPSTREAM_TRANSFORM",
510 message: format!(
511 "source(..., '{table_name}') looks like a transform endpoint; \
512 prefer published stage (stg_*) or dim/fact contracts, not private tf_*"
513 ),
514 });
515 }
516 }
517 }
518
519 if matches!(node.layer, ModelLayer::Mart) {
522 if let Some(fm) = fm {
523 if fm.wants_parts_source() || fm.has_scan_contract() {
524 report.diagnostics.push(BronzeDiagnostic {
525 model: node.name.clone(),
526 severity: DiagnosticSeverity::Warning,
527 code: "W_RBT_MART_SCAN_CONTRACT",
528 message: "mart (dim_/fact_/obt_) declares a bronze scan/parts contract; \
529 prefer scan on stg_*, business prep on tf_*, thin marts via ref()"
530 .into(),
531 });
532 }
533 }
534 }
535
536 let Some(fm) = fm else {
537 continue;
538 };
539
540 if let Some(grain) = fm.grain.as_ref().filter(|g| !g.is_empty()) {
542 let unique_cols: Option<Vec<String>> = fm
543 .tests
544 .as_ref()
545 .and_then(|t| t.unique.clone())
546 .or_else(|| fm.unique_key.clone());
547 match unique_cols {
548 None => {
549 report.diagnostics.push(BronzeDiagnostic {
550 model: node.name.clone(),
551 severity: DiagnosticSeverity::Warning,
552 code: "W_RBT_GRAIN_NO_UNIQUE",
553 message: format!(
554 "grain {:?} declared but no tests.unique / unique_key; \
555 grain should be testable for uniqueness",
556 grain
557 ),
558 });
559 }
560 Some(u) if u != *grain && u.len() != 1 => {
561 if !(u.len() == 1
563 && (u[0].ends_with("_sk") || u[0].ends_with("_key") || u[0] == "sk"))
564 {
565 report.diagnostics.push(BronzeDiagnostic {
566 model: node.name.clone(),
567 severity: DiagnosticSeverity::Warning,
568 code: "W_RBT_GRAIN_UNIQUE_MISMATCH",
569 message: format!(
570 "grain {:?} differs from unique {:?}; ensure SK vs NK is intentional",
571 grain, u
572 ),
573 });
574 }
575 }
576 _ => {}
577 }
578 }
579
580 if node.name.starts_with("fact_") || node.name.starts_with("fct_") {
582 let has_rel = fm
583 .tests
584 .as_ref()
585 .and_then(|t| t.relationships.as_ref())
586 .map(|r| !r.is_empty())
587 .unwrap_or(false);
588 if !has_rel {
589 report.diagnostics.push(BronzeDiagnostic {
590 model: node.name.clone(),
591 severity: DiagnosticSeverity::Warning,
592 code: "W_RBT_FACT_NO_RELATIONSHIP",
593 message: "fact model has no tests.relationships; \
594 prefer SK FK checks to dim_* (Unknown member -1)"
595 .into(),
596 });
597 }
598 }
599
600 if let Some(grain) = &fm.grain {
604 if grain.iter().any(|c| c.starts_with("_rbt_")) {
605 report.diagnostics.push(BronzeDiagnostic {
606 model: node.name.clone(),
607 severity: DiagnosticSeverity::Warning,
608 code: "W_RBT_LINEAGE_IN_GRAIN",
609 message: "grain includes _rbt_* lineage columns; keep lineage out of business grain"
610 .into(),
611 });
612 }
613 }
614 }
615 }
616}
617
618#[cfg(test)]
619mod tests {
620 use super::*;
621
622 #[test]
623 fn test_dag_building_and_tiering() -> Result<()> {
624 let mut dag = ModelDag::new();
625
626 dag.add_model_with_format(
628 "stg_users",
629 "SELECT * FROM {{ source('raw', 'users') }}",
630 Materialization::View,
631 OutputFormat::Jsonl,
632 None,
633 "db",
634 )?;
635 dag.add_model_with_format(
636 "stg_orders",
637 "SELECT * FROM {{ source('raw', 'orders') }}",
638 Materialization::View,
639 OutputFormat::ParquetAndIceberg,
640 None,
641 "db",
642 )?;
643
644 dag.add_model(
646 "int_user_orders",
647 "SELECT * FROM {{ ref('stg_users') }} u JOIN {{ ref('stg_orders') }} o ON u.id = o.user_id",
648 Materialization::Table,
649 "db",
650 )?;
651
652 dag.add_model(
654 "fct_revenue",
655 "SELECT user_id, SUM(amount) FROM {{ ref('int_user_orders') }} GROUP BY user_id",
656 Materialization::IncrementalAppend,
657 "db",
658 )?;
659
660 dag.build_graph()?;
661
662 let tiers = dag.execution_tiers()?;
663 assert_eq!(tiers.len(), 3);
664
665 assert_eq!(tiers[0].len(), 2);
667 let tier0_formats: Vec<OutputFormat> =
668 tiers[0].iter().map(|m| m.output_format.clone()).collect();
669 assert!(tier0_formats.contains(&OutputFormat::Jsonl));
670 assert!(tier0_formats.contains(&OutputFormat::ParquetAndIceberg));
671
672 Ok(())
673 }
674
675 #[test]
676 fn test_cycle_detection() -> Result<()> {
677 let mut dag = ModelDag::new();
678
679 dag.add_model(
680 "model_a",
681 "SELECT * FROM {{ ref('model_b') }}",
682 Materialization::View,
683 "db",
684 )?;
685 dag.add_model(
686 "model_b",
687 "SELECT * FROM {{ ref('model_a') }}",
688 Materialization::View,
689 "db",
690 )?;
691
692 let res = dag.build_graph();
693 assert!(res.is_err());
694 assert!(res.unwrap_err().to_string().contains("Circular dependency"));
695
696 Ok(())
697 }
698
699 #[test]
700 fn modeling_hygiene_flags_source_tf_and_grain() -> Result<()> {
701 let mut dag = ModelDag::new();
702 dag.add_model_with_format(
703 "stg_ok",
704 "---\ngrain: [id]\n---\nSELECT 1 AS id FROM {{ source('raw', 'x') }}",
705 Materialization::Table,
706 OutputFormat::Parquet,
707 None,
708 "db",
709 )?;
710 dag.add_model_with_format(
711 "stg_bad_source",
712 "SELECT * FROM {{ source('upstream', 'tf_secret') }}",
713 Materialization::Table,
714 OutputFormat::Parquet,
715 None,
716 "db",
717 )?;
718 dag.add_model_with_format(
719 "fact_sales",
720 "---\ngrain: [id]\ntests:\n unique: [id]\n---\nSELECT 1 AS id",
721 Materialization::Table,
722 OutputFormat::Parquet,
723 None,
724 "db",
725 )?;
726 dag.build_graph()?;
727 let diags = dag.modeling_hygiene_diagnostics();
728 assert!(
729 diags.iter().any(|d| d.code == "W_RBT_GRAIN_NO_UNIQUE"),
730 "expected grain warning, got {:?}",
731 diags
732 );
733 assert!(
734 diags
735 .iter()
736 .any(|d| d.code == "W_RBT_SOURCE_UPSTREAM_TRANSFORM"),
737 "expected source(tf_*) warning, got {:?}",
738 diags
739 );
740 assert!(
741 diags.iter().any(|d| d.code == "W_RBT_FACT_NO_RELATIONSHIP"),
742 "expected fact relationship warning, got {:?}",
743 diags
744 );
745 Ok(())
746 }
747
748 #[test]
749 fn test_layer_boundary_enforcement() -> Result<()> {
750 let mut dag = ModelDag::new();
751
752 dag.add_model(
754 "fact_orders",
755 "SELECT 1 AS order_id",
756 Materialization::Table,
757 "db",
758 )?;
759
760 dag.add_model(
762 "tf_illegal_transform",
763 "SELECT * FROM {{ ref('fact_orders') }}",
764 Materialization::Table,
765 "db",
766 )?;
767
768 let res = dag.build_graph();
769 assert!(res.is_err());
770 assert!(res
771 .unwrap_err()
772 .to_string()
773 .contains("Illegal DAG Layer Boundary"));
774
775 Ok(())
776 }
777
778 #[test]
779 fn test_bronze_scan_path_validation_warn_and_fail() -> Result<()> {
780 let mut dag = ModelDag::new();
781 dag.add_model_with_format(
782 "stg_events",
783 r#"---
784source_format: jsonl
785scan_path: "lake/bronze/missing.jsonl"
786---
787SELECT * FROM {{ source('bronze', 'events') }}
788"#,
789 Materialization::Table,
790 OutputFormat::Parquet,
791 None,
792 "",
793 )?;
794 dag.build_graph()?;
795
796 let project = Path::new("/tmp/rbt_nonexistent_project_root");
797 let warn = dag.validate_bronze_sources(project, BronzeCheckMode::Warn)?;
798 assert_eq!(warn.error_count(), 0);
799 assert!(warn.warning_count() >= 1);
800 assert!(warn
801 .diagnostics
802 .iter()
803 .any(|d| d.code == "E_RBT_BRONZE_SCAN_PATH_NOT_FOUND"));
804
805 let fail = dag.validate_bronze_sources(project, BronzeCheckMode::Fail)?;
806 assert!(fail.has_errors());
807 Ok(())
808 }
809}