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                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            // Validate path_glob patterns early (syntax only).
420            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            // source() identity vs frontmatter overrides — soft check
432            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        // Tier 0: Staging models (no dependencies)
478        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        // Tier 1: Intermediate model depending on stg_users and stg_orders
496        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        // Tier 2: Mart model depending on int_user_orders
504        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        // Tier 0 has 2 parallel models with formats Jsonl and ParquetAndIceberg
517        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        // Mart model
555        dag.add_model(
556            "fact_orders",
557            "SELECT 1 AS order_id",
558            Materialization::Table,
559            "db",
560        )?;
561
562        // Illegal transform model attempting to pull from a mart model!
563        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}