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                        // Transform models cannot depend on Mart models (facts/dims are terminal for transforms)
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                        // Staging is the silver endpoint: may ref silver prep transforms (tf_ before stg),
193                        // but not marts or other staging models.
194                        if node.layer == ModelLayer::Staging {
195                            match dep_node.layer {
196                                ModelLayer::Mart => {
197                                    bail!(
198                                        "Illegal DAG Layer Boundary: Staging model '{}' (stg_) cannot depend on Mart model '{}'",
199                                        node.name,
200                                        dep_node.name
201                                    );
202                                }
203                                ModelLayer::Staging => {
204                                    bail!(
205                                        "Illegal DAG Layer Boundary: Staging model '{}' cannot depend on staging model '{}' \
206                                         (stg_* are silver endpoints; chain prep in tf_* then land stg_*)",
207                                        node.name,
208                                        dep_node.name
209                                    );
210                                }
211                                ModelLayer::Transform => {
212                                    // OK: bronze → tf_base_* → stg_* (optional silver prep before stage endpoint)
213                                }
214                            }
215                        }
216
217                        edges.push((dep_idx, idx));
218                    } else {
219                        bail!(
220                            "Missing model dependency '{}' referenced by model '{}'",
221                            dep_name,
222                            node.name
223                        );
224                    }
225                }
226            }
227        }
228
229        for (from, to) in edges {
230            self.graph.add_edge(from, to, ());
231        }
232
233        if is_cyclic_directed(&self.graph) {
234            bail!("Circular dependency detected in model pipeline DAG!");
235        }
236
237        // Medallion transform band: never mix silver-prep and gold-prep deps on one tf.
238        // - Gold transforms: only ref stg_* (silver endpoints)
239        // - Silver prep transforms: only ref other tf_* (or bronze via source()), never stg_*
240        // - Never: stg_* → silver/tf_* (stg is endpoint; gold tf refs stg instead)
241        for &idx in self.node_map.values() {
242            let node = &self.graph[idx];
243            if node.layer != ModelLayer::Transform {
244                continue;
245            }
246            let mut refs_stg = false;
247            let mut refs_tf = false;
248            for dep in &node.dependencies {
249                if let DependencyRef::Model(dep_name) = dep {
250                    if let Some(&dep_idx) = self.node_map.get(dep_name) {
251                        match self.graph[dep_idx].layer {
252                            ModelLayer::Staging => refs_stg = true,
253                            ModelLayer::Transform => refs_tf = true,
254                            ModelLayer::Mart => {}
255                        }
256                    }
257                }
258            }
259            if refs_stg && refs_tf {
260                bail!(
261                    "E_RBT_LAYER_TRANSFORM_BAND: transform '{}' refs both stg_* and tf_*. \
262                     Gold transforms may only ref silver stage endpoints (stg_*). \
263                     Silver prep transforms may only ref bronze sources or other silver tf_* \
264                     (then land stg_*). Never stg_* → silver/tf_*.",
265                    node.name
266                );
267            }
268        }
269
270        Ok(())
271    }
272
273    /// Computes a flat topologically sorted execution sequence.
274    pub fn topological_sequence(&self) -> Result<Vec<ModelNode>> {
275        let indices = toposort(&self.graph, None)
276            .map_err(|_| anyhow::anyhow!("Circular dependency found during topological sort"))?;
277
278        Ok(indices.into_iter().map(|i| self.graph[i].clone()).collect())
279    }
280
281    /// Computes parallel execution tiers where all models in a tier can be run concurrently.
282    pub fn execution_tiers(&self) -> Result<Vec<Vec<ModelNode>>> {
283        let _sorted = self.topological_sequence()?;
284        let mut in_degrees: HashMap<NodeIndex, usize> = HashMap::new();
285
286        for idx in self.graph.node_indices() {
287            in_degrees.insert(
288                idx,
289                self.graph
290                    .neighbors_directed(idx, petgraph::Direction::Incoming)
291                    .count(),
292            );
293        }
294
295        let mut current_tier: Vec<NodeIndex> = in_degrees
296            .iter()
297            .filter(|(_, &deg)| deg == 0)
298            .map(|(&idx, _)| idx)
299            .collect();
300
301        let mut tiers = Vec::new();
302        let mut visited = HashSet::new();
303
304        while !current_tier.is_empty() {
305            let tier_nodes: Vec<ModelNode> = current_tier
306                .iter()
307                .map(|&i| self.graph[i].clone())
308                .collect();
309            tiers.push(tier_nodes);
310
311            for &node_idx in &current_tier {
312                visited.insert(node_idx);
313            }
314
315            let mut next_tier = Vec::new();
316            for idx in self.graph.node_indices() {
317                if visited.contains(&idx) {
318                    continue;
319                }
320                let incoming_unvisited = self
321                    .graph
322                    .neighbors_directed(idx, petgraph::Direction::Incoming)
323                    .filter(|n| !visited.contains(n))
324                    .count();
325
326                if incoming_unvisited == 0 {
327                    next_tier.push(idx);
328                }
329            }
330
331            current_tier = next_tier;
332        }
333
334        Ok(tiers)
335    }
336
337    /// First `source('ns','table')` dependency, if any.
338    pub fn primary_source(node: &ModelNode) -> Option<(&str, &str)> {
339        node.dependencies.iter().find_map(|d| match d {
340            DependencyRef::Source {
341                source_name,
342                table_name,
343            } => Some((source_name.as_str(), table_name.as_str())),
344            _ => None,
345        })
346    }
347
348    /// Resolve registration identity for a bronze-backed model.
349    pub fn bronze_source_ident(node: &ModelNode) -> Option<(String, String)> {
350        if let Some(fm) = &node.frontmatter {
351            if let (Some(s), Some(t)) = (&fm.source_name, &fm.source_table) {
352                return Some((s.clone(), t.clone()));
353            }
354            if let Some((s, t)) = Self::primary_source(node) {
355                let source = fm.source_name.clone().unwrap_or_else(|| s.to_string());
356                let table = fm.source_table.clone().unwrap_or_else(|| t.to_string());
357                return Some((source, table));
358            }
359            if fm.has_scan_contract() {
360                // Fall back to model name under schema "bronze"
361                let table = fm.source_table.clone().unwrap_or_else(|| node.name.clone());
362                let source = fm
363                    .source_name
364                    .clone()
365                    .unwrap_or_else(|| "bronze".to_string());
366                return Some((source, table));
367            }
368        }
369        Self::primary_source(node).map(|(s, t)| (s.to_string(), t.to_string()))
370    }
371
372    /// Compile-time bronze frontmatter / scan_path validation.
373    ///
374    /// * `Off` — no filesystem checks
375    /// * `Warn` — missing paths become warnings
376    /// * `Fail` — missing paths become errors (`report.has_errors()`)
377    pub fn validate_bronze_sources(
378        &self,
379        project_dir: &Path,
380        mode: BronzeCheckMode,
381    ) -> Result<BronzeValidationReport> {
382        self.validate_bronze_sources_with_roots(
383            project_dir,
384            mode,
385            &std::collections::HashMap::new(),
386        )
387    }
388
389    /// Compile-time bronze checks with project `roots:` for `$name` path templates.
390    pub fn validate_bronze_sources_with_roots(
391        &self,
392        project_dir: &Path,
393        mode: BronzeCheckMode,
394        roots: &std::collections::HashMap<String, String>,
395    ) -> Result<BronzeValidationReport> {
396        if mode == BronzeCheckMode::Off {
397            return Ok(BronzeValidationReport::default());
398        }
399
400        let severity = match mode {
401            BronzeCheckMode::Fail => DiagnosticSeverity::Error,
402            BronzeCheckMode::Warn => DiagnosticSeverity::Warning,
403            BronzeCheckMode::Off => unreachable!(),
404        };
405
406        let mut report = BronzeValidationReport::default();
407
408        for idx in self.graph.node_indices() {
409            let node = &self.graph[idx];
410            let has_source_dep = node
411                .dependencies
412                .iter()
413                .any(|d| matches!(d, DependencyRef::Source { .. }));
414            let fm = node.frontmatter.as_ref();
415
416            // Staging models with source() should declare a scan contract.
417            if has_source_dep && node.layer == ModelLayer::Staging {
418                let missing_scan = fm.map(|f| !f.has_scan_contract()).unwrap_or(true);
419                if missing_scan {
420                    report.diagnostics.push(BronzeDiagnostic {
421                        model: node.name.clone(),
422                        severity,
423                        code: "E_RBT_BRONZE_SCAN_PATH_MISSING",
424                        message:
425                            "staging model references source() but has no frontmatter scan_path; \
426                             add YAML frontmatter with scan_path (and source_format)"
427                                .to_string(),
428                    });
429                    continue;
430                }
431            }
432
433            let Some(fm) = fm else {
434                continue;
435            };
436
437            if !fm.has_scan_contract() {
438                continue;
439            }
440
441            let scan_path = fm.scan_path.as_deref().unwrap();
442
443            // Format resolvable?
444            if let Err(e) = fm.resolve_format() {
445                report.diagnostics.push(BronzeDiagnostic {
446                    model: node.name.clone(),
447                    severity,
448                    code: "E_RBT_BRONZE_FORMAT_UNKNOWN",
449                    message: e.to_string(),
450                });
451            }
452
453            if !super::frontmatter::scan_path_exists_with_roots(project_dir, scan_path, roots) {
454                // Optional artifact families may be absent for a partition (P5a).
455                if fm.on_missing_policy() == super::run_scope::OnMissing::Empty {
456                    if let Err(e) = fm.empty_frame_schema() {
457                        report.diagnostics.push(BronzeDiagnostic {
458                            model: node.name.clone(),
459                            severity,
460                            code: "E_RBT_EMPTY_SCHEMA",
461                            message: format!(
462                                "on_missing: empty but schema invalid while scan_path missing: {e}"
463                            ),
464                        });
465                    }
466                } else {
467                    let resolved = super::paths::resolve_project_path(project_dir, scan_path, roots)
468                        .unwrap_or_else(|_| project_dir.join(scan_path));
469                    report.diagnostics.push(BronzeDiagnostic {
470                        model: node.name.clone(),
471                        severity,
472                        code: "E_RBT_BRONZE_SCAN_PATH_NOT_FOUND",
473                        message: format!(
474                            "scan_path '{}' does not exist (resolved: {}). \
475                             Hint: set on_missing: empty for optional artifact families.",
476                            scan_path,
477                            resolved.display()
478                        ),
479                    });
480                }
481            }
482
483            // Validate path_glob patterns early (syntax only).
484            if let Some(globs) = fm.path_glob.as_ref() {
485                if let Err(e) = super::paths::validate_glob_patterns(globs) {
486                    report.diagnostics.push(BronzeDiagnostic {
487                        model: node.name.clone(),
488                        severity,
489                        code: "E_RBT_PATH_GLOB_INVALID",
490                        message: e.to_string(),
491                    });
492                }
493            }
494
495            // source() identity vs frontmatter overrides — soft check
496            if let (Some((dep_s, dep_t)), Some(ident)) =
497                (Self::primary_source(node), Self::bronze_source_ident(node))
498            {
499                if let Some(fm_s) = &fm.source_name {
500                    if fm_s != dep_s {
501                        report.diagnostics.push(BronzeDiagnostic {
502                            model: node.name.clone(),
503                            severity: DiagnosticSeverity::Warning,
504                            code: "W_RBT_BRONZE_SOURCE_NAME_MISMATCH",
505                            message: format!(
506                                "frontmatter source_name='{}' differs from source('{}', ...); \
507                                 registration will use '{}'.{} ",
508                                fm_s, dep_s, ident.0, ident.1
509                            ),
510                        });
511                    }
512                }
513                if let Some(fm_t) = &fm.source_table {
514                    if fm_t != dep_t {
515                        report.diagnostics.push(BronzeDiagnostic {
516                            model: node.name.clone(),
517                            severity: DiagnosticSeverity::Warning,
518                            code: "W_RBT_BRONZE_SOURCE_TABLE_MISMATCH",
519                            message: format!(
520                                "frontmatter source_table='{}' differs from source(..., '{}')",
521                                fm_t, dep_t
522                            ),
523                        });
524                    }
525                }
526            }
527        }
528
529        // Kimball / gold hygiene (warnings; does not fail bronze_check alone).
530        self.append_modeling_hygiene_diagnostics(&mut report);
531        Ok(report)
532    }
533
534    /// Star-schema and layer hygiene warnings (grain/unique, parts on marts, source(tf_*)).
535    pub fn modeling_hygiene_diagnostics(&self) -> Vec<BronzeDiagnostic> {
536        let mut report = BronzeValidationReport::default();
537        self.append_modeling_hygiene_diagnostics(&mut report);
538        report.diagnostics
539    }
540
541    fn append_modeling_hygiene_diagnostics(&self, report: &mut BronzeValidationReport) {
542        for idx in self.graph.node_indices() {
543            let node = &self.graph[idx];
544            let fm = node.frontmatter.as_ref();
545
546            // source() must not name an upstream transform-like table (private, unstable).
547            for dep in &node.dependencies {
548                if let DependencyRef::Source {
549                    source_name: _,
550                    table_name,
551                } = dep
552                {
553                    let t = table_name.as_str();
554                    if t.starts_with("tf_") || t.starts_with("int_") {
555                        report.diagnostics.push(BronzeDiagnostic {
556                            model: node.name.clone(),
557                            severity: DiagnosticSeverity::Warning,
558                            code: "W_RBT_SOURCE_UPSTREAM_TRANSFORM",
559                            message: format!(
560                                "source(..., '{table_name}') looks like a transform endpoint; \
561                                 prefer published stage (stg_*) or dim/fact contracts, not private tf_*"
562                            ),
563                        });
564                    }
565                }
566            }
567
568            // Gold transforms (tf under mart naming is rare): if transform refs another transform,
569            // that is fine for silver/tf chains. Flag marts that scan parts without ref-only.
570            if matches!(node.layer, ModelLayer::Mart) {
571                if let Some(fm) = fm {
572                    if fm.wants_parts_source() || fm.has_scan_contract() {
573                        report.diagnostics.push(BronzeDiagnostic {
574                            model: node.name.clone(),
575                            severity: DiagnosticSeverity::Warning,
576                            code: "W_RBT_MART_SCAN_CONTRACT",
577                            message: "mart (dim_/fact_/obt_) declares a bronze scan/parts contract; \
578                                 prefer scan on stg_*, business prep on tf_*, thin marts via ref()"
579                                .into(),
580                        });
581                    }
582                }
583            }
584
585            let Some(fm) = fm else {
586                continue;
587            };
588
589            // Grain without unique coverage
590            if let Some(grain) = fm.grain.as_ref().filter(|g| !g.is_empty()) {
591                let unique_cols: Option<Vec<String>> = fm
592                    .tests
593                    .as_ref()
594                    .and_then(|t| t.unique.clone())
595                    .or_else(|| fm.unique_key.clone());
596                match unique_cols {
597                    None => {
598                        report.diagnostics.push(BronzeDiagnostic {
599                            model: node.name.clone(),
600                            severity: DiagnosticSeverity::Warning,
601                            code: "W_RBT_GRAIN_NO_UNIQUE",
602                            message: format!(
603                                "grain {:?} declared but no tests.unique / unique_key; \
604                                 grain should be testable for uniqueness",
605                                grain
606                            ),
607                        });
608                    }
609                    Some(u) if u != *grain && u.len() != 1 => {
610                        // Allow unique_key = [sk] while grain is natural keys
611                        if !(u.len() == 1
612                            && (u[0].ends_with("_sk") || u[0].ends_with("_key") || u[0] == "sk"))
613                        {
614                            report.diagnostics.push(BronzeDiagnostic {
615                                model: node.name.clone(),
616                                severity: DiagnosticSeverity::Warning,
617                                code: "W_RBT_GRAIN_UNIQUE_MISMATCH",
618                                message: format!(
619                                    "grain {:?} differs from unique {:?}; ensure SK vs NK is intentional",
620                                    grain, u
621                                ),
622                            });
623                        }
624                    }
625                    _ => {}
626                }
627            }
628
629            // Fact-like models: recommend relationship tests when none
630            if node.name.starts_with("fact_") || node.name.starts_with("fct_") {
631                let has_rel = fm
632                    .tests
633                    .as_ref()
634                    .and_then(|t| t.relationships.as_ref())
635                    .map(|r| !r.is_empty())
636                    .unwrap_or(false);
637                if !has_rel {
638                    report.diagnostics.push(BronzeDiagnostic {
639                        model: node.name.clone(),
640                        severity: DiagnosticSeverity::Warning,
641                        code: "W_RBT_FACT_NO_RELATIONSHIP",
642                        message: "fact model has no tests.relationships; \
643                             prefer SK FK checks to dim_* (Unknown member -1)"
644                            .into(),
645                    });
646                }
647            }
648
649            // Dim: soft note if no unknown convention in description (optional noise) — skip
650
651            // Lineage columns in grain
652            if let Some(grain) = &fm.grain {
653                if grain.iter().any(|c| c.starts_with("_rbt_")) {
654                    report.diagnostics.push(BronzeDiagnostic {
655                        model: node.name.clone(),
656                        severity: DiagnosticSeverity::Warning,
657                        code: "W_RBT_LINEAGE_IN_GRAIN",
658                        message: "grain includes _rbt_* lineage columns; keep lineage out of business grain"
659                            .into(),
660                    });
661                }
662            }
663        }
664    }
665}
666
667#[cfg(test)]
668mod tests {
669    use super::*;
670
671    #[test]
672    fn test_dag_building_and_tiering() -> Result<()> {
673        let mut dag = ModelDag::new();
674
675        // Tier 0: Staging models (no dependencies)
676        dag.add_model_with_format(
677            "stg_users",
678            "SELECT * FROM {{ source('raw', 'users') }}",
679            Materialization::View,
680            OutputFormat::Jsonl,
681            None,
682            "db",
683        )?;
684        dag.add_model_with_format(
685            "stg_orders",
686            "SELECT * FROM {{ source('raw', 'orders') }}",
687            Materialization::View,
688            OutputFormat::ParquetAndIceberg,
689            None,
690            "db",
691        )?;
692
693        // Tier 1: Intermediate model depending on stg_users and stg_orders
694        dag.add_model(
695            "int_user_orders",
696            "SELECT * FROM {{ ref('stg_users') }} u JOIN {{ ref('stg_orders') }} o ON u.id = o.user_id",
697            Materialization::Table,
698            "db",
699        )?;
700
701        // Tier 2: Mart model depending on int_user_orders
702        dag.add_model(
703            "fct_revenue",
704            "SELECT user_id, SUM(amount) FROM {{ ref('int_user_orders') }} GROUP BY user_id",
705            Materialization::IncrementalAppend,
706            "db",
707        )?;
708
709        dag.build_graph()?;
710
711        let tiers = dag.execution_tiers()?;
712        assert_eq!(tiers.len(), 3);
713
714        // Tier 0 has 2 parallel models with formats Jsonl and ParquetAndIceberg
715        assert_eq!(tiers[0].len(), 2);
716        let tier0_formats: Vec<OutputFormat> =
717            tiers[0].iter().map(|m| m.output_format.clone()).collect();
718        assert!(tier0_formats.contains(&OutputFormat::Jsonl));
719        assert!(tier0_formats.contains(&OutputFormat::ParquetAndIceberg));
720
721        Ok(())
722    }
723
724    #[test]
725    fn test_cycle_detection() -> Result<()> {
726        let mut dag = ModelDag::new();
727
728        dag.add_model(
729            "model_a",
730            "SELECT * FROM {{ ref('model_b') }}",
731            Materialization::View,
732            "db",
733        )?;
734        dag.add_model(
735            "model_b",
736            "SELECT * FROM {{ ref('model_a') }}",
737            Materialization::View,
738            "db",
739        )?;
740
741        let res = dag.build_graph();
742        assert!(res.is_err());
743        assert!(res.unwrap_err().to_string().contains("Circular dependency"));
744
745        Ok(())
746    }
747
748    #[test]
749    fn modeling_hygiene_flags_source_tf_and_grain() -> Result<()> {
750        let mut dag = ModelDag::new();
751        dag.add_model_with_format(
752            "stg_ok",
753            "---\ngrain: [id]\n---\nSELECT 1 AS id FROM {{ source('raw', 'x') }}",
754            Materialization::Table,
755            OutputFormat::Parquet,
756            None,
757            "db",
758        )?;
759        dag.add_model_with_format(
760            "stg_bad_source",
761            "SELECT * FROM {{ source('upstream', 'tf_secret') }}",
762            Materialization::Table,
763            OutputFormat::Parquet,
764            None,
765            "db",
766        )?;
767        dag.add_model_with_format(
768            "fact_sales",
769            "---\ngrain: [id]\ntests:\n  unique: [id]\n---\nSELECT 1 AS id",
770            Materialization::Table,
771            OutputFormat::Parquet,
772            None,
773            "db",
774        )?;
775        dag.build_graph()?;
776        let diags = dag.modeling_hygiene_diagnostics();
777        assert!(
778            diags.iter().any(|d| d.code == "W_RBT_GRAIN_NO_UNIQUE"),
779            "expected grain warning, got {:?}",
780            diags
781        );
782        assert!(
783            diags
784                .iter()
785                .any(|d| d.code == "W_RBT_SOURCE_UPSTREAM_TRANSFORM"),
786            "expected source(tf_*) warning, got {:?}",
787            diags
788        );
789        assert!(
790            diags.iter().any(|d| d.code == "W_RBT_FACT_NO_RELATIONSHIP"),
791            "expected fact relationship warning, got {:?}",
792            diags
793        );
794        Ok(())
795    }
796
797    #[test]
798    fn test_layer_boundary_enforcement() -> Result<()> {
799        let mut dag = ModelDag::new();
800
801        // Mart model
802        dag.add_model(
803            "fact_orders",
804            "SELECT 1 AS order_id",
805            Materialization::Table,
806            "db",
807        )?;
808
809        // Illegal transform model attempting to pull from a mart model!
810        dag.add_model(
811            "tf_illegal_transform",
812            "SELECT * FROM {{ ref('fact_orders') }}",
813            Materialization::Table,
814            "db",
815        )?;
816
817        let res = dag.build_graph();
818        assert!(res.is_err());
819        assert!(res
820            .unwrap_err()
821            .to_string()
822            .contains("Illegal DAG Layer Boundary"));
823
824        Ok(())
825    }
826
827    #[test]
828    fn transform_cannot_mix_stg_and_tf_deps() -> Result<()> {
829        let mut dag = ModelDag::new();
830        dag.add_model(
831            "stg_a",
832            "SELECT 1 AS id FROM {{ source('b', 'x') }}",
833            Materialization::Table,
834            "db",
835        )?;
836        dag.add_model(
837            "tf_prep",
838            "SELECT 1 AS id FROM {{ source('b', 'y') }}",
839            Materialization::Table,
840            "db",
841        )?;
842        dag.add_model(
843            "tf_mixed",
844            "SELECT * FROM {{ ref('stg_a') }} a JOIN {{ ref('tf_prep') }} t ON 1=1",
845            Materialization::Table,
846            "db",
847        )?;
848        let err = dag.build_graph().unwrap_err().to_string();
849        assert!(
850            err.contains("E_RBT_LAYER_TRANSFORM_BAND"),
851            "got {err}"
852        );
853        Ok(())
854    }
855
856    #[test]
857    fn staging_may_ref_silver_prep_transform() -> Result<()> {
858        let mut dag = ModelDag::new();
859        dag.add_model(
860            "tf_base_events",
861            "SELECT 1 AS id FROM {{ source('bronze', 'raw') }}",
862            Materialization::Table,
863            "db",
864        )?;
865        dag.add_model(
866            "stg_events",
867            "SELECT * FROM {{ ref('tf_base_events') }}",
868            Materialization::Table,
869            "db",
870        )?;
871        dag.build_graph()?;
872        Ok(())
873    }
874
875    #[test]
876    fn gold_transform_may_ref_only_stg() -> Result<()> {
877        let mut dag = ModelDag::new();
878        dag.add_model(
879            "stg_a",
880            "SELECT 1 AS id FROM {{ source('b', 'x') }}",
881            Materialization::Table,
882            "db",
883        )?;
884        dag.add_model(
885            "tf_gold_prep",
886            "SELECT * FROM {{ ref('stg_a') }}",
887            Materialization::Table,
888            "db",
889        )?;
890        dag.add_model(
891            "fact_a",
892            "SELECT * FROM {{ ref('tf_gold_prep') }}",
893            Materialization::Table,
894            "db",
895        )?;
896        dag.build_graph()?;
897        Ok(())
898    }
899
900    #[test]
901    fn test_bronze_scan_path_validation_warn_and_fail() -> Result<()> {
902        let mut dag = ModelDag::new();
903        dag.add_model_with_format(
904            "stg_events",
905            r#"---
906source_format: jsonl
907scan_path: "lake/bronze/missing.jsonl"
908---
909SELECT * FROM {{ source('bronze', 'events') }}
910"#,
911            Materialization::Table,
912            OutputFormat::Parquet,
913            None,
914            "",
915        )?;
916        dag.build_graph()?;
917
918        let project = Path::new("/tmp/rbt_nonexistent_project_root");
919        let warn = dag.validate_bronze_sources(project, BronzeCheckMode::Warn)?;
920        assert_eq!(warn.error_count(), 0);
921        assert!(warn.warning_count() >= 1);
922        assert!(warn
923            .diagnostics
924            .iter()
925            .any(|d| d.code == "E_RBT_BRONZE_SCAN_PATH_NOT_FOUND"));
926
927        let fail = dag.validate_bronze_sources(project, BronzeCheckMode::Fail)?;
928        assert!(fail.has_errors());
929        Ok(())
930    }
931}