Skip to main content

rbt/engine/
mod.rs

1//! `rbt::engine`: Apache DataFusion query engine integration, bronze registration, and DAG execution.
2
3pub mod bronze;
4pub mod udf;
5
6use crate::core::dag::{Materialization, ModelDag, ModelNode, OutputFormat};
7use crate::core::project::{
8    MaterializeConfig, MaterializeMode, RbtProjectConfig, RefBackend,
9};
10use crate::core::receipt::{
11    bronze_fingerprint, effective_contract_version, now_unix_ms, ModelRunResult, RunReceipt,
12    RunStatus,
13};
14use crate::core::run_scope::RunScope;
15use crate::materializer::{
16    incremental_ref_path, load_parquet_batches, materialize_incremental_append_stream,
17    materialize_stream, new_wap_run_id, sibling_iceberg_dir, stamp_batch, wap_publish,
18    LineageStamp, MaterializeWriteOptions, MultiFormatWriter, StreamWriteStats, WapModelPaths,
19};
20use crate::engine::udf::register_builtin_udfs;
21use crate::testing::{assertions_from_model_tests, Assertion, RecordBatchValidator};
22use anyhow::{bail, Context, Result};
23use datafusion::datasource::MemTable;
24use datafusion::execution::context::SessionContext;
25use datafusion::physical_plan::SendableRecordBatchStream;
26use datafusion::prelude::{CsvReadOptions, JsonReadOptions, ParquetReadOptions};
27use iceberg::Catalog;
28use iceberg_datafusion::IcebergCatalogProvider;
29use std::collections::HashSet;
30use std::path::{Path, PathBuf};
31use std::sync::{Arc, Mutex};
32
33pub use bronze::{
34    register_bronze_for_model, register_bronze_for_model_scoped, register_bronze_sources_for_dag,
35    register_bronze_sources_for_dag_scoped, BronzeRegistrationMode, BronzeSourceMeta,
36    BronzeTableProvider,
37};
38
39/// Execution metric summary for a executed model DAG.
40#[derive(Debug, Clone)]
41pub struct DagExecutionSummary {
42    pub models_executed: usize,
43    pub total_rows_produced: usize,
44    pub bronze_sources_registered: usize,
45    /// True when fingerprint skip short-circuited materialize (P5b).
46    pub skipped: bool,
47    pub skip_reason: Option<String>,
48    pub bronze_fingerprint: Option<String>,
49    pub run_id: Option<String>,
50    pub receipt_path: Option<PathBuf>,
51    pub model_results: Vec<ModelRunResult>,
52}
53
54impl Default for DagExecutionSummary {
55    fn default() -> Self {
56        Self {
57            models_executed: 0,
58            total_rows_produced: 0,
59            bronze_sources_registered: 0,
60            skipped: false,
61            skip_reason: None,
62            bronze_fingerprint: None,
63            run_id: None,
64            receipt_path: None,
65            model_results: Vec::new(),
66        }
67    }
68}
69
70/// Result of `preview` — limited rows from one model without materializing it.
71#[derive(Debug, Clone)]
72pub struct PreviewResult {
73    pub model: String,
74    pub compiled_sql: String,
75    pub limit: usize,
76    pub rows: usize,
77    pub batches: Vec<arrow::record_batch::RecordBatch>,
78    pub ancestors_executed: usize,
79}
80
81/// Fluent Builder for configuring and launching `TransformationEngine` instances.
82#[derive(Default)]
83pub struct RbtEngineBuilder {
84    catalogs: Vec<(String, Arc<dyn Catalog>)>,
85}
86
87impl RbtEngineBuilder {
88    pub fn new() -> Self {
89        Self::default()
90    }
91
92    pub fn with_catalog(mut self, name: impl Into<String>, catalog: Arc<dyn Catalog>) -> Self {
93        self.catalogs.push((name.into(), catalog));
94        self
95    }
96
97    pub async fn build(self) -> Result<TransformationEngine> {
98        let engine = TransformationEngine::new();
99        for (name, cat) in self.catalogs {
100            engine.register_iceberg_catalog(&name, cat).await?;
101        }
102        Ok(engine)
103    }
104}
105
106pub struct TransformationEngine {
107    pub ctx: SessionContext,
108    /// Cached project config keyed by canonical project_dir (roots, materialize, scan limits).
109    ///
110    /// Avoids re-reading `rbt_project.yml` once per bronze model on large DAGs.
111    project_cache: Mutex<Option<(PathBuf, Arc<RbtProjectConfig>)>>,
112}
113
114impl Default for TransformationEngine {
115    fn default() -> Self {
116        Self::new()
117    }
118}
119
120impl TransformationEngine {
121    pub fn new() -> Self {
122        let ctx = SessionContext::new();
123        if let Err(e) = register_builtin_udfs(&ctx) {
124            tracing::warn!("E_RBT_UDF: failed to register builtins: {e}");
125        }
126        Self {
127            ctx,
128            project_cache: Mutex::new(None),
129        }
130    }
131
132    /// Load (or reuse cached) project config for `project_dir`.
133    pub fn load_project_config(&self, project_dir: &Path) -> Result<Arc<RbtProjectConfig>> {
134        let key = project_dir
135            .canonicalize()
136            .unwrap_or_else(|_| project_dir.to_path_buf());
137        let mut guard = self
138            .project_cache
139            .lock()
140            .map_err(|_| anyhow::anyhow!("E_RBT_ENGINE: project config cache lock poisoned"))?;
141        if let Some((ref cached_dir, ref cfg)) = *guard {
142            if *cached_dir == key {
143                return Ok(Arc::clone(cfg));
144            }
145        }
146        let cfg = Arc::new(RbtProjectConfig::load(project_dir).with_context(|| {
147            format!(
148                "E_RBT_PROJECT_LOAD: failed loading rbt_project.yml under {}",
149                project_dir.display()
150            )
151        })?);
152        *guard = Some((key, Arc::clone(&cfg)));
153        Ok(cfg)
154    }
155
156    /// Clear cached project config (tests / multi-project hosts).
157    pub fn clear_project_cache(&self) {
158        if let Ok(mut guard) = self.project_cache.lock() {
159            *guard = None;
160        }
161    }
162
163    /// Registers an Apache Iceberg catalog directly into the DataFusion query context.
164    pub async fn register_iceberg_catalog(
165        &self,
166        catalog_name: &str,
167        catalog: Arc<dyn Catalog>,
168    ) -> Result<()> {
169        tracing::info!(
170            "Registering Iceberg catalog '{}' into DataFusion SessionContext",
171            catalog_name
172        );
173        let provider = IcebergCatalogProvider::try_new(catalog).await?;
174        self.ctx.register_catalog(catalog_name, Arc::new(provider));
175        Ok(())
176    }
177
178    /// Executes a SQL transform query against registered tables.
179    pub async fn execute_sql(&self, sql: &str) -> Result<SendableRecordBatchStream> {
180        tracing::info!(
181            "Executing SQL transform via Apache DataFusion engine: {}",
182            sql
183        );
184        let df = self.ctx.sql(sql).await?;
185        let stream = df.execute_stream().await?;
186        Ok(stream)
187    }
188
189    /// Executes a full pipeline DAG tier by tier.
190    ///
191    /// Loads `materialize:` policy from `rbt_project.yml` when present (defaults to
192    /// lake-as-truth Parquet re-read for `ref()`).
193    ///
194    /// Before any model SQL runs, bronze sources declared in staging frontmatter are
195    /// registered via [`register_bronze_sources_for_dag`].
196    pub async fn execute_dag(
197        &self,
198        dag: &ModelDag,
199        project_dir: impl AsRef<Path>,
200        output_dir: impl AsRef<Path>,
201    ) -> Result<DagExecutionSummary> {
202        let project_dir = project_dir.as_ref();
203        let config = self.load_project_config(project_dir)?;
204        self.execute_dag_with_config(dag, project_dir, output_dir, &config)
205            .await
206    }
207
208    /// Like [`execute_dag`] but with an explicit [`MaterializeConfig`] (tests / library).
209    pub async fn execute_dag_with_materialize(
210        &self,
211        dag: &ModelDag,
212        project_dir: impl AsRef<Path>,
213        output_dir: impl AsRef<Path>,
214        materialize: &MaterializeConfig,
215    ) -> Result<DagExecutionSummary> {
216        let project_dir = project_dir.as_ref();
217        let mut config = (*self.load_project_config(project_dir)?).clone();
218        config.materialize = materialize.clone();
219        self.execute_dag_with_config(dag, project_dir, output_dir, &config)
220            .await
221    }
222
223    /// Preview a single model: materialize ancestors, then run target SQL with `LIMIT`.
224    ///
225    /// Does **not** write the target model to the lake. Bronze + ancestor `ref()` tables
226    /// are registered as for a normal run. `limit` is clamped to `1..=10_000`.
227    pub async fn preview_model(
228        &self,
229        full_dag: &ModelDag,
230        project_dir: impl AsRef<Path>,
231        output_dir: impl AsRef<Path>,
232        model_name: &str,
233        limit: usize,
234    ) -> Result<PreviewResult> {
235        let project_dir = project_dir.as_ref();
236        let config = self.load_project_config(project_dir)?;
237        let limit = limit.clamp(1, 10_000);
238
239        let sub = full_dag
240            .apply_select(Some(model_name), crate::core::SelectMode::Execute)
241            .with_context(|| {
242                format!(
243                    "E_RBT_PREVIEW: cannot select model '{model_name}' (check name / --select)"
244                )
245            })?;
246        let seq = sub.topological_sequence()?;
247        let target = seq
248            .iter()
249            .find(|m| m.name == model_name)
250            .cloned()
251            .ok_or_else(|| {
252                anyhow::anyhow!("E_RBT_PREVIEW: model '{model_name}' not found in project DAG")
253            })?;
254        self.preview_model_inner(full_dag, project_dir, output_dir, &target, limit, &config)
255            .await
256    }
257
258    async fn preview_model_inner(
259        &self,
260        full_dag: &ModelDag,
261        project_dir: &Path,
262        output_dir: impl AsRef<Path>,
263        target: &ModelNode,
264        limit: usize,
265        config: &RbtProjectConfig,
266    ) -> Result<PreviewResult> {
267        let output_dir = output_dir.as_ref();
268        let sub = full_dag
269            .apply_select(Some(&target.name), crate::core::SelectMode::Execute)?;
270        let seq = sub.topological_sequence()?;
271        let ancestor_names: Vec<String> = seq
272            .iter()
273            .map(|m| m.name.clone())
274            .filter(|n| n != &target.name)
275            .collect();
276
277        let mut ancestors_executed = 0usize;
278        if !ancestor_names.is_empty() {
279            let anc_select = ancestor_names.join(",");
280            let anc_dag = full_dag
281                .apply_select(Some(&anc_select), crate::core::SelectMode::Execute)
282                .context("E_RBT_PREVIEW: ancestor select failed")?;
283            let summary = self
284                .execute_dag_with_config(&anc_dag, project_dir, output_dir, config)
285                .await
286                .context("E_RBT_PREVIEW: ancestor materialize failed")?;
287            ancestors_executed = summary.models_executed;
288        } else {
289            // Still need bronze for staging-only preview
290            let mut registered = HashSet::new();
291            register_bronze_sources_for_dag(
292                &self.ctx,
293                &sub,
294                project_dir,
295                &mut registered,
296                config,
297            )
298            .await
299            .context("E_RBT_PREVIEW: bronze registration failed")?;
300        }
301
302        // Ensure target bronze contract is registered (staging models).
303        let mut registered = HashSet::new();
304        register_bronze_for_model(&self.ctx, target, project_dir, &mut registered, config)
305            .await?;
306
307        let preview_sql = format!(
308            "SELECT * FROM (\n{}\n) AS _rbt_preview LIMIT {}",
309            target.compiled_sql.trim().trim_end_matches(';'),
310            limit
311        );
312        let df = self.ctx.sql(&preview_sql).await.with_context(|| {
313            format!(
314                "E_RBT_PREVIEW: SQL failed for model '{}': {preview_sql}",
315                target.name
316            )
317        })?;
318        let batches = df.collect().await.with_context(|| {
319            format!("E_RBT_PREVIEW: collect failed for model '{}'", target.name)
320        })?;
321        let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
322
323        Ok(PreviewResult {
324            model: target.name.clone(),
325            compiled_sql: target.compiled_sql.clone(),
326            limit,
327            rows,
328            batches,
329            ancestors_executed,
330        })
331    }
332
333    /// Full DAG execution with a pre-loaded project config (roots, scan limits, materialize).
334    pub async fn execute_dag_with_config(
335        &self,
336        dag: &ModelDag,
337        project_dir: impl AsRef<Path>,
338        output_dir: impl AsRef<Path>,
339        config: &RbtProjectConfig,
340    ) -> Result<DagExecutionSummary> {
341        self.execute_dag_with_scope(dag, project_dir, output_dir, config, &RunScope::default())
342            .await
343    }
344
345    /// DAG execution with run scope (vars, partition binds, fingerprint skip, receipts).
346    pub async fn execute_dag_with_scope(
347        &self,
348        dag: &ModelDag,
349        project_dir: impl AsRef<Path>,
350        output_dir: impl AsRef<Path>,
351        config: &RbtProjectConfig,
352        scope: &RunScope,
353    ) -> Result<DagExecutionSummary> {
354        let project_dir = project_dir.as_ref();
355        let output_base = output_dir.as_ref();
356        let materialize = &config.materialize;
357        tokio::fs::create_dir_all(output_base).await?;
358
359        let started = now_unix_ms();
360        let run_id = scope.resolve_run_id();
361        let scope_key = scope.scope_key();
362        let contract = effective_contract_version(config, scope);
363        let fp = bronze_fingerprint(dag, project_dir, config, scope)
364            .context("E_RBT_FINGERPRINT: bronze fingerprint failed")?;
365
366        if scope.skip_if_fingerprint_match {
367            if let Some(prev) = RunReceipt::load_latest_for_scope(project_dir, &scope_key) {
368                if prev.status == RunStatus::Ok
369                    && prev.bronze_fingerprint == fp
370                    && prev.contract_version == contract
371                {
372                    let finished = now_unix_ms();
373                    let mut summary = DagExecutionSummary {
374                        models_executed: 0,
375                        total_rows_produced: 0,
376                        bronze_sources_registered: 0,
377                        skipped: true,
378                        skip_reason: Some(
379                            "bronze fingerprint + contract_version match previous successful receipt"
380                                .into(),
381                        ),
382                        bronze_fingerprint: Some(fp.clone()),
383                        run_id: Some(run_id.clone()),
384                        receipt_path: None,
385                        model_results: Vec::new(),
386                    };
387                    if scope.write_receipt {
388                        let receipt = RunReceipt {
389                            schema_version: RunReceipt::SCHEMA_VERSION,
390                            run_id: run_id.clone(),
391                            project: config.name.clone(),
392                            package_version: crate::VERSION.into(),
393                            contract_version: contract.clone(),
394                            scope_key: scope_key.clone(),
395                            vars: scope.vars.clone(),
396                            status: RunStatus::Skipped,
397                            skipped: true,
398                            skip_reason: summary.skip_reason.clone(),
399                            bronze_fingerprint: fp.clone(),
400                            models_executed: 0,
401                            total_rows: 0,
402                            bronze_sources: 0,
403                            model_results: Vec::new(),
404                            started_unix_ms: started,
405                            finished_unix_ms: finished,
406                            wall_ms: finished.saturating_sub(started),
407                            error: None,
408                        };
409                        summary.receipt_path = Some(receipt.write(project_dir)?);
410                    }
411                    tracing::info!(
412                        "E_RBT_SKIP: identical bronze fingerprint for scope {scope_key}; skipping materialize"
413                    );
414                    return Ok(summary);
415                }
416            }
417        }
418
419        let mut registered = HashSet::new();
420        let bronze_sources_registered = register_bronze_sources_for_dag_scoped(
421            &self.ctx,
422            dag,
423            project_dir,
424            &mut registered,
425            config,
426            Some(scope),
427        )
428        .await
429        .context("frontmatter-driven bronze registration failed")?;
430
431        let tiers = dag.execution_tiers()?;
432        let mut models_executed = 0;
433        let mut total_rows_produced = 0;
434        let mut model_results: Vec<ModelRunResult> = Vec::new();
435        let wap_run_id = if materialize.wap {
436            Some(new_wap_run_id())
437        } else {
438            None
439        };
440
441        for (tier_idx, tier) in tiers.iter().enumerate() {
442            tracing::info!(
443                "Executing DAG Tier {} with {} parallel models",
444                tier_idx,
445                tier.len()
446            );
447
448            for model in tier {
449                tracing::info!("Executing model '{}'...", model.name);
450
451                // Late-bind: if this model carries frontmatter not registered yet
452                register_bronze_for_model_scoped(
453                    &self.ctx,
454                    model,
455                    project_dir,
456                    &mut registered,
457                    config,
458                    Some(scope),
459                )
460                .await?;
461
462                let dest_path = model
463                    .output_path
464                    .as_ref()
465                    .map(PathBuf::from)
466                    .unwrap_or_else(|| match model.output_format {
467                        OutputFormat::Iceberg => output_base.join(&model.name),
468                        OutputFormat::Jsonl => output_base.join(format!("{}.jsonl", model.name)),
469                        OutputFormat::Csv => output_base.join(format!("{}.csv", model.name)),
470                        _ => output_base.join(format!("{}.parquet", model.name)),
471                    });
472
473                if let Some(parent) = dest_path.parent() {
474                    std::fs::create_dir_all(parent)?;
475                }
476
477                let (assertions, fail_on_error) = model_assertions(model);
478                let mut write_opts =
479                    MaterializeWriteOptions::from_config(materialize, fail_on_error);
480                if model
481                    .frontmatter
482                    .as_ref()
483                    .map(|f| f.wants_lineage_stamp())
484                    .unwrap_or(false)
485                {
486                    write_opts = write_opts.with_lineage(LineageStamp {
487                        run_id: run_id.clone(),
488                        contract_version: contract.clone(),
489                        model: model.name.clone(),
490                        bronze_fingerprint: Some(fp.clone()),
491                    });
492                }
493                let mode = materialize.effective_mode();
494
495                // WAP: write to stage path first; publish only after audit.
496                let (write_path, wap_paths) = if let Some(ref run_id) = wap_run_id {
497                    if matches!(
498                        model.output_format,
499                        OutputFormat::Parquet | OutputFormat::ZeroCopyClone
500                    ) && model.materialization != Materialization::IncrementalAppend
501                    {
502                        let paths =
503                            WapModelPaths::for_model(project_dir, run_id, &model.name, &dest_path);
504                        if let Some(p) = paths.stage_path.parent() {
505                            std::fs::create_dir_all(p)?;
506                        }
507                        (paths.stage_path.clone(), Some(paths))
508                    } else {
509                        (dest_path.clone(), None)
510                    }
511                } else {
512                    (dest_path.clone(), None)
513                };
514
515                let (row_count, write_stats) = match (
516                    &model.materialization,
517                    &model.output_format,
518                    mode,
519                ) {
520                    (
521                        Materialization::IncrementalAppend,
522                        OutputFormat::Parquet | OutputFormat::ZeroCopyClone,
523                        MaterializeMode::Stream,
524                    ) => {
525                        let df = self.ctx.sql(&model.compiled_sql).await.with_context(|| {
526                            format!("E_RBT_SQL: model '{}'", model.name)
527                        })?;
528                        let stream = df.execute_stream().await?;
529                        let stats = materialize_incremental_append_stream(
530                            stream,
531                            &dest_path,
532                            &write_opts,
533                            &assertions,
534                        )
535                        .await
536                        .with_context(|| {
537                            format!(
538                                "E_RBT_INCREMENTAL: model '{}' append failed",
539                                model.name
540                            )
541                        })?;
542                        log_assertion_result(model, &stats, fail_on_error)?;
543                        (stats.rows, Some(stats))
544                    }
545                    (
546                        Materialization::IncrementalMerge,
547                        _,
548                        _,
549                    ) => {
550                        bail!(
551                            "E_RBT_INCREMENTAL: model '{}': incremental_merge is not implemented yet \
552                             (use incremental_append for part-file appends)",
553                            model.name
554                        );
555                    }
556                    (_, _, MaterializeMode::Stream) => {
557                        let stats = execute_model_stream(
558                            &self.ctx,
559                            model,
560                            &write_path,
561                            &write_opts,
562                            &assertions,
563                            fail_on_error,
564                        )
565                        .await?;
566                        (stats.rows, Some(stats))
567                    }
568                    (_, _, MaterializeMode::Collect) => {
569                        let rows = execute_model_collect(
570                            &self.ctx,
571                            model,
572                            &write_path,
573                            &write_opts,
574                            &assertions,
575                            fail_on_error,
576                        )
577                        .await?;
578                        (rows, None)
579                    }
580                };
581
582                // WAP publish after successful write+audit (stream assertions already applied).
583                if let Some(ref paths) = wap_paths {
584                    let validation = write_stats
585                        .as_ref()
586                        .map(|s| s.validation.clone())
587                        .unwrap_or_else(|| crate::testing::ValidationResult {
588                            total_rows: row_count,
589                            passed_assertions: 0,
590                            failed_assertions: 0,
591                            errors: Vec::new(),
592                        });
593                    wap_publish(paths, &model.name, row_count, &validation)?;
594                }
595
596                // Expose model for downstream {{ ref() }} per project materialize policy.
597                if row_count > 0
598                    || matches!(
599                        model.output_format,
600                        OutputFormat::Parquet
601                            | OutputFormat::Iceberg
602                            | OutputFormat::ParquetAndIceberg
603                            | OutputFormat::ZeroCopyClone
604                    )
605                {
606                    let backend = materialize.choose_ref_backend(row_count);
607                    if row_count > 0 {
608                        let ref_path = if model.materialization == Materialization::IncrementalAppend
609                            && matches!(
610                                model.output_format,
611                                OutputFormat::Parquet | OutputFormat::ZeroCopyClone
612                            ) {
613                            incremental_ref_path(&dest_path)
614                        } else {
615                            dest_path.clone()
616                        };
617                        register_model_for_ref(
618                            &self.ctx,
619                            &model.name,
620                            &model.output_format,
621                            &ref_path,
622                            backend,
623                        )
624                        .await
625                        .with_context(|| {
626                            format!(
627                                "E_RBT_REF_REGISTER: model '{}' (backend={:?}, rows={}, mode={:?})",
628                                model.name, backend, row_count, mode
629                            )
630                        })?;
631                        tracing::debug!(
632                            model = %model.name,
633                            rows = row_count,
634                            ?backend,
635                            ?mode,
636                            strategy = ?materialize.ref_strategy,
637                            mat = ?model.materialization,
638                            "registered model for ref()"
639                        );
640                    }
641                }
642
643                // Relationship tests after model is registered for ref() (parent dims must exist).
644                if let Some(fm) = model.frontmatter.as_ref() {
645                    if let Some(tests) = fm.tests.as_ref() {
646                        if let Some(rels) = tests.relationships.as_ref() {
647                            if !rels.is_empty() {
648                                check_relationships(
649                                    &self.ctx,
650                                    &model.name,
651                                    rels,
652                                    tests.should_fail_on_error(),
653                                )
654                                .await?;
655                            }
656                        }
657                    }
658                }
659
660                models_executed += 1;
661                total_rows_produced += row_count;
662                model_results.push(ModelRunResult {
663                    name: model.name.clone(),
664                    rows: row_count,
665                    output_path: Some(dest_path.display().to_string()),
666                });
667            }
668        }
669
670        let finished = now_unix_ms();
671        let mut summary = DagExecutionSummary {
672            models_executed,
673            total_rows_produced,
674            bronze_sources_registered,
675            skipped: false,
676            skip_reason: None,
677            bronze_fingerprint: Some(fp.clone()),
678            run_id: Some(run_id.clone()),
679            receipt_path: None,
680            model_results: model_results.clone(),
681        };
682
683        if scope.write_receipt {
684            let receipt = RunReceipt {
685                schema_version: RunReceipt::SCHEMA_VERSION,
686                run_id: run_id.clone(),
687                project: config.name.clone(),
688                package_version: crate::VERSION.into(),
689                contract_version: contract,
690                scope_key,
691                vars: scope.vars.clone(),
692                status: RunStatus::Ok,
693                skipped: false,
694                skip_reason: None,
695                bronze_fingerprint: fp,
696                models_executed,
697                total_rows: total_rows_produced,
698                bronze_sources: bronze_sources_registered,
699                model_results,
700                started_unix_ms: started,
701                finished_unix_ms: finished,
702                wall_ms: finished.saturating_sub(started),
703                error: None,
704            };
705            summary.receipt_path = Some(receipt.write(project_dir)?);
706        }
707
708        Ok(summary)
709    }
710}
711
712/// Build frontmatter assertion list + fail-on-error policy for a model.
713fn model_assertions(model: &ModelNode) -> (Vec<Assertion>, bool) {
714    let mut fail_on_error = true;
715    let assertions = if let Some(fm) = model.frontmatter.as_ref() {
716        if let Some(tests) = fm.tests.as_ref() {
717            fail_on_error = tests.should_fail_on_error();
718            if tests.is_empty() {
719                Vec::new()
720            } else {
721                let unique = tests
722                    .unique
723                    .clone()
724                    .or_else(|| fm.unique_key.clone())
725                    .or_else(|| fm.grain.clone());
726                assertions_from_model_tests(
727                    tests.not_null.as_deref(),
728                    unique.as_deref(),
729                    tests.accepted_values.as_ref(),
730                )
731            }
732        } else if let Some(uk) = fm
733            .unique_key
734            .as_ref()
735            .or(fm.grain.as_ref())
736            .filter(|v| !v.is_empty())
737        {
738            fail_on_error = true;
739            assertions_from_model_tests(None, Some(uk.as_slice()), None)
740        } else {
741            Vec::new()
742        }
743    } else {
744        Vec::new()
745    };
746    (assertions, fail_on_error)
747}
748
749fn log_assertion_result(
750    model: &ModelNode,
751    stats: &StreamWriteStats,
752    fail_on_error: bool,
753) -> Result<()> {
754    if stats.validation.failed_assertions > 0 {
755        let msg = format!(
756            "model '{}' failed {} test(s): {}",
757            model.name,
758            stats.validation.failed_assertions,
759            stats.validation.errors.join("; ")
760        );
761        if fail_on_error {
762            bail!("{msg}");
763        }
764        tracing::warn!("{msg}");
765    } else if stats.validation.passed_assertions > 0 {
766        tracing::info!(
767            "model '{}': {} assertion(s) passed ({} rows)",
768            model.name,
769            stats.validation.passed_assertions,
770            stats.rows
771        );
772    }
773    Ok(())
774}
775
776async fn execute_model_stream(
777    ctx: &SessionContext,
778    model: &ModelNode,
779    dest_path: &Path,
780    write_opts: &MaterializeWriteOptions,
781    assertions: &[Assertion],
782    fail_on_error: bool,
783) -> Result<StreamWriteStats> {
784    let df = ctx.sql(&model.compiled_sql).await.with_context(|| {
785        format!(
786            "E_RBT_SQL: execution failed for model '{}' (compiled: {})",
787            model.name, model.compiled_sql
788        )
789    })?;
790    let stream = df.execute_stream().await.with_context(|| {
791        format!(
792            "E_RBT_SQL: execute_stream failed for model '{}'",
793            model.name
794        )
795    })?;
796
797    let stats = materialize_stream(
798        stream,
799        &model.output_format,
800        dest_path,
801        write_opts,
802        assertions,
803    )
804    .await
805    .with_context(|| {
806        format!(
807            "E_RBT_MATERIALIZE: stream write failed for model '{}' → {}",
808            model.name,
809            dest_path.display()
810        )
811    })?;
812
813    if stats.validation.failed_assertions > 0 {
814        let msg = format!(
815            "model '{}' failed {} test(s): {}",
816            model.name,
817            stats.validation.failed_assertions,
818            stats.validation.errors.join("; ")
819        );
820        if fail_on_error {
821            bail!("{msg}");
822        }
823        tracing::warn!("{msg}");
824    } else if !assertions.is_empty() {
825        tracing::info!(
826            "model '{}': {} assertion(s) passed ({} rows, {} batches, stream)",
827            model.name,
828            stats.validation.passed_assertions,
829            stats.rows,
830            stats.batches
831        );
832    } else {
833        tracing::debug!(
834            model = %model.name,
835            rows = stats.rows,
836            batches = stats.batches,
837            bytes = stats.bytes_written,
838            "stream materialize complete"
839        );
840    }
841    Ok(stats)
842}
843
844async fn execute_model_collect(
845    ctx: &SessionContext,
846    model: &ModelNode,
847    dest_path: &Path,
848    write_opts: &MaterializeWriteOptions,
849    assertions: &[Assertion],
850    fail_on_error: bool,
851) -> Result<usize> {
852    let df = ctx.sql(&model.compiled_sql).await.with_context(|| {
853        format!(
854            "E_RBT_SQL: execution failed for model '{}' (compiled: {})",
855            model.name, model.compiled_sql
856        )
857    })?;
858    let mut batches = df.collect().await.with_context(|| {
859        format!(
860            "E_RBT_SQL: collect failed for model '{}'",
861            model.name
862        )
863    })?;
864    if let Some(ref lin) = write_opts.lineage {
865        batches = batches
866            .iter()
867            .map(|b| stamp_batch(b, lin))
868            .collect::<Result<Vec<_>>>()?;
869    }
870    let row_count: usize = batches.iter().map(|b| b.num_rows()).sum();
871
872    MultiFormatWriter::write_batches(&batches, &model.output_format, dest_path)?;
873    // Prefer atomic parquet path for primary formats already handled inside MultiFormatWriter.
874
875    if !assertions.is_empty() {
876        let result = RecordBatchValidator::validate_batches(&batches, assertions);
877        if result.failed_assertions > 0 {
878            let msg = format!(
879                "model '{}' failed {} test(s): {}",
880                model.name,
881                result.failed_assertions,
882                result.errors.join("; ")
883            );
884            if fail_on_error {
885                bail!("{msg}");
886            }
887            tracing::warn!("{msg}");
888        } else {
889            tracing::info!(
890                "model '{}': {} assertion(s) passed ({} rows, collect)",
891                model.name,
892                result.passed_assertions,
893                result.total_rows
894            );
895        }
896    }
897
898    Ok(row_count)
899}
900
901/// FK-ish relationship checks: orphan child keys vs parent model table.
902async fn check_relationships(
903    ctx: &SessionContext,
904    child_model: &str,
905    rels: &[crate::core::frontmatter::RelationshipTest],
906    fail_on_error: bool,
907) -> Result<()> {
908    for rel in rels {
909        let parent_col = rel.parent_column();
910        // Escape double-quotes in identifiers lightly (model names are [a-z0-9_]).
911        let sql = format!(
912            r#"SELECT COUNT(*) AS orphan_cnt FROM "{child}" c
913WHERE c."{child_col}" IS NOT NULL
914  AND NOT EXISTS (
915    SELECT 1 FROM "{parent}" p WHERE p."{parent_col}" = c."{child_col}"
916  )"#,
917            child = child_model.replace('"', ""),
918            child_col = rel.column.replace('"', ""),
919            parent = rel.to_model.replace('"', ""),
920            parent_col = parent_col.replace('"', ""),
921        );
922        let df = ctx.sql(&sql).await.with_context(|| {
923            format!(
924                "E_RBT_RELATIONSHIP: SQL failed for {}.{} → {}.{}: {sql}",
925                child_model, rel.column, rel.to_model, parent_col
926            )
927        })?;
928        let batches = df.collect().await?;
929        use arrow::array::Array;
930        let orphans = batches
931            .first()
932            .map(|b| {
933                let col = b.column(0);
934                if col.len() == 0 || col.is_null(0) {
935                    return 0i64;
936                }
937                if let Some(a) = col.as_any().downcast_ref::<arrow::array::Int64Array>() {
938                    return a.value(0);
939                }
940                if let Some(a) = col.as_any().downcast_ref::<arrow::array::UInt64Array>() {
941                    return a.value(0) as i64;
942                }
943                if let Some(a) = col.as_any().downcast_ref::<arrow::array::Int32Array>() {
944                    return i64::from(a.value(0));
945                }
946                0
947            })
948            .unwrap_or(0);
949        if orphans > 0 {
950            let msg = format!(
951                "E_RBT_RELATIONSHIP: model '{child_model}' column '{}' has {orphans} value(s) \
952                 not found in {}.{}",
953                rel.column, rel.to_model, parent_col
954            );
955            if fail_on_error {
956                bail!("{msg}");
957            }
958            tracing::warn!("{msg}");
959        } else {
960            tracing::info!(
961                "model '{}': relationship {} → {}.{} ok",
962                child_model,
963                rel.column,
964                rel.to_model,
965                parent_col
966            );
967        }
968    }
969    Ok(())
970}
971
972/// Path used to re-read a model from the lake after materialize.
973fn lake_read_path(format: &OutputFormat, dest_path: &Path) -> PathBuf {
974    match format {
975        OutputFormat::Iceberg => {
976            // Catalog SoR: prefer `.rbt_iceberg_data` hint, then any parquet under table root.
977            let hint = dest_path.join(".rbt_iceberg_data");
978            if let Ok(p) = std::fs::read_to_string(&hint) {
979                let p = PathBuf::from(p.trim());
980                if p.exists() {
981                    return p;
982                }
983            }
984            let preferred = dest_path.join("data/part-00000.parquet");
985            if preferred.exists() {
986                preferred
987            } else if let Some(p) = find_first_parquet_under(dest_path) {
988                p
989            } else {
990                preferred
991            }
992        }
993        OutputFormat::ParquetAndIceberg => {
994            // Flat parquet is the primary dual-write artifact for ref().
995            if dest_path.extension().and_then(|e| e.to_str()) == Some("parquet") {
996                dest_path.to_path_buf()
997            } else {
998                dest_path.with_extension("parquet")
999            }
1000        }
1001        _ => dest_path.to_path_buf(),
1002    }
1003}
1004
1005fn find_first_parquet_under(dir: &Path) -> Option<PathBuf> {
1006    let mut stack = vec![dir.to_path_buf()];
1007    while let Some(d) = stack.pop() {
1008        let entries = std::fs::read_dir(&d).ok()?;
1009        for e in entries.flatten() {
1010            let p = e.path();
1011            if p.is_dir() {
1012                stack.push(p);
1013            } else if p.extension().and_then(|x| x.to_str()) == Some("parquet") {
1014                return Some(p);
1015            }
1016        }
1017    }
1018    None
1019}
1020
1021/// Register a completed model so later SQL `ref('name')` resolves.
1022///
1023/// Never requires an in-memory `Vec<RecordBatch>` for the default lake-file backend.
1024/// MemTable backend re-reads the written lake path (only used for small tables).
1025async fn register_model_for_ref(
1026    ctx: &SessionContext,
1027    name: &str,
1028    format: &OutputFormat,
1029    dest_path: &Path,
1030    backend: RefBackend,
1031) -> Result<()> {
1032    let _ = ctx.deregister_table(name);
1033
1034    match backend {
1035        RefBackend::MemTable => {
1036            let batches = match format {
1037                OutputFormat::Parquet
1038                | OutputFormat::ZeroCopyClone
1039                | OutputFormat::Iceberg
1040                | OutputFormat::ParquetAndIceberg => {
1041                    let path = resolve_lake_read_path(format, dest_path)?;
1042                    load_parquet_batches(&path).with_context(|| {
1043                        format!(
1044                            "E_RBT_REF_MEMTABLE: load {} for ref('{name}')",
1045                            path.display()
1046                        )
1047                    })?
1048                }
1049                OutputFormat::Jsonl | OutputFormat::Csv => {
1050                    // Register lake file then collect into MemTable (small tables only).
1051                    Box::pin(async {
1052                        // temporarily use lake registration path then table scan
1053                        register_model_for_ref(ctx, name, format, dest_path, RefBackend::LakeFile)
1054                            .await?;
1055                        let df = ctx.table(name).await.map_err(|e| {
1056                            anyhow::anyhow!("E_RBT_REF_MEMTABLE: table '{name}': {e}")
1057                        })?;
1058                        df.collect().await.map_err(|e| {
1059                            anyhow::anyhow!("E_RBT_REF_MEMTABLE: collect '{name}': {e}")
1060                        })
1061                    })
1062                    .await?
1063                }
1064            };
1065            if batches.is_empty() {
1066                bail!("E_RBT_REF_MEMTABLE: no batches for ref('{name}')");
1067            }
1068            let _ = ctx.deregister_table(name);
1069            let schema = batches[0].schema();
1070            let mem_table = MemTable::try_new(schema, vec![batches])
1071                .map_err(|e| anyhow::anyhow!("MemTable::try_new: {e}"))?;
1072            ctx.register_table(name, Arc::new(mem_table))
1073                .map_err(|e| anyhow::anyhow!("register_table MemTable: {e}"))?;
1074        }
1075        RefBackend::LakeFile => match format {
1076            OutputFormat::Parquet
1077            | OutputFormat::ZeroCopyClone
1078            | OutputFormat::Iceberg
1079            | OutputFormat::ParquetAndIceberg => {
1080                let path = resolve_lake_read_path(format, dest_path)?;
1081                ctx.register_parquet(
1082                    name,
1083                    path.to_str().unwrap_or_default(),
1084                    ParquetReadOptions::default(),
1085                )
1086                .await
1087                .map_err(|e| {
1088                    anyhow::anyhow!(
1089                        "E_RBT_REF_REGISTER: register_parquet {} for '{name}': {e}",
1090                        path.display()
1091                    )
1092                })?;
1093            }
1094            OutputFormat::Jsonl => {
1095                let p = dest_path.to_str().unwrap_or_default();
1096                let opts = JsonReadOptions::default()
1097                    .file_extension(".jsonl")
1098                    .newline_delimited(true);
1099                if let Err(e) = ctx.register_json(name, p, opts).await {
1100                    tracing::debug!("jsonl register failed ({e}); retry default");
1101                    ctx.register_json(name, p, JsonReadOptions::default())
1102                        .await
1103                        .map_err(|e| anyhow::anyhow!("E_RBT_REF_REGISTER: register_json: {e}"))?;
1104                }
1105            }
1106            OutputFormat::Csv => {
1107                ctx.register_csv(
1108                    name,
1109                    dest_path.to_str().unwrap_or_default(),
1110                    CsvReadOptions::default(),
1111                )
1112                .await
1113                .map_err(|e| anyhow::anyhow!("E_RBT_REF_REGISTER: register_csv: {e}"))?;
1114            }
1115        },
1116    }
1117    Ok(())
1118}
1119
1120fn resolve_lake_read_path(format: &OutputFormat, dest_path: &Path) -> Result<PathBuf> {
1121    let mut path = lake_read_path(format, dest_path);
1122    if !path.exists() && matches!(format, OutputFormat::ParquetAndIceberg) {
1123        let alt = sibling_iceberg_dir(dest_path).join("data/part-00000.parquet");
1124        if alt.exists() {
1125            path = alt;
1126        }
1127    }
1128    if !path.exists() {
1129        bail!(
1130            "E_RBT_REF_MISSING: lake file missing for ref(): expected {}",
1131            path.display()
1132        );
1133    }
1134    Ok(path)
1135}
1136
1137#[cfg(test)]
1138mod tests {
1139    use super::*;
1140    use crate::core::dag::{Materialization, ModelDag, OutputFormat};
1141
1142    #[tokio::test]
1143    async fn test_engine_initialization() -> Result<()> {
1144        let engine = TransformationEngine::new();
1145        let df = engine.ctx.sql("SELECT 1 AS col").await?;
1146        let batches = df.collect().await?;
1147        assert_eq!(batches.len(), 1);
1148        assert_eq!(batches[0].num_rows(), 1);
1149        Ok(())
1150    }
1151
1152    #[tokio::test]
1153    async fn test_dag_execution_multi_format() -> Result<()> {
1154        let temp_dir = tempfile::tempdir()?;
1155        let engine = TransformationEngine::new();
1156
1157        let mut dag = ModelDag::new();
1158        dag.add_model_with_format(
1159            "users",
1160            "SELECT 1 AS id, 'Alice' AS name",
1161            Materialization::Table,
1162            OutputFormat::Jsonl,
1163            None,
1164            "",
1165        )?;
1166        dag.add_model_with_format(
1167            "active_users",
1168            "SELECT * FROM {{ ref('users') }} WHERE id = 1",
1169            Materialization::Table,
1170            OutputFormat::Parquet,
1171            None,
1172            "",
1173        )?;
1174        dag.build_graph()?;
1175
1176        let summary = engine
1177            .execute_dag(&dag, temp_dir.path(), temp_dir.path())
1178            .await?;
1179        assert_eq!(summary.models_executed, 2);
1180        assert_eq!(summary.total_rows_produced, 2);
1181        assert!(temp_dir.path().join("users.jsonl").exists());
1182        assert!(temp_dir.path().join("active_users.parquet").exists());
1183        Ok(())
1184    }
1185
1186    #[tokio::test]
1187    async fn test_frontmatter_bronze_end_to_end() -> Result<()> {
1188        let temp = tempfile::tempdir()?;
1189        let bronze_dir = temp.path().join("lake/bronze");
1190        std::fs::create_dir_all(&bronze_dir)?;
1191        std::fs::write(
1192            bronze_dir.join("raw_stock_trades.jsonl"),
1193            r#"{"ticker":"NVDA","timestamp":"2026-07-24T09:30:01Z","price":125.5,"volume":100}
1194{"ticker":"AAPL","timestamp":"2026-07-24T09:30:05Z","price":190.0,"volume":50}
1195"#,
1196        )?;
1197
1198        let sql = r#"---
1199source_format: jsonl
1200scan_path: "lake/bronze/raw_stock_trades.jsonl"
1201---
1202SELECT ticker, price, volume FROM {{ source('bronze', 'raw_stock_trades') }}
1203"#;
1204
1205        let mut dag = ModelDag::new();
1206        dag.add_model_with_format(
1207            "stg_stock_trades",
1208            sql,
1209            Materialization::Table,
1210            OutputFormat::Parquet,
1211            Some(
1212                temp.path()
1213                    .join("lake/silver/stg_stock_trades.parquet")
1214                    .to_string_lossy()
1215                    .into(),
1216            ),
1217            "",
1218        )?;
1219        dag.build_graph()?;
1220
1221        let engine = TransformationEngine::new();
1222        let summary = engine
1223            .execute_dag(&dag, temp.path(), temp.path().join("out"))
1224            .await?;
1225        assert_eq!(summary.bronze_sources_registered, 1);
1226        assert_eq!(summary.models_executed, 1);
1227        assert_eq!(summary.total_rows_produced, 2);
1228        assert!(temp
1229            .path()
1230            .join("lake/silver/stg_stock_trades.parquet")
1231            .exists());
1232        Ok(())
1233    }
1234
1235    #[tokio::test]
1236    async fn test_ref_via_parquet_reread_default() -> Result<()> {
1237        use crate::core::project::{MaterializeConfig, RefStrategy};
1238
1239        let temp = tempfile::tempdir()?;
1240        let mut dag = ModelDag::new();
1241        dag.add_model_with_format(
1242            "stg_a",
1243            "SELECT 1 AS id, 10 AS v UNION ALL SELECT 2, 20",
1244            Materialization::Table,
1245            OutputFormat::Parquet,
1246            Some(temp.path().join("stg_a.parquet").to_string_lossy().into()),
1247            "",
1248        )?;
1249        dag.add_model_with_format(
1250            "tf_b",
1251            "SELECT id, v * 2 AS v2 FROM {{ ref('stg_a') }}",
1252            Materialization::Table,
1253            OutputFormat::Parquet,
1254            Some(temp.path().join("tf_b.parquet").to_string_lossy().into()),
1255            "",
1256        )?;
1257        dag.build_graph()?;
1258
1259        let mat = MaterializeConfig {
1260            ref_strategy: RefStrategy::Parquet,
1261            memtable_max_rows: 50_000,
1262            ..Default::default()
1263        };
1264        let engine = TransformationEngine::new();
1265        let summary = engine
1266            .execute_dag_with_materialize(&dag, temp.path(), temp.path(), &mat)
1267            .await?;
1268        assert_eq!(summary.models_executed, 2);
1269        assert_eq!(summary.total_rows_produced, 4);
1270        assert!(temp.path().join("tf_b.parquet").exists());
1271        Ok(())
1272    }
1273
1274    #[tokio::test]
1275    async fn test_ref_via_memtable_when_configured() -> Result<()> {
1276        use crate::core::project::{MaterializeConfig, RefStrategy};
1277
1278        let temp = tempfile::tempdir()?;
1279        let mut dag = ModelDag::new();
1280        dag.add_model_with_format(
1281            "stg_a",
1282            "SELECT 1 AS id UNION ALL SELECT 2",
1283            Materialization::Table,
1284            OutputFormat::Parquet,
1285            Some(temp.path().join("stg_a.parquet").to_string_lossy().into()),
1286            "",
1287        )?;
1288        dag.add_model_with_format(
1289            "tf_b",
1290            "SELECT count(*) AS c FROM {{ ref('stg_a') }}",
1291            Materialization::Table,
1292            OutputFormat::Parquet,
1293            Some(temp.path().join("tf_b.parquet").to_string_lossy().into()),
1294            "",
1295        )?;
1296        dag.build_graph()?;
1297
1298        let mat = MaterializeConfig {
1299            ref_strategy: RefStrategy::Memtable,
1300            memtable_max_rows: 50_000,
1301            ..Default::default()
1302        };
1303        let engine = TransformationEngine::new();
1304        let summary = engine
1305            .execute_dag_with_materialize(&dag, temp.path(), temp.path(), &mat)
1306            .await?;
1307        assert_eq!(summary.models_executed, 2);
1308        assert!(temp.path().join("tf_b.parquet").exists());
1309        Ok(())
1310    }
1311
1312    #[tokio::test]
1313    async fn test_memtable_falls_back_to_lake_above_cutoff() -> Result<()> {
1314        use crate::core::project::{MaterializeConfig, RefStrategy};
1315
1316        // Cutoff 1 → 2-row model must use lake re-read.
1317        let temp = tempfile::tempdir()?;
1318        let mut dag = ModelDag::new();
1319        dag.add_model_with_format(
1320            "stg_a",
1321            "SELECT 1 AS id UNION ALL SELECT 2",
1322            Materialization::Table,
1323            OutputFormat::Parquet,
1324            Some(temp.path().join("stg_a.parquet").to_string_lossy().into()),
1325            "",
1326        )?;
1327        dag.add_model_with_format(
1328            "tf_b",
1329            "SELECT * FROM {{ ref('stg_a') }}",
1330            Materialization::Table,
1331            OutputFormat::Parquet,
1332            Some(temp.path().join("tf_b.parquet").to_string_lossy().into()),
1333            "",
1334        )?;
1335        dag.build_graph()?;
1336
1337        let mat = MaterializeConfig {
1338            ref_strategy: RefStrategy::Memtable,
1339            memtable_max_rows: 1,
1340            ..Default::default()
1341        };
1342        let engine = TransformationEngine::new();
1343        let summary = engine
1344            .execute_dag_with_materialize(&dag, temp.path(), temp.path(), &mat)
1345            .await?;
1346        assert_eq!(summary.models_executed, 2);
1347        assert_eq!(summary.total_rows_produced, 4);
1348        Ok(())
1349    }
1350}