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        // Kimball / gold hygiene (warnings; does not fail bronze_check alone).
481        self.append_modeling_hygiene_diagnostics(&mut report);
482        Ok(report)
483    }
484
485    /// Star-schema and layer hygiene warnings (grain/unique, parts on marts, source(tf_*)).
486    pub fn modeling_hygiene_diagnostics(&self) -> Vec<BronzeDiagnostic> {
487        let mut report = BronzeValidationReport::default();
488        self.append_modeling_hygiene_diagnostics(&mut report);
489        report.diagnostics
490    }
491
492    fn append_modeling_hygiene_diagnostics(&self, report: &mut BronzeValidationReport) {
493        for idx in self.graph.node_indices() {
494            let node = &self.graph[idx];
495            let fm = node.frontmatter.as_ref();
496
497            // source() must not name an upstream transform-like table (private, unstable).
498            for dep in &node.dependencies {
499                if let DependencyRef::Source {
500                    source_name: _,
501                    table_name,
502                } = dep
503                {
504                    let t = table_name.as_str();
505                    if t.starts_with("tf_") || t.starts_with("int_") {
506                        report.diagnostics.push(BronzeDiagnostic {
507                            model: node.name.clone(),
508                            severity: DiagnosticSeverity::Warning,
509                            code: "W_RBT_SOURCE_UPSTREAM_TRANSFORM",
510                            message: format!(
511                                "source(..., '{table_name}') looks like a transform endpoint; \
512                                 prefer published stage (stg_*) or dim/fact contracts, not private tf_*"
513                            ),
514                        });
515                    }
516                }
517            }
518
519            // Gold transforms (tf under mart naming is rare): if transform refs another transform,
520            // that is fine for silver/tf chains. Flag marts that scan parts without ref-only.
521            if matches!(node.layer, ModelLayer::Mart) {
522                if let Some(fm) = fm {
523                    if fm.wants_parts_source() || fm.has_scan_contract() {
524                        report.diagnostics.push(BronzeDiagnostic {
525                            model: node.name.clone(),
526                            severity: DiagnosticSeverity::Warning,
527                            code: "W_RBT_MART_SCAN_CONTRACT",
528                            message: "mart (dim_/fact_/obt_) declares a bronze scan/parts contract; \
529                                 prefer scan on stg_*, business prep on tf_*, thin marts via ref()"
530                                .into(),
531                        });
532                    }
533                }
534            }
535
536            let Some(fm) = fm else {
537                continue;
538            };
539
540            // Grain without unique coverage
541            if let Some(grain) = fm.grain.as_ref().filter(|g| !g.is_empty()) {
542                let unique_cols: Option<Vec<String>> = fm
543                    .tests
544                    .as_ref()
545                    .and_then(|t| t.unique.clone())
546                    .or_else(|| fm.unique_key.clone());
547                match unique_cols {
548                    None => {
549                        report.diagnostics.push(BronzeDiagnostic {
550                            model: node.name.clone(),
551                            severity: DiagnosticSeverity::Warning,
552                            code: "W_RBT_GRAIN_NO_UNIQUE",
553                            message: format!(
554                                "grain {:?} declared but no tests.unique / unique_key; \
555                                 grain should be testable for uniqueness",
556                                grain
557                            ),
558                        });
559                    }
560                    Some(u) if u != *grain && u.len() != 1 => {
561                        // Allow unique_key = [sk] while grain is natural keys
562                        if !(u.len() == 1
563                            && (u[0].ends_with("_sk") || u[0].ends_with("_key") || u[0] == "sk"))
564                        {
565                            report.diagnostics.push(BronzeDiagnostic {
566                                model: node.name.clone(),
567                                severity: DiagnosticSeverity::Warning,
568                                code: "W_RBT_GRAIN_UNIQUE_MISMATCH",
569                                message: format!(
570                                    "grain {:?} differs from unique {:?}; ensure SK vs NK is intentional",
571                                    grain, u
572                                ),
573                            });
574                        }
575                    }
576                    _ => {}
577                }
578            }
579
580            // Fact-like models: recommend relationship tests when none
581            if node.name.starts_with("fact_") || node.name.starts_with("fct_") {
582                let has_rel = fm
583                    .tests
584                    .as_ref()
585                    .and_then(|t| t.relationships.as_ref())
586                    .map(|r| !r.is_empty())
587                    .unwrap_or(false);
588                if !has_rel {
589                    report.diagnostics.push(BronzeDiagnostic {
590                        model: node.name.clone(),
591                        severity: DiagnosticSeverity::Warning,
592                        code: "W_RBT_FACT_NO_RELATIONSHIP",
593                        message: "fact model has no tests.relationships; \
594                             prefer SK FK checks to dim_* (Unknown member -1)"
595                            .into(),
596                    });
597                }
598            }
599
600            // Dim: soft note if no unknown convention in description (optional noise) — skip
601
602            // Lineage columns in grain
603            if let Some(grain) = &fm.grain {
604                if grain.iter().any(|c| c.starts_with("_rbt_")) {
605                    report.diagnostics.push(BronzeDiagnostic {
606                        model: node.name.clone(),
607                        severity: DiagnosticSeverity::Warning,
608                        code: "W_RBT_LINEAGE_IN_GRAIN",
609                        message: "grain includes _rbt_* lineage columns; keep lineage out of business grain"
610                            .into(),
611                    });
612                }
613            }
614        }
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621
622    #[test]
623    fn test_dag_building_and_tiering() -> Result<()> {
624        let mut dag = ModelDag::new();
625
626        // Tier 0: Staging models (no dependencies)
627        dag.add_model_with_format(
628            "stg_users",
629            "SELECT * FROM {{ source('raw', 'users') }}",
630            Materialization::View,
631            OutputFormat::Jsonl,
632            None,
633            "db",
634        )?;
635        dag.add_model_with_format(
636            "stg_orders",
637            "SELECT * FROM {{ source('raw', 'orders') }}",
638            Materialization::View,
639            OutputFormat::ParquetAndIceberg,
640            None,
641            "db",
642        )?;
643
644        // Tier 1: Intermediate model depending on stg_users and stg_orders
645        dag.add_model(
646            "int_user_orders",
647            "SELECT * FROM {{ ref('stg_users') }} u JOIN {{ ref('stg_orders') }} o ON u.id = o.user_id",
648            Materialization::Table,
649            "db",
650        )?;
651
652        // Tier 2: Mart model depending on int_user_orders
653        dag.add_model(
654            "fct_revenue",
655            "SELECT user_id, SUM(amount) FROM {{ ref('int_user_orders') }} GROUP BY user_id",
656            Materialization::IncrementalAppend,
657            "db",
658        )?;
659
660        dag.build_graph()?;
661
662        let tiers = dag.execution_tiers()?;
663        assert_eq!(tiers.len(), 3);
664
665        // Tier 0 has 2 parallel models with formats Jsonl and ParquetAndIceberg
666        assert_eq!(tiers[0].len(), 2);
667        let tier0_formats: Vec<OutputFormat> =
668            tiers[0].iter().map(|m| m.output_format.clone()).collect();
669        assert!(tier0_formats.contains(&OutputFormat::Jsonl));
670        assert!(tier0_formats.contains(&OutputFormat::ParquetAndIceberg));
671
672        Ok(())
673    }
674
675    #[test]
676    fn test_cycle_detection() -> Result<()> {
677        let mut dag = ModelDag::new();
678
679        dag.add_model(
680            "model_a",
681            "SELECT * FROM {{ ref('model_b') }}",
682            Materialization::View,
683            "db",
684        )?;
685        dag.add_model(
686            "model_b",
687            "SELECT * FROM {{ ref('model_a') }}",
688            Materialization::View,
689            "db",
690        )?;
691
692        let res = dag.build_graph();
693        assert!(res.is_err());
694        assert!(res.unwrap_err().to_string().contains("Circular dependency"));
695
696        Ok(())
697    }
698
699    #[test]
700    fn modeling_hygiene_flags_source_tf_and_grain() -> Result<()> {
701        let mut dag = ModelDag::new();
702        dag.add_model_with_format(
703            "stg_ok",
704            "---\ngrain: [id]\n---\nSELECT 1 AS id FROM {{ source('raw', 'x') }}",
705            Materialization::Table,
706            OutputFormat::Parquet,
707            None,
708            "db",
709        )?;
710        dag.add_model_with_format(
711            "stg_bad_source",
712            "SELECT * FROM {{ source('upstream', 'tf_secret') }}",
713            Materialization::Table,
714            OutputFormat::Parquet,
715            None,
716            "db",
717        )?;
718        dag.add_model_with_format(
719            "fact_sales",
720            "---\ngrain: [id]\ntests:\n  unique: [id]\n---\nSELECT 1 AS id",
721            Materialization::Table,
722            OutputFormat::Parquet,
723            None,
724            "db",
725        )?;
726        dag.build_graph()?;
727        let diags = dag.modeling_hygiene_diagnostics();
728        assert!(
729            diags.iter().any(|d| d.code == "W_RBT_GRAIN_NO_UNIQUE"),
730            "expected grain warning, got {:?}",
731            diags
732        );
733        assert!(
734            diags
735                .iter()
736                .any(|d| d.code == "W_RBT_SOURCE_UPSTREAM_TRANSFORM"),
737            "expected source(tf_*) warning, got {:?}",
738            diags
739        );
740        assert!(
741            diags.iter().any(|d| d.code == "W_RBT_FACT_NO_RELATIONSHIP"),
742            "expected fact relationship warning, got {:?}",
743            diags
744        );
745        Ok(())
746    }
747
748    #[test]
749    fn test_layer_boundary_enforcement() -> Result<()> {
750        let mut dag = ModelDag::new();
751
752        // Mart model
753        dag.add_model(
754            "fact_orders",
755            "SELECT 1 AS order_id",
756            Materialization::Table,
757            "db",
758        )?;
759
760        // Illegal transform model attempting to pull from a mart model!
761        dag.add_model(
762            "tf_illegal_transform",
763            "SELECT * FROM {{ ref('fact_orders') }}",
764            Materialization::Table,
765            "db",
766        )?;
767
768        let res = dag.build_graph();
769        assert!(res.is_err());
770        assert!(res
771            .unwrap_err()
772            .to_string()
773            .contains("Illegal DAG Layer Boundary"));
774
775        Ok(())
776    }
777
778    #[test]
779    fn test_bronze_scan_path_validation_warn_and_fail() -> Result<()> {
780        let mut dag = ModelDag::new();
781        dag.add_model_with_format(
782            "stg_events",
783            r#"---
784source_format: jsonl
785scan_path: "lake/bronze/missing.jsonl"
786---
787SELECT * FROM {{ source('bronze', 'events') }}
788"#,
789            Materialization::Table,
790            OutputFormat::Parquet,
791            None,
792            "",
793        )?;
794        dag.build_graph()?;
795
796        let project = Path::new("/tmp/rbt_nonexistent_project_root");
797        let warn = dag.validate_bronze_sources(project, BronzeCheckMode::Warn)?;
798        assert_eq!(warn.error_count(), 0);
799        assert!(warn.warning_count() >= 1);
800        assert!(warn
801            .diagnostics
802            .iter()
803            .any(|d| d.code == "E_RBT_BRONZE_SCAN_PATH_NOT_FOUND"));
804
805        let fail = dag.validate_bronze_sources(project, BronzeCheckMode::Fail)?;
806        assert!(fail.has_errors());
807        Ok(())
808    }
809}