1use crate::frontmatter::{
2 scan_path_exists, BronzeCheckMode, BronzeDiagnostic, BronzeValidationReport,
3 DiagnosticSeverity, StagingFrontmatter,
4};
5use crate::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
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26pub enum OutputFormat {
27 Parquet,
29 Jsonl,
31 Csv,
33 Iceberg,
35 ParquetAndIceberg,
37 ZeroCopyClone,
39}
40
41impl Default for OutputFormat {
42 fn default() -> Self {
43 Self::Parquet
44 }
45}
46
47
48#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
50pub enum ModelLayer {
51 Staging,
52 Transform,
53 Mart,
54}
55
56impl ModelLayer {
57 pub fn from_name(name: &str) -> Self {
58 if name.starts_with("stg_") {
59 Self::Staging
60 } else if name.starts_with("tf_") || name.starts_with("int_") {
61 Self::Transform
62 } else if name.starts_with("dim_") || name.starts_with("fact_") || name.starts_with("obt_") || name.starts_with("fct_") {
63 Self::Mart
64 } else {
65 Self::Transform
66 }
67 }
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct ModelNode {
73 pub name: String,
74 pub description: Option<String>,
75 pub raw_sql: String,
76 pub compiled_sql: String,
77 pub materialization: Materialization,
78 pub output_format: OutputFormat,
79 pub output_path: Option<String>,
80 pub dependencies: Vec<DependencyRef>,
81 pub layer: ModelLayer,
82 pub frontmatter: Option<StagingFrontmatter>,
83}
84
85#[derive(Debug, Default)]
87pub struct ModelDag {
88 pub graph: DiGraph<ModelNode, ()>,
89 pub node_map: HashMap<String, NodeIndex>,
90}
91
92impl ModelDag {
93 pub fn new() -> Self {
94 Self::default()
95 }
96
97 pub fn add_model(
99 &mut self,
100 name: impl Into<String>,
101 raw_sql: &str,
102 materialization: Materialization,
103 catalog_prefix: &str,
104 ) -> Result<NodeIndex> {
105 self.add_model_with_format(
106 name,
107 raw_sql,
108 materialization,
109 OutputFormat::Parquet,
110 None,
111 catalog_prefix,
112 )
113 }
114
115 pub fn add_model_with_format(
117 &mut self,
118 name: impl Into<String>,
119 raw_sql: &str,
120 materialization: Materialization,
121 output_format: OutputFormat,
122 output_path: Option<String>,
123 catalog_prefix: &str,
124 ) -> Result<NodeIndex> {
125 let name = name.into();
126 let (frontmatter, pure_sql) = SqlModelParser::parse_frontmatter(raw_sql)
127 .map_err(|e| anyhow::anyhow!("model '{}': {}", name, e))?;
128 let dependencies = SqlModelParser::extract_dependencies(&pure_sql)?;
129 let compiled_sql = SqlModelParser::compile_sql(&pure_sql, catalog_prefix)?;
130 let layer = ModelLayer::from_name(&name);
131 let description = frontmatter
132 .as_ref()
133 .and_then(|f| f.description.clone());
134
135 let node = ModelNode {
136 name: name.clone(),
137 description,
138 raw_sql: raw_sql.to_string(),
139 compiled_sql,
140 materialization,
141 output_format,
142 output_path,
143 dependencies,
144 layer,
145 frontmatter,
146 };
147
148 let idx = self.graph.add_node(node);
149 self.node_map.insert(name, idx);
150 Ok(idx)
151 }
152
153 pub fn build_graph(&mut self) -> Result<()> {
155 let name_map = self.node_map.clone();
156 let mut edges = Vec::new();
157
158 for (idx, node) in self.node_map.values().map(|&i| (i, &self.graph[i])) {
159 for dep in &node.dependencies {
160 if let DependencyRef::Model(dep_name) = dep {
161 if let Some(&dep_idx) = name_map.get(dep_name) {
162 let dep_node = &self.graph[dep_idx];
163
164 if node.layer == ModelLayer::Transform && dep_node.layer == ModelLayer::Mart {
166 bail!(
167 "Illegal DAG Layer Boundary: Transform model '{}' (tf_) cannot depend on Mart model '{}' (dim_/fact_/obt_)",
168 node.name,
169 dep_node.name
170 );
171 }
172
173 if node.layer == ModelLayer::Staging {
175 bail!(
176 "Illegal DAG Layer Boundary: Staging model '{}' (stg_) cannot depend on downstream model '{}'",
177 node.name,
178 dep_node.name
179 );
180 }
181
182 edges.push((dep_idx, idx));
183 } else {
184 bail!(
185 "Missing model dependency '{}' referenced by model '{}'",
186 dep_name,
187 node.name
188 );
189 }
190 }
191 }
192 }
193
194 for (from, to) in edges {
195 self.graph.add_edge(from, to, ());
196 }
197
198 if is_cyclic_directed(&self.graph) {
199 bail!("Circular dependency detected in model pipeline DAG!");
200 }
201
202 Ok(())
203 }
204
205 pub fn topological_sequence(&self) -> Result<Vec<ModelNode>> {
207 let indices = toposort(&self.graph, None)
208 .map_err(|_| anyhow::anyhow!("Circular dependency found during topological sort"))?;
209
210 Ok(indices.into_iter().map(|i| self.graph[i].clone()).collect())
211 }
212
213 pub fn execution_tiers(&self) -> Result<Vec<Vec<ModelNode>>> {
215 let _sorted = self.topological_sequence()?;
216 let mut in_degrees: HashMap<NodeIndex, usize> = HashMap::new();
217
218 for idx in self.graph.node_indices() {
219 in_degrees.insert(
220 idx,
221 self.graph
222 .neighbors_directed(idx, petgraph::Direction::Incoming)
223 .count(),
224 );
225 }
226
227 let mut current_tier: Vec<NodeIndex> = in_degrees
228 .iter()
229 .filter(|(_, °)| deg == 0)
230 .map(|(&idx, _)| idx)
231 .collect();
232
233 let mut tiers = Vec::new();
234 let mut visited = HashSet::new();
235
236 while !current_tier.is_empty() {
237 let tier_nodes: Vec<ModelNode> =
238 current_tier.iter().map(|&i| self.graph[i].clone()).collect();
239 tiers.push(tier_nodes);
240
241 for &node_idx in ¤t_tier {
242 visited.insert(node_idx);
243 }
244
245 let mut next_tier = Vec::new();
246 for idx in self.graph.node_indices() {
247 if visited.contains(&idx) {
248 continue;
249 }
250 let incoming_unvisited = self
251 .graph
252 .neighbors_directed(idx, petgraph::Direction::Incoming)
253 .filter(|n| !visited.contains(n))
254 .count();
255
256 if incoming_unvisited == 0 {
257 next_tier.push(idx);
258 }
259 }
260
261 current_tier = next_tier;
262 }
263
264 Ok(tiers)
265 }
266
267 pub fn primary_source(node: &ModelNode) -> Option<(&str, &str)> {
269 node.dependencies.iter().find_map(|d| match d {
270 DependencyRef::Source {
271 source_name,
272 table_name,
273 } => Some((source_name.as_str(), table_name.as_str())),
274 _ => None,
275 })
276 }
277
278 pub fn bronze_source_ident(node: &ModelNode) -> Option<(String, String)> {
280 if let Some(fm) = &node.frontmatter {
281 if let (Some(s), Some(t)) = (&fm.source_name, &fm.source_table) {
282 return Some((s.clone(), t.clone()));
283 }
284 if let Some((s, t)) = Self::primary_source(node) {
285 let source = fm.source_name.clone().unwrap_or_else(|| s.to_string());
286 let table = fm.source_table.clone().unwrap_or_else(|| t.to_string());
287 return Some((source, table));
288 }
289 if fm.has_scan_contract() {
290 let table = fm
292 .source_table
293 .clone()
294 .unwrap_or_else(|| node.name.clone());
295 let source = fm
296 .source_name
297 .clone()
298 .unwrap_or_else(|| "bronze".to_string());
299 return Some((source, table));
300 }
301 }
302 Self::primary_source(node).map(|(s, t)| (s.to_string(), t.to_string()))
303 }
304
305 pub fn validate_bronze_sources(
311 &self,
312 project_dir: &Path,
313 mode: BronzeCheckMode,
314 ) -> Result<BronzeValidationReport> {
315 if mode == BronzeCheckMode::Off {
316 return Ok(BronzeValidationReport::default());
317 }
318
319 let severity = match mode {
320 BronzeCheckMode::Fail => DiagnosticSeverity::Error,
321 BronzeCheckMode::Warn => DiagnosticSeverity::Warning,
322 BronzeCheckMode::Off => unreachable!(),
323 };
324
325 let mut report = BronzeValidationReport::default();
326
327 for idx in self.graph.node_indices() {
328 let node = &self.graph[idx];
329 let has_source_dep = node
330 .dependencies
331 .iter()
332 .any(|d| matches!(d, DependencyRef::Source { .. }));
333 let fm = node.frontmatter.as_ref();
334
335 if has_source_dep && node.layer == ModelLayer::Staging {
337 let missing_scan = fm.map(|f| !f.has_scan_contract()).unwrap_or(true);
338 if missing_scan {
339 report.diagnostics.push(BronzeDiagnostic {
340 model: node.name.clone(),
341 severity,
342 code: "E_RBT_BRONZE_SCAN_PATH_MISSING",
343 message: format!(
344 "staging model references source() but has no frontmatter scan_path; \
345 add YAML frontmatter with scan_path (and source_format)"
346 ),
347 });
348 continue;
349 }
350 }
351
352 let Some(fm) = fm else {
353 continue;
354 };
355
356 if !fm.has_scan_contract() {
357 continue;
358 }
359
360 let scan_path = fm.scan_path.as_deref().unwrap();
361
362 if let Err(e) = fm.resolve_format() {
364 report.diagnostics.push(BronzeDiagnostic {
365 model: node.name.clone(),
366 severity,
367 code: "E_RBT_BRONZE_FORMAT_UNKNOWN",
368 message: e.to_string(),
369 });
370 }
371
372 if !scan_path_exists(project_dir, scan_path) {
373 let resolved = crate::frontmatter::resolve_scan_path(project_dir, scan_path);
374 report.diagnostics.push(BronzeDiagnostic {
375 model: node.name.clone(),
376 severity,
377 code: "E_RBT_BRONZE_SCAN_PATH_NOT_FOUND",
378 message: format!(
379 "scan_path '{}' does not exist (resolved: {})",
380 scan_path,
381 resolved.display()
382 ),
383 });
384 }
385
386 if let (Some((dep_s, dep_t)), Some(ident)) =
388 (Self::primary_source(node), Self::bronze_source_ident(node))
389 {
390 if let Some(fm_s) = &fm.source_name {
391 if fm_s != dep_s {
392 report.diagnostics.push(BronzeDiagnostic {
393 model: node.name.clone(),
394 severity: DiagnosticSeverity::Warning,
395 code: "W_RBT_BRONZE_SOURCE_NAME_MISMATCH",
396 message: format!(
397 "frontmatter source_name='{}' differs from source('{}', ...); \
398 registration will use '{}'.{} ",
399 fm_s, dep_s, ident.0, ident.1
400 ),
401 });
402 }
403 }
404 if let Some(fm_t) = &fm.source_table {
405 if fm_t != dep_t {
406 report.diagnostics.push(BronzeDiagnostic {
407 model: node.name.clone(),
408 severity: DiagnosticSeverity::Warning,
409 code: "W_RBT_BRONZE_SOURCE_TABLE_MISMATCH",
410 message: format!(
411 "frontmatter source_table='{}' differs from source(..., '{}')",
412 fm_t, dep_t
413 ),
414 });
415 }
416 }
417 }
418 }
419
420 Ok(report)
421 }
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 #[test]
429 fn test_dag_building_and_tiering() -> Result<()> {
430 let mut dag = ModelDag::new();
431
432 dag.add_model_with_format(
434 "stg_users",
435 "SELECT * FROM {{ source('raw', 'users') }}",
436 Materialization::View,
437 OutputFormat::Jsonl,
438 None,
439 "db",
440 )?;
441 dag.add_model_with_format(
442 "stg_orders",
443 "SELECT * FROM {{ source('raw', 'orders') }}",
444 Materialization::View,
445 OutputFormat::ParquetAndIceberg,
446 None,
447 "db",
448 )?;
449
450 dag.add_model(
452 "int_user_orders",
453 "SELECT * FROM {{ ref('stg_users') }} u JOIN {{ ref('stg_orders') }} o ON u.id = o.user_id",
454 Materialization::Table,
455 "db",
456 )?;
457
458 dag.add_model(
460 "fct_revenue",
461 "SELECT user_id, SUM(amount) FROM {{ ref('int_user_orders') }} GROUP BY user_id",
462 Materialization::IncrementalAppend,
463 "db",
464 )?;
465
466 dag.build_graph()?;
467
468 let tiers = dag.execution_tiers()?;
469 assert_eq!(tiers.len(), 3);
470
471 assert_eq!(tiers[0].len(), 2);
473 let tier0_formats: Vec<OutputFormat> = tiers[0].iter().map(|m| m.output_format.clone()).collect();
474 assert!(tier0_formats.contains(&OutputFormat::Jsonl));
475 assert!(tier0_formats.contains(&OutputFormat::ParquetAndIceberg));
476
477 Ok(())
478 }
479
480 #[test]
481 fn test_cycle_detection() -> Result<()> {
482 let mut dag = ModelDag::new();
483
484 dag.add_model(
485 "model_a",
486 "SELECT * FROM {{ ref('model_b') }}",
487 Materialization::View,
488 "db",
489 )?;
490 dag.add_model(
491 "model_b",
492 "SELECT * FROM {{ ref('model_a') }}",
493 Materialization::View,
494 "db",
495 )?;
496
497 let res = dag.build_graph();
498 assert!(res.is_err());
499 assert!(res.unwrap_err().to_string().contains("Circular dependency"));
500
501 Ok(())
502 }
503
504 #[test]
505 fn test_layer_boundary_enforcement() -> Result<()> {
506 let mut dag = ModelDag::new();
507
508 dag.add_model(
510 "fact_orders",
511 "SELECT 1 AS order_id",
512 Materialization::Table,
513 "db",
514 )?;
515
516 dag.add_model(
518 "tf_illegal_transform",
519 "SELECT * FROM {{ ref('fact_orders') }}",
520 Materialization::Table,
521 "db",
522 )?;
523
524 let res = dag.build_graph();
525 assert!(res.is_err());
526 assert!(res.unwrap_err().to_string().contains("Illegal DAG Layer Boundary"));
527
528 Ok(())
529 }
530
531 #[test]
532 fn test_bronze_scan_path_validation_warn_and_fail() -> Result<()> {
533 let mut dag = ModelDag::new();
534 dag.add_model_with_format(
535 "stg_events",
536 r#"---
537source_format: jsonl
538scan_path: "lake/bronze/missing.jsonl"
539---
540SELECT * FROM {{ source('bronze', 'events') }}
541"#,
542 Materialization::Table,
543 OutputFormat::Parquet,
544 None,
545 "",
546 )?;
547 dag.build_graph()?;
548
549 let project = Path::new("/tmp/rbt_nonexistent_project_root");
550 let warn = dag.validate_bronze_sources(project, BronzeCheckMode::Warn)?;
551 assert_eq!(warn.error_count(), 0);
552 assert!(warn.warning_count() >= 1);
553 assert!(warn
554 .diagnostics
555 .iter()
556 .any(|d| d.code == "E_RBT_BRONZE_SCAN_PATH_NOT_FOUND"));
557
558 let fail = dag.validate_bronze_sources(project, BronzeCheckMode::Fail)?;
559 assert!(fail.has_errors());
560 Ok(())
561 }
562}