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 {
195 match dep_node.layer {
196 ModelLayer::Mart => {
197 bail!(
198 "Illegal DAG Layer Boundary: Staging model '{}' (stg_) cannot depend on Mart model '{}'",
199 node.name,
200 dep_node.name
201 );
202 }
203 ModelLayer::Staging => {
204 bail!(
205 "Illegal DAG Layer Boundary: Staging model '{}' cannot depend on staging model '{}' \
206 (stg_* are silver endpoints; chain prep in tf_* then land stg_*)",
207 node.name,
208 dep_node.name
209 );
210 }
211 ModelLayer::Transform => {
212 }
214 }
215 }
216
217 edges.push((dep_idx, idx));
218 } else {
219 bail!(
220 "Missing model dependency '{}' referenced by model '{}'",
221 dep_name,
222 node.name
223 );
224 }
225 }
226 }
227 }
228
229 for (from, to) in edges {
230 self.graph.add_edge(from, to, ());
231 }
232
233 if is_cyclic_directed(&self.graph) {
234 bail!("Circular dependency detected in model pipeline DAG!");
235 }
236
237 for &idx in self.node_map.values() {
242 let node = &self.graph[idx];
243 if node.layer != ModelLayer::Transform {
244 continue;
245 }
246 let mut refs_stg = false;
247 let mut refs_tf = false;
248 for dep in &node.dependencies {
249 if let DependencyRef::Model(dep_name) = dep {
250 if let Some(&dep_idx) = self.node_map.get(dep_name) {
251 match self.graph[dep_idx].layer {
252 ModelLayer::Staging => refs_stg = true,
253 ModelLayer::Transform => refs_tf = true,
254 ModelLayer::Mart => {}
255 }
256 }
257 }
258 }
259 if refs_stg && refs_tf {
260 bail!(
261 "E_RBT_LAYER_TRANSFORM_BAND: transform '{}' refs both stg_* and tf_*. \
262 Gold transforms may only ref silver stage endpoints (stg_*). \
263 Silver prep transforms may only ref bronze sources or other silver tf_* \
264 (then land stg_*). Never stg_* → silver/tf_*.",
265 node.name
266 );
267 }
268 }
269
270 Ok(())
271 }
272
273 pub fn topological_sequence(&self) -> Result<Vec<ModelNode>> {
275 let indices = toposort(&self.graph, None)
276 .map_err(|_| anyhow::anyhow!("Circular dependency found during topological sort"))?;
277
278 Ok(indices.into_iter().map(|i| self.graph[i].clone()).collect())
279 }
280
281 pub fn execution_tiers(&self) -> Result<Vec<Vec<ModelNode>>> {
283 let _sorted = self.topological_sequence()?;
284 let mut in_degrees: HashMap<NodeIndex, usize> = HashMap::new();
285
286 for idx in self.graph.node_indices() {
287 in_degrees.insert(
288 idx,
289 self.graph
290 .neighbors_directed(idx, petgraph::Direction::Incoming)
291 .count(),
292 );
293 }
294
295 let mut current_tier: Vec<NodeIndex> = in_degrees
296 .iter()
297 .filter(|(_, °)| deg == 0)
298 .map(|(&idx, _)| idx)
299 .collect();
300
301 let mut tiers = Vec::new();
302 let mut visited = HashSet::new();
303
304 while !current_tier.is_empty() {
305 let tier_nodes: Vec<ModelNode> = current_tier
306 .iter()
307 .map(|&i| self.graph[i].clone())
308 .collect();
309 tiers.push(tier_nodes);
310
311 for &node_idx in ¤t_tier {
312 visited.insert(node_idx);
313 }
314
315 let mut next_tier = Vec::new();
316 for idx in self.graph.node_indices() {
317 if visited.contains(&idx) {
318 continue;
319 }
320 let incoming_unvisited = self
321 .graph
322 .neighbors_directed(idx, petgraph::Direction::Incoming)
323 .filter(|n| !visited.contains(n))
324 .count();
325
326 if incoming_unvisited == 0 {
327 next_tier.push(idx);
328 }
329 }
330
331 current_tier = next_tier;
332 }
333
334 Ok(tiers)
335 }
336
337 pub fn primary_source(node: &ModelNode) -> Option<(&str, &str)> {
339 node.dependencies.iter().find_map(|d| match d {
340 DependencyRef::Source {
341 source_name,
342 table_name,
343 } => Some((source_name.as_str(), table_name.as_str())),
344 _ => None,
345 })
346 }
347
348 pub fn bronze_source_ident(node: &ModelNode) -> Option<(String, String)> {
350 if let Some(fm) = &node.frontmatter {
351 if let (Some(s), Some(t)) = (&fm.source_name, &fm.source_table) {
352 return Some((s.clone(), t.clone()));
353 }
354 if let Some((s, t)) = Self::primary_source(node) {
355 let source = fm.source_name.clone().unwrap_or_else(|| s.to_string());
356 let table = fm.source_table.clone().unwrap_or_else(|| t.to_string());
357 return Some((source, table));
358 }
359 if fm.has_scan_contract() {
360 let table = fm.source_table.clone().unwrap_or_else(|| node.name.clone());
362 let source = fm
363 .source_name
364 .clone()
365 .unwrap_or_else(|| "bronze".to_string());
366 return Some((source, table));
367 }
368 }
369 Self::primary_source(node).map(|(s, t)| (s.to_string(), t.to_string()))
370 }
371
372 pub fn validate_bronze_sources(
378 &self,
379 project_dir: &Path,
380 mode: BronzeCheckMode,
381 ) -> Result<BronzeValidationReport> {
382 self.validate_bronze_sources_with_roots(
383 project_dir,
384 mode,
385 &std::collections::HashMap::new(),
386 )
387 }
388
389 pub fn validate_bronze_sources_with_roots(
391 &self,
392 project_dir: &Path,
393 mode: BronzeCheckMode,
394 roots: &std::collections::HashMap<String, String>,
395 ) -> Result<BronzeValidationReport> {
396 if mode == BronzeCheckMode::Off {
397 return Ok(BronzeValidationReport::default());
398 }
399
400 let severity = match mode {
401 BronzeCheckMode::Fail => DiagnosticSeverity::Error,
402 BronzeCheckMode::Warn => DiagnosticSeverity::Warning,
403 BronzeCheckMode::Off => unreachable!(),
404 };
405
406 let mut report = BronzeValidationReport::default();
407
408 for idx in self.graph.node_indices() {
409 let node = &self.graph[idx];
410 let has_source_dep = node
411 .dependencies
412 .iter()
413 .any(|d| matches!(d, DependencyRef::Source { .. }));
414 let fm = node.frontmatter.as_ref();
415
416 if has_source_dep && node.layer == ModelLayer::Staging {
418 let missing_scan = fm.map(|f| !f.has_scan_contract()).unwrap_or(true);
419 if missing_scan {
420 report.diagnostics.push(BronzeDiagnostic {
421 model: node.name.clone(),
422 severity,
423 code: "E_RBT_BRONZE_SCAN_PATH_MISSING",
424 message:
425 "staging model references source() but has no frontmatter scan_path; \
426 add YAML frontmatter with scan_path (and source_format)"
427 .to_string(),
428 });
429 continue;
430 }
431 }
432
433 let Some(fm) = fm else {
434 continue;
435 };
436
437 if !fm.has_scan_contract() {
438 continue;
439 }
440
441 let scan_path = fm.scan_path.as_deref().unwrap();
442
443 if let Err(e) = fm.resolve_format() {
445 report.diagnostics.push(BronzeDiagnostic {
446 model: node.name.clone(),
447 severity,
448 code: "E_RBT_BRONZE_FORMAT_UNKNOWN",
449 message: e.to_string(),
450 });
451 }
452
453 if !super::frontmatter::scan_path_exists_with_roots(project_dir, scan_path, roots) {
454 if fm.on_missing_policy() == super::run_scope::OnMissing::Empty {
456 if let Err(e) = fm.empty_frame_schema() {
457 report.diagnostics.push(BronzeDiagnostic {
458 model: node.name.clone(),
459 severity,
460 code: "E_RBT_EMPTY_SCHEMA",
461 message: format!(
462 "on_missing: empty but schema invalid while scan_path missing: {e}"
463 ),
464 });
465 }
466 } else {
467 let resolved = super::paths::resolve_project_path(project_dir, scan_path, roots)
468 .unwrap_or_else(|_| project_dir.join(scan_path));
469 report.diagnostics.push(BronzeDiagnostic {
470 model: node.name.clone(),
471 severity,
472 code: "E_RBT_BRONZE_SCAN_PATH_NOT_FOUND",
473 message: format!(
474 "scan_path '{}' does not exist (resolved: {}). \
475 Hint: set on_missing: empty for optional artifact families.",
476 scan_path,
477 resolved.display()
478 ),
479 });
480 }
481 }
482
483 if let Some(globs) = fm.path_glob.as_ref() {
485 if let Err(e) = super::paths::validate_glob_patterns(globs) {
486 report.diagnostics.push(BronzeDiagnostic {
487 model: node.name.clone(),
488 severity,
489 code: "E_RBT_PATH_GLOB_INVALID",
490 message: e.to_string(),
491 });
492 }
493 }
494
495 if let (Some((dep_s, dep_t)), Some(ident)) =
497 (Self::primary_source(node), Self::bronze_source_ident(node))
498 {
499 if let Some(fm_s) = &fm.source_name {
500 if fm_s != dep_s {
501 report.diagnostics.push(BronzeDiagnostic {
502 model: node.name.clone(),
503 severity: DiagnosticSeverity::Warning,
504 code: "W_RBT_BRONZE_SOURCE_NAME_MISMATCH",
505 message: format!(
506 "frontmatter source_name='{}' differs from source('{}', ...); \
507 registration will use '{}'.{} ",
508 fm_s, dep_s, ident.0, ident.1
509 ),
510 });
511 }
512 }
513 if let Some(fm_t) = &fm.source_table {
514 if fm_t != dep_t {
515 report.diagnostics.push(BronzeDiagnostic {
516 model: node.name.clone(),
517 severity: DiagnosticSeverity::Warning,
518 code: "W_RBT_BRONZE_SOURCE_TABLE_MISMATCH",
519 message: format!(
520 "frontmatter source_table='{}' differs from source(..., '{}')",
521 fm_t, dep_t
522 ),
523 });
524 }
525 }
526 }
527 }
528
529 self.append_modeling_hygiene_diagnostics(&mut report);
531 Ok(report)
532 }
533
534 pub fn modeling_hygiene_diagnostics(&self) -> Vec<BronzeDiagnostic> {
536 let mut report = BronzeValidationReport::default();
537 self.append_modeling_hygiene_diagnostics(&mut report);
538 report.diagnostics
539 }
540
541 fn append_modeling_hygiene_diagnostics(&self, report: &mut BronzeValidationReport) {
542 for idx in self.graph.node_indices() {
543 let node = &self.graph[idx];
544 let fm = node.frontmatter.as_ref();
545
546 for dep in &node.dependencies {
548 if let DependencyRef::Source {
549 source_name: _,
550 table_name,
551 } = dep
552 {
553 let t = table_name.as_str();
554 if t.starts_with("tf_") || t.starts_with("int_") {
555 report.diagnostics.push(BronzeDiagnostic {
556 model: node.name.clone(),
557 severity: DiagnosticSeverity::Warning,
558 code: "W_RBT_SOURCE_UPSTREAM_TRANSFORM",
559 message: format!(
560 "source(..., '{table_name}') looks like a transform endpoint; \
561 prefer published stage (stg_*) or dim/fact contracts, not private tf_*"
562 ),
563 });
564 }
565 }
566 }
567
568 if matches!(node.layer, ModelLayer::Mart) {
571 if let Some(fm) = fm {
572 if fm.wants_parts_source() || fm.has_scan_contract() {
573 report.diagnostics.push(BronzeDiagnostic {
574 model: node.name.clone(),
575 severity: DiagnosticSeverity::Warning,
576 code: "W_RBT_MART_SCAN_CONTRACT",
577 message: "mart (dim_/fact_/obt_) declares a bronze scan/parts contract; \
578 prefer scan on stg_*, business prep on tf_*, thin marts via ref()"
579 .into(),
580 });
581 }
582 }
583 }
584
585 let Some(fm) = fm else {
586 continue;
587 };
588
589 if let Some(grain) = fm.grain.as_ref().filter(|g| !g.is_empty()) {
591 let unique_cols: Option<Vec<String>> = fm
592 .tests
593 .as_ref()
594 .and_then(|t| t.unique.clone())
595 .or_else(|| fm.unique_key.clone());
596 match unique_cols {
597 None => {
598 report.diagnostics.push(BronzeDiagnostic {
599 model: node.name.clone(),
600 severity: DiagnosticSeverity::Warning,
601 code: "W_RBT_GRAIN_NO_UNIQUE",
602 message: format!(
603 "grain {:?} declared but no tests.unique / unique_key; \
604 grain should be testable for uniqueness",
605 grain
606 ),
607 });
608 }
609 Some(u) if u != *grain && u.len() != 1 => {
610 if !(u.len() == 1
612 && (u[0].ends_with("_sk") || u[0].ends_with("_key") || u[0] == "sk"))
613 {
614 report.diagnostics.push(BronzeDiagnostic {
615 model: node.name.clone(),
616 severity: DiagnosticSeverity::Warning,
617 code: "W_RBT_GRAIN_UNIQUE_MISMATCH",
618 message: format!(
619 "grain {:?} differs from unique {:?}; ensure SK vs NK is intentional",
620 grain, u
621 ),
622 });
623 }
624 }
625 _ => {}
626 }
627 }
628
629 if node.name.starts_with("fact_") || node.name.starts_with("fct_") {
631 let has_rel = fm
632 .tests
633 .as_ref()
634 .and_then(|t| t.relationships.as_ref())
635 .map(|r| !r.is_empty())
636 .unwrap_or(false);
637 if !has_rel {
638 report.diagnostics.push(BronzeDiagnostic {
639 model: node.name.clone(),
640 severity: DiagnosticSeverity::Warning,
641 code: "W_RBT_FACT_NO_RELATIONSHIP",
642 message: "fact model has no tests.relationships; \
643 prefer SK FK checks to dim_* (Unknown member -1)"
644 .into(),
645 });
646 }
647 }
648
649 if let Some(grain) = &fm.grain {
653 if grain.iter().any(|c| c.starts_with("_rbt_")) {
654 report.diagnostics.push(BronzeDiagnostic {
655 model: node.name.clone(),
656 severity: DiagnosticSeverity::Warning,
657 code: "W_RBT_LINEAGE_IN_GRAIN",
658 message: "grain includes _rbt_* lineage columns; keep lineage out of business grain"
659 .into(),
660 });
661 }
662 }
663 }
664 }
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670
671 #[test]
672 fn test_dag_building_and_tiering() -> Result<()> {
673 let mut dag = ModelDag::new();
674
675 dag.add_model_with_format(
677 "stg_users",
678 "SELECT * FROM {{ source('raw', 'users') }}",
679 Materialization::View,
680 OutputFormat::Jsonl,
681 None,
682 "db",
683 )?;
684 dag.add_model_with_format(
685 "stg_orders",
686 "SELECT * FROM {{ source('raw', 'orders') }}",
687 Materialization::View,
688 OutputFormat::ParquetAndIceberg,
689 None,
690 "db",
691 )?;
692
693 dag.add_model(
695 "int_user_orders",
696 "SELECT * FROM {{ ref('stg_users') }} u JOIN {{ ref('stg_orders') }} o ON u.id = o.user_id",
697 Materialization::Table,
698 "db",
699 )?;
700
701 dag.add_model(
703 "fct_revenue",
704 "SELECT user_id, SUM(amount) FROM {{ ref('int_user_orders') }} GROUP BY user_id",
705 Materialization::IncrementalAppend,
706 "db",
707 )?;
708
709 dag.build_graph()?;
710
711 let tiers = dag.execution_tiers()?;
712 assert_eq!(tiers.len(), 3);
713
714 assert_eq!(tiers[0].len(), 2);
716 let tier0_formats: Vec<OutputFormat> =
717 tiers[0].iter().map(|m| m.output_format.clone()).collect();
718 assert!(tier0_formats.contains(&OutputFormat::Jsonl));
719 assert!(tier0_formats.contains(&OutputFormat::ParquetAndIceberg));
720
721 Ok(())
722 }
723
724 #[test]
725 fn test_cycle_detection() -> Result<()> {
726 let mut dag = ModelDag::new();
727
728 dag.add_model(
729 "model_a",
730 "SELECT * FROM {{ ref('model_b') }}",
731 Materialization::View,
732 "db",
733 )?;
734 dag.add_model(
735 "model_b",
736 "SELECT * FROM {{ ref('model_a') }}",
737 Materialization::View,
738 "db",
739 )?;
740
741 let res = dag.build_graph();
742 assert!(res.is_err());
743 assert!(res.unwrap_err().to_string().contains("Circular dependency"));
744
745 Ok(())
746 }
747
748 #[test]
749 fn modeling_hygiene_flags_source_tf_and_grain() -> Result<()> {
750 let mut dag = ModelDag::new();
751 dag.add_model_with_format(
752 "stg_ok",
753 "---\ngrain: [id]\n---\nSELECT 1 AS id FROM {{ source('raw', 'x') }}",
754 Materialization::Table,
755 OutputFormat::Parquet,
756 None,
757 "db",
758 )?;
759 dag.add_model_with_format(
760 "stg_bad_source",
761 "SELECT * FROM {{ source('upstream', 'tf_secret') }}",
762 Materialization::Table,
763 OutputFormat::Parquet,
764 None,
765 "db",
766 )?;
767 dag.add_model_with_format(
768 "fact_sales",
769 "---\ngrain: [id]\ntests:\n unique: [id]\n---\nSELECT 1 AS id",
770 Materialization::Table,
771 OutputFormat::Parquet,
772 None,
773 "db",
774 )?;
775 dag.build_graph()?;
776 let diags = dag.modeling_hygiene_diagnostics();
777 assert!(
778 diags.iter().any(|d| d.code == "W_RBT_GRAIN_NO_UNIQUE"),
779 "expected grain warning, got {:?}",
780 diags
781 );
782 assert!(
783 diags
784 .iter()
785 .any(|d| d.code == "W_RBT_SOURCE_UPSTREAM_TRANSFORM"),
786 "expected source(tf_*) warning, got {:?}",
787 diags
788 );
789 assert!(
790 diags.iter().any(|d| d.code == "W_RBT_FACT_NO_RELATIONSHIP"),
791 "expected fact relationship warning, got {:?}",
792 diags
793 );
794 Ok(())
795 }
796
797 #[test]
798 fn test_layer_boundary_enforcement() -> Result<()> {
799 let mut dag = ModelDag::new();
800
801 dag.add_model(
803 "fact_orders",
804 "SELECT 1 AS order_id",
805 Materialization::Table,
806 "db",
807 )?;
808
809 dag.add_model(
811 "tf_illegal_transform",
812 "SELECT * FROM {{ ref('fact_orders') }}",
813 Materialization::Table,
814 "db",
815 )?;
816
817 let res = dag.build_graph();
818 assert!(res.is_err());
819 assert!(res
820 .unwrap_err()
821 .to_string()
822 .contains("Illegal DAG Layer Boundary"));
823
824 Ok(())
825 }
826
827 #[test]
828 fn transform_cannot_mix_stg_and_tf_deps() -> Result<()> {
829 let mut dag = ModelDag::new();
830 dag.add_model(
831 "stg_a",
832 "SELECT 1 AS id FROM {{ source('b', 'x') }}",
833 Materialization::Table,
834 "db",
835 )?;
836 dag.add_model(
837 "tf_prep",
838 "SELECT 1 AS id FROM {{ source('b', 'y') }}",
839 Materialization::Table,
840 "db",
841 )?;
842 dag.add_model(
843 "tf_mixed",
844 "SELECT * FROM {{ ref('stg_a') }} a JOIN {{ ref('tf_prep') }} t ON 1=1",
845 Materialization::Table,
846 "db",
847 )?;
848 let err = dag.build_graph().unwrap_err().to_string();
849 assert!(
850 err.contains("E_RBT_LAYER_TRANSFORM_BAND"),
851 "got {err}"
852 );
853 Ok(())
854 }
855
856 #[test]
857 fn staging_may_ref_silver_prep_transform() -> Result<()> {
858 let mut dag = ModelDag::new();
859 dag.add_model(
860 "tf_base_events",
861 "SELECT 1 AS id FROM {{ source('bronze', 'raw') }}",
862 Materialization::Table,
863 "db",
864 )?;
865 dag.add_model(
866 "stg_events",
867 "SELECT * FROM {{ ref('tf_base_events') }}",
868 Materialization::Table,
869 "db",
870 )?;
871 dag.build_graph()?;
872 Ok(())
873 }
874
875 #[test]
876 fn gold_transform_may_ref_only_stg() -> Result<()> {
877 let mut dag = ModelDag::new();
878 dag.add_model(
879 "stg_a",
880 "SELECT 1 AS id FROM {{ source('b', 'x') }}",
881 Materialization::Table,
882 "db",
883 )?;
884 dag.add_model(
885 "tf_gold_prep",
886 "SELECT * FROM {{ ref('stg_a') }}",
887 Materialization::Table,
888 "db",
889 )?;
890 dag.add_model(
891 "fact_a",
892 "SELECT * FROM {{ ref('tf_gold_prep') }}",
893 Materialization::Table,
894 "db",
895 )?;
896 dag.build_graph()?;
897 Ok(())
898 }
899
900 #[test]
901 fn test_bronze_scan_path_validation_warn_and_fail() -> Result<()> {
902 let mut dag = ModelDag::new();
903 dag.add_model_with_format(
904 "stg_events",
905 r#"---
906source_format: jsonl
907scan_path: "lake/bronze/missing.jsonl"
908---
909SELECT * FROM {{ source('bronze', 'events') }}
910"#,
911 Materialization::Table,
912 OutputFormat::Parquet,
913 None,
914 "",
915 )?;
916 dag.build_graph()?;
917
918 let project = Path::new("/tmp/rbt_nonexistent_project_root");
919 let warn = dag.validate_bronze_sources(project, BronzeCheckMode::Warn)?;
920 assert_eq!(warn.error_count(), 0);
921 assert!(warn.warning_count() >= 1);
922 assert!(warn
923 .diagnostics
924 .iter()
925 .any(|d| d.code == "E_RBT_BRONZE_SCAN_PATH_NOT_FOUND"));
926
927 let fail = dag.validate_bronze_sources(project, BronzeCheckMode::Fail)?;
928 assert!(fail.has_errors());
929 Ok(())
930 }
931}