Skip to main content

rbt/core/
dag.rs

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/// Model materialization strategy in Apache Iceberg & data lakes.
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15pub enum Materialization {
16    View,
17    Table,
18    IncrementalAppend,
19    IncrementalMerge,
20    /// Native zero-copy metadata table clone (zero disk byte duplication)
21    ZeroCopyClone,
22}
23
24/// Parse frontmatter `materialization:` string into [`Materialization`].
25pub 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/// Configurable model output format supported by the engine.
40#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
41pub enum OutputFormat {
42    /// Output directly as Parquet file(s)
43    #[default]
44    Parquet,
45    /// Output as JSONL (jshift zero-copy compatible)
46    Jsonl,
47    /// Output as CSV
48    Csv,
49    /// Output directly into Apache Iceberg catalog table
50    Iceberg,
51    /// Dual-write output: local Parquet files AND Apache Iceberg catalog registration
52    ParquetAndIceberg,
53    /// Native zero-copy metadata pointer clone
54    ZeroCopyClone,
55}
56
57/// DAG Layer classification following project architecture conventions.
58#[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/// Pipeline Model Node definition.
84#[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/// Complete pipeline DAG with topological execution tiering and cycle detection.
99#[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    /// Registers a raw model SQL definition into the DAG with a default Parquet format.
111    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    /// Registers a raw model SQL definition with explicit `OutputFormat` and optional output path.
129    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        // Frontmatter materialization overrides the positional default when set.
146        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    /// Resolves dependency edges between models and validates that there are no circular dependencies or layer boundary violations.
172    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                        // Enforce Layer Boundary Rule: Transform models cannot depend on Mart models
183                        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                        // Enforce Layer Boundary Rule: Staging models cannot depend on downstream models
193                        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    /// Computes a flat topologically sorted execution sequence.
225    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    /// Computes parallel execution tiers where all models in a tier can be run concurrently.
233    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)| 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 &current_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    /// First `source('ns','table')` dependency, if any.
289    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    /// Resolve registration identity for a bronze-backed model.
300    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                // Fall back to model name under schema "bronze"
312                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    /// Compile-time bronze frontmatter / scan_path validation.
324    ///
325    /// * `Off` — no filesystem checks
326    /// * `Warn` — missing paths become warnings
327    /// * `Fail` — missing paths become errors (`report.has_errors()`)
328    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    /// Compile-time bronze checks with project `roots:` for `$name` path templates.
341    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            // Staging models with source() should declare a scan contract.
368            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            // Format resolvable?
395            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                // Optional artifact families may be absent for a partition (P5a).
406                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            // Validate path_glob patterns early (syntax only).
435            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            // source() identity vs frontmatter overrides — soft check
447            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        Ok(report)
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    #[test]
489    fn test_dag_building_and_tiering() -> Result<()> {
490        let mut dag = ModelDag::new();
491
492        // Tier 0: Staging models (no dependencies)
493        dag.add_model_with_format(
494            "stg_users",
495            "SELECT * FROM {{ source('raw', 'users') }}",
496            Materialization::View,
497            OutputFormat::Jsonl,
498            None,
499            "db",
500        )?;
501        dag.add_model_with_format(
502            "stg_orders",
503            "SELECT * FROM {{ source('raw', 'orders') }}",
504            Materialization::View,
505            OutputFormat::ParquetAndIceberg,
506            None,
507            "db",
508        )?;
509
510        // Tier 1: Intermediate model depending on stg_users and stg_orders
511        dag.add_model(
512            "int_user_orders",
513            "SELECT * FROM {{ ref('stg_users') }} u JOIN {{ ref('stg_orders') }} o ON u.id = o.user_id",
514            Materialization::Table,
515            "db",
516        )?;
517
518        // Tier 2: Mart model depending on int_user_orders
519        dag.add_model(
520            "fct_revenue",
521            "SELECT user_id, SUM(amount) FROM {{ ref('int_user_orders') }} GROUP BY user_id",
522            Materialization::IncrementalAppend,
523            "db",
524        )?;
525
526        dag.build_graph()?;
527
528        let tiers = dag.execution_tiers()?;
529        assert_eq!(tiers.len(), 3);
530
531        // Tier 0 has 2 parallel models with formats Jsonl and ParquetAndIceberg
532        assert_eq!(tiers[0].len(), 2);
533        let tier0_formats: Vec<OutputFormat> =
534            tiers[0].iter().map(|m| m.output_format.clone()).collect();
535        assert!(tier0_formats.contains(&OutputFormat::Jsonl));
536        assert!(tier0_formats.contains(&OutputFormat::ParquetAndIceberg));
537
538        Ok(())
539    }
540
541    #[test]
542    fn test_cycle_detection() -> Result<()> {
543        let mut dag = ModelDag::new();
544
545        dag.add_model(
546            "model_a",
547            "SELECT * FROM {{ ref('model_b') }}",
548            Materialization::View,
549            "db",
550        )?;
551        dag.add_model(
552            "model_b",
553            "SELECT * FROM {{ ref('model_a') }}",
554            Materialization::View,
555            "db",
556        )?;
557
558        let res = dag.build_graph();
559        assert!(res.is_err());
560        assert!(res.unwrap_err().to_string().contains("Circular dependency"));
561
562        Ok(())
563    }
564
565    #[test]
566    fn test_layer_boundary_enforcement() -> Result<()> {
567        let mut dag = ModelDag::new();
568
569        // Mart model
570        dag.add_model(
571            "fact_orders",
572            "SELECT 1 AS order_id",
573            Materialization::Table,
574            "db",
575        )?;
576
577        // Illegal transform model attempting to pull from a mart model!
578        dag.add_model(
579            "tf_illegal_transform",
580            "SELECT * FROM {{ ref('fact_orders') }}",
581            Materialization::Table,
582            "db",
583        )?;
584
585        let res = dag.build_graph();
586        assert!(res.is_err());
587        assert!(res
588            .unwrap_err()
589            .to_string()
590            .contains("Illegal DAG Layer Boundary"));
591
592        Ok(())
593    }
594
595    #[test]
596    fn test_bronze_scan_path_validation_warn_and_fail() -> Result<()> {
597        let mut dag = ModelDag::new();
598        dag.add_model_with_format(
599            "stg_events",
600            r#"---
601source_format: jsonl
602scan_path: "lake/bronze/missing.jsonl"
603---
604SELECT * FROM {{ source('bronze', 'events') }}
605"#,
606            Materialization::Table,
607            OutputFormat::Parquet,
608            None,
609            "",
610        )?;
611        dag.build_graph()?;
612
613        let project = Path::new("/tmp/rbt_nonexistent_project_root");
614        let warn = dag.validate_bronze_sources(project, BronzeCheckMode::Warn)?;
615        assert_eq!(warn.error_count(), 0);
616        assert!(warn.warning_count() >= 1);
617        assert!(warn
618            .diagnostics
619            .iter()
620            .any(|d| d.code == "E_RBT_BRONZE_SCAN_PATH_NOT_FOUND"));
621
622        let fail = dag.validate_bronze_sources(project, BronzeCheckMode::Fail)?;
623        assert!(fail.has_errors());
624        Ok(())
625    }
626}