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 let resolved = super::paths::resolve_project_path(project_dir, scan_path, roots)
406 .unwrap_or_else(|_| project_dir.join(scan_path));
407 report.diagnostics.push(BronzeDiagnostic {
408 model: node.name.clone(),
409 severity,
410 code: "E_RBT_BRONZE_SCAN_PATH_NOT_FOUND",
411 message: format!(
412 "scan_path '{}' does not exist (resolved: {})",
413 scan_path,
414 resolved.display()
415 ),
416 });
417 }
418
419 if let Some(globs) = fm.path_glob.as_ref() {
421 if let Err(e) = super::paths::validate_glob_patterns(globs) {
422 report.diagnostics.push(BronzeDiagnostic {
423 model: node.name.clone(),
424 severity,
425 code: "E_RBT_PATH_GLOB_INVALID",
426 message: e.to_string(),
427 });
428 }
429 }
430
431 if let (Some((dep_s, dep_t)), Some(ident)) =
433 (Self::primary_source(node), Self::bronze_source_ident(node))
434 {
435 if let Some(fm_s) = &fm.source_name {
436 if fm_s != dep_s {
437 report.diagnostics.push(BronzeDiagnostic {
438 model: node.name.clone(),
439 severity: DiagnosticSeverity::Warning,
440 code: "W_RBT_BRONZE_SOURCE_NAME_MISMATCH",
441 message: format!(
442 "frontmatter source_name='{}' differs from source('{}', ...); \
443 registration will use '{}'.{} ",
444 fm_s, dep_s, ident.0, ident.1
445 ),
446 });
447 }
448 }
449 if let Some(fm_t) = &fm.source_table {
450 if fm_t != dep_t {
451 report.diagnostics.push(BronzeDiagnostic {
452 model: node.name.clone(),
453 severity: DiagnosticSeverity::Warning,
454 code: "W_RBT_BRONZE_SOURCE_TABLE_MISMATCH",
455 message: format!(
456 "frontmatter source_table='{}' differs from source(..., '{}')",
457 fm_t, dep_t
458 ),
459 });
460 }
461 }
462 }
463 }
464
465 Ok(report)
466 }
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472
473 #[test]
474 fn test_dag_building_and_tiering() -> Result<()> {
475 let mut dag = ModelDag::new();
476
477 dag.add_model_with_format(
479 "stg_users",
480 "SELECT * FROM {{ source('raw', 'users') }}",
481 Materialization::View,
482 OutputFormat::Jsonl,
483 None,
484 "db",
485 )?;
486 dag.add_model_with_format(
487 "stg_orders",
488 "SELECT * FROM {{ source('raw', 'orders') }}",
489 Materialization::View,
490 OutputFormat::ParquetAndIceberg,
491 None,
492 "db",
493 )?;
494
495 dag.add_model(
497 "int_user_orders",
498 "SELECT * FROM {{ ref('stg_users') }} u JOIN {{ ref('stg_orders') }} o ON u.id = o.user_id",
499 Materialization::Table,
500 "db",
501 )?;
502
503 dag.add_model(
505 "fct_revenue",
506 "SELECT user_id, SUM(amount) FROM {{ ref('int_user_orders') }} GROUP BY user_id",
507 Materialization::IncrementalAppend,
508 "db",
509 )?;
510
511 dag.build_graph()?;
512
513 let tiers = dag.execution_tiers()?;
514 assert_eq!(tiers.len(), 3);
515
516 assert_eq!(tiers[0].len(), 2);
518 let tier0_formats: Vec<OutputFormat> =
519 tiers[0].iter().map(|m| m.output_format.clone()).collect();
520 assert!(tier0_formats.contains(&OutputFormat::Jsonl));
521 assert!(tier0_formats.contains(&OutputFormat::ParquetAndIceberg));
522
523 Ok(())
524 }
525
526 #[test]
527 fn test_cycle_detection() -> Result<()> {
528 let mut dag = ModelDag::new();
529
530 dag.add_model(
531 "model_a",
532 "SELECT * FROM {{ ref('model_b') }}",
533 Materialization::View,
534 "db",
535 )?;
536 dag.add_model(
537 "model_b",
538 "SELECT * FROM {{ ref('model_a') }}",
539 Materialization::View,
540 "db",
541 )?;
542
543 let res = dag.build_graph();
544 assert!(res.is_err());
545 assert!(res.unwrap_err().to_string().contains("Circular dependency"));
546
547 Ok(())
548 }
549
550 #[test]
551 fn test_layer_boundary_enforcement() -> Result<()> {
552 let mut dag = ModelDag::new();
553
554 dag.add_model(
556 "fact_orders",
557 "SELECT 1 AS order_id",
558 Materialization::Table,
559 "db",
560 )?;
561
562 dag.add_model(
564 "tf_illegal_transform",
565 "SELECT * FROM {{ ref('fact_orders') }}",
566 Materialization::Table,
567 "db",
568 )?;
569
570 let res = dag.build_graph();
571 assert!(res.is_err());
572 assert!(res
573 .unwrap_err()
574 .to_string()
575 .contains("Illegal DAG Layer Boundary"));
576
577 Ok(())
578 }
579
580 #[test]
581 fn test_bronze_scan_path_validation_warn_and_fail() -> Result<()> {
582 let mut dag = ModelDag::new();
583 dag.add_model_with_format(
584 "stg_events",
585 r#"---
586source_format: jsonl
587scan_path: "lake/bronze/missing.jsonl"
588---
589SELECT * FROM {{ source('bronze', 'events') }}
590"#,
591 Materialization::Table,
592 OutputFormat::Parquet,
593 None,
594 "",
595 )?;
596 dag.build_graph()?;
597
598 let project = Path::new("/tmp/rbt_nonexistent_project_root");
599 let warn = dag.validate_bronze_sources(project, BronzeCheckMode::Warn)?;
600 assert_eq!(warn.error_count(), 0);
601 assert!(warn.warning_count() >= 1);
602 assert!(warn
603 .diagnostics
604 .iter()
605 .any(|d| d.code == "E_RBT_BRONZE_SCAN_PATH_NOT_FOUND"));
606
607 let fail = dag.validate_bronze_sources(project, BronzeCheckMode::Fail)?;
608 assert!(fail.has_errors());
609 Ok(())
610 }
611}