Skip to main content

rbt_core/
dag.rs

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/// 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/// Configurable model output format supported by the engine.
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26pub enum OutputFormat {
27    /// Output directly as Parquet file(s)
28    Parquet,
29    /// Output as JSONL (jshift zero-copy compatible)
30    Jsonl,
31    /// Output as CSV
32    Csv,
33    /// Output directly into Apache Iceberg catalog table
34    Iceberg,
35    /// Dual-write output: local Parquet files AND Apache Iceberg catalog registration
36    ParquetAndIceberg,
37    /// Native zero-copy metadata pointer clone
38    ZeroCopyClone,
39}
40
41impl Default for OutputFormat {
42    fn default() -> Self {
43        Self::Parquet
44    }
45}
46
47
48/// DAG Layer classification following project architecture conventions.
49#[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/// Pipeline Model Node definition.
71#[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/// Complete pipeline DAG with topological execution tiering and cycle detection.
86#[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    /// Registers a raw model SQL definition into the DAG with a default Parquet format.
98    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    /// Registers a raw model SQL definition with explicit `OutputFormat` and optional output path.
116    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    /// Resolves dependency edges between models and validates that there are no circular dependencies or layer boundary violations.
154    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                        // Enforce Layer Boundary Rule: Transform models cannot depend on Mart models
165                        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                        // Enforce Layer Boundary Rule: Staging models cannot depend on downstream models
174                        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    /// Computes a flat topologically sorted execution sequence.
206    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    /// Computes parallel execution tiers where all models in a tier can be run concurrently.
214    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)| 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 &current_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    /// First `source('ns','table')` dependency, if any.
268    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    /// Resolve registration identity for a bronze-backed model.
279    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                // Fall back to model name under schema "bronze"
291                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    /// Compile-time bronze frontmatter / scan_path validation.
306    ///
307    /// * `Off` — no filesystem checks
308    /// * `Warn` — missing paths become warnings
309    /// * `Fail` — missing paths become errors (`report.has_errors()`)
310    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            // Staging models with source() should declare a scan contract.
336            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            // Format resolvable?
363            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            // source() identity vs frontmatter overrides — soft check
387            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        // Tier 0: Staging models (no dependencies)
433        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        // Tier 1: Intermediate model depending on stg_users and stg_orders
451        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        // Tier 2: Mart model depending on int_user_orders
459        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        // Tier 0 has 2 parallel models with formats Jsonl and ParquetAndIceberg
472        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        // Mart model
509        dag.add_model(
510            "fact_orders",
511            "SELECT 1 AS order_id",
512            Materialization::Table,
513            "db",
514        )?;
515
516        // Illegal transform model attempting to pull from a mart model!
517        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}